@hed-hog/lms 0.0.366 → 0.0.370

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 (169) hide show
  1. package/dist/certificate/certificate.controller.d.ts +1 -1
  2. package/dist/certificate/certificate.controller.d.ts.map +1 -1
  3. package/dist/certificate/certificate.controller.js +4 -2
  4. package/dist/certificate/certificate.controller.js.map +1 -1
  5. package/dist/certificate/certificate.service.d.ts +50 -0
  6. package/dist/certificate/certificate.service.d.ts.map +1 -1
  7. package/dist/certificate/certificate.service.js +73 -0
  8. package/dist/certificate/certificate.service.js.map +1 -1
  9. package/dist/course/course-ai-usage.service.d.ts +58 -0
  10. package/dist/course/course-ai-usage.service.d.ts.map +1 -0
  11. package/dist/course/course-ai-usage.service.js +176 -0
  12. package/dist/course/course-ai-usage.service.js.map +1 -0
  13. package/dist/course/course-audio-transcription.service.d.ts +65 -1
  14. package/dist/course/course-audio-transcription.service.d.ts.map +1 -1
  15. package/dist/course/course-audio-transcription.service.js +381 -29
  16. package/dist/course/course-audio-transcription.service.js.map +1 -1
  17. package/dist/course/course-export-scorm12.service.d.ts +3 -0
  18. package/dist/course/course-export-scorm12.service.d.ts.map +1 -1
  19. package/dist/course/course-export-scorm12.service.js +141 -6
  20. package/dist/course/course-export-scorm12.service.js.map +1 -1
  21. package/dist/course/course-export.service.d.ts.map +1 -1
  22. package/dist/course/course-export.service.js +2 -1
  23. package/dist/course/course-export.service.js.map +1 -1
  24. package/dist/course/course-lesson.controller.d.ts +25 -3
  25. package/dist/course/course-lesson.controller.d.ts.map +1 -1
  26. package/dist/course/course-lesson.controller.js +71 -8
  27. package/dist/course/course-lesson.controller.js.map +1 -1
  28. package/dist/course/course-structure.controller.d.ts +26 -5
  29. package/dist/course/course-structure.controller.d.ts.map +1 -1
  30. package/dist/course/course-structure.controller.js +31 -1
  31. package/dist/course/course-structure.controller.js.map +1 -1
  32. package/dist/course/course-structure.service.d.ts +37 -5
  33. package/dist/course/course-structure.service.d.ts.map +1 -1
  34. package/dist/course/course-structure.service.js +165 -20
  35. package/dist/course/course-structure.service.js.map +1 -1
  36. package/dist/course/course-transcription-translation.service.d.ts +31 -0
  37. package/dist/course/course-transcription-translation.service.d.ts.map +1 -0
  38. package/dist/course/course-transcription-translation.service.js +227 -0
  39. package/dist/course/course-transcription-translation.service.js.map +1 -0
  40. package/dist/course/course-video-agent-pipeline.service.js +7 -7
  41. package/dist/course/course-video-agent-pipeline.service.js.map +1 -1
  42. package/dist/course/course.module.d.ts.map +1 -1
  43. package/dist/course/course.module.js +4 -0
  44. package/dist/course/course.module.js.map +1 -1
  45. package/dist/course/dto/create-course-bulk-job.dto.d.ts +2 -1
  46. package/dist/course/dto/create-course-bulk-job.dto.d.ts.map +1 -1
  47. package/dist/course/dto/create-course-bulk-job.dto.js +6 -1
  48. package/dist/course/dto/create-course-bulk-job.dto.js.map +1 -1
  49. package/dist/course/dto/create-course-export.dto.d.ts +1 -0
  50. package/dist/course/dto/create-course-export.dto.d.ts.map +1 -1
  51. package/dist/course/dto/create-course-export.dto.js +6 -0
  52. package/dist/course/dto/create-course-export.dto.js.map +1 -1
  53. package/dist/course/lms-bulk-upload-automation.service.d.ts.map +1 -1
  54. package/dist/course/lms-bulk-upload-automation.service.js +26 -13
  55. package/dist/course/lms-bulk-upload-automation.service.js.map +1 -1
  56. package/dist/course/lms-bulk-upload.controller.d.ts +3 -0
  57. package/dist/course/lms-bulk-upload.controller.d.ts.map +1 -1
  58. package/dist/course/lms-bulk-upload.service.d.ts +3 -0
  59. package/dist/course/lms-bulk-upload.service.d.ts.map +1 -1
  60. package/dist/course/lms-bulk-upload.service.js +48 -29
  61. package/dist/course/lms-bulk-upload.service.js.map +1 -1
  62. package/dist/course/subtitle.util.d.ts +46 -0
  63. package/dist/course/subtitle.util.d.ts.map +1 -0
  64. package/dist/course/subtitle.util.js +206 -0
  65. package/dist/course/subtitle.util.js.map +1 -0
  66. package/dist/enterprise/training/training-student.service.d.ts +27 -0
  67. package/dist/enterprise/training/training-student.service.d.ts.map +1 -1
  68. package/dist/enterprise/training/training-student.service.js +197 -10
  69. package/dist/enterprise/training/training-student.service.js.map +1 -1
  70. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.d.ts +3 -1
  71. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.d.ts.map +1 -1
  72. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.js +19 -5
  73. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.js.map +1 -1
  74. package/dist/lesson-xp-map/lesson-xp-map.module.d.ts.map +1 -1
  75. package/dist/lesson-xp-map/lesson-xp-map.module.js +2 -1
  76. package/dist/lesson-xp-map/lesson-xp-map.module.js.map +1 -1
  77. package/dist/libraries/lms/tsconfig.tsbuildinfo +1 -1
  78. package/dist/lms.module.d.ts.map +1 -1
  79. package/dist/lms.module.js +4 -0
  80. package/dist/lms.module.js.map +1 -1
  81. package/dist/platforma/platforma-performance.service.js +121 -121
  82. package/dist/platforma/platforma-video.service.d.ts +8 -0
  83. package/dist/platforma/platforma-video.service.d.ts.map +1 -1
  84. package/dist/platforma/platforma-video.service.js +45 -2
  85. package/dist/platforma/platforma-video.service.js.map +1 -1
  86. package/dist/platforma/platforma.controller.d.ts +99 -1
  87. package/dist/platforma/platforma.controller.d.ts.map +1 -1
  88. package/dist/platforma/platforma.controller.js +111 -2
  89. package/dist/platforma/platforma.controller.js.map +1 -1
  90. package/dist/training/dto/create-training.dto.d.ts +9 -0
  91. package/dist/training/dto/create-training.dto.d.ts.map +1 -1
  92. package/dist/training/dto/create-training.dto.js +45 -1
  93. package/dist/training/dto/create-training.dto.js.map +1 -1
  94. package/dist/training/training.controller.d.ts +144 -0
  95. package/dist/training/training.controller.d.ts.map +1 -1
  96. package/dist/training/training.service.d.ts +149 -0
  97. package/dist/training/training.service.d.ts.map +1 -1
  98. package/dist/training/training.service.js +332 -167
  99. package/dist/training/training.service.js.map +1 -1
  100. package/hedhog/data/image_type.yaml +10 -0
  101. package/hedhog/data/route.yaml +251 -0
  102. package/hedhog/data/setting_group.yaml +97 -0
  103. package/hedhog/frontend/app/bulk-upload-sessions/page.tsx.ejs +139 -27
  104. package/hedhog/frontend/app/courses/[id]/_components/CourseFlagsCard.tsx.ejs +69 -57
  105. package/hedhog/frontend/app/courses/[id]/_components/CourseIssuedCertificatesCard.tsx.ejs +168 -0
  106. package/hedhog/frontend/app/courses/[id]/structure/_components/course-ai-costs-tab.tsx.ejs +191 -0
  107. package/hedhog/frontend/app/courses/[id]/structure/_components/course-export-sheet.tsx.ejs +81 -1
  108. package/hedhog/frontend/app/courses/[id]/structure/_components/course-exports-tab.tsx.ejs +12 -0
  109. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-course.tsx.ejs +69 -1
  110. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +267 -19
  111. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-bulk.tsx.ejs +114 -86
  112. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +239 -31
  113. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +344 -59
  114. package/hedhog/frontend/app/courses/[id]/structure/_components/lesson-video-preview.tsx.ejs +200 -0
  115. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +1 -0
  116. package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +3 -0
  117. package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +19 -7
  118. package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +4 -0
  119. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-ai-costs.ts.ejs +40 -0
  120. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-exports.ts.ejs +25 -0
  121. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-mutations.ts.ejs +148 -0
  122. package/hedhog/frontend/app/courses/[id]/structure/_data/use-transcription-segments.ts.ejs +157 -8
  123. package/hedhog/frontend/app/courses/_components/CourseRowActions.tsx.ejs +1 -22
  124. package/hedhog/frontend/app/courses/page.tsx.ejs +26 -4
  125. package/hedhog/frontend/app/paths/page.tsx.ejs +612 -164
  126. package/hedhog/frontend/messages/en.json +23 -12
  127. package/hedhog/frontend/messages/pt.json +23 -12
  128. package/hedhog/query/triggers.sql +33 -0
  129. package/hedhog/table/course_ai_usage.yaml +46 -0
  130. package/hedhog/table/course_lesson.yaml +3 -0
  131. package/hedhog/table/course_lesson_answer.yaml +37 -0
  132. package/hedhog/table/course_lesson_transcription_segment.yaml +8 -0
  133. package/hedhog/table/learning_path.yaml +6 -0
  134. package/hedhog/table/learning_path_module.yaml +22 -0
  135. package/hedhog/table/learning_path_step.yaml +9 -6
  136. package/hedhog/table/lesson_view_event.yaml +66 -66
  137. package/package.json +9 -9
  138. package/src/certificate/certificate.controller.ts +2 -0
  139. package/src/certificate/certificate.service.ts +99 -0
  140. package/src/course/course-ai-usage.service.ts +221 -0
  141. package/src/course/course-audio-transcription.service.ts +471 -43
  142. package/src/course/course-export-scorm12.service.ts +149 -5
  143. package/src/course/course-export.service.ts +1 -0
  144. package/src/course/course-lesson.controller.ts +59 -6
  145. package/src/course/course-structure.controller.ts +16 -0
  146. package/src/course/course-structure.service.ts +184 -10
  147. package/src/course/course-transcription-translation.service.ts +293 -0
  148. package/src/course/course-video-agent-pipeline.service.ts +471 -471
  149. package/src/course/course.module.ts +4 -0
  150. package/src/course/dto/create-course-bulk-job.dto.ts +7 -3
  151. package/src/course/dto/create-course-export.dto.ts +6 -0
  152. package/src/course/ffmpeg.util.ts +65 -65
  153. package/src/course/lms-bulk-upload-automation.service.ts +29 -7
  154. package/src/course/lms-bulk-upload.service.ts +20 -1
  155. package/src/course/subtitle.util.ts +220 -0
  156. package/src/enterprise/training/training-student.service.ts +224 -4
  157. package/src/lesson-xp-map/lesson-xp-ai-calculation.service.ts +14 -0
  158. package/src/lesson-xp-map/lesson-xp-map.module.ts +2 -1
  159. package/src/lms.module.ts +4 -0
  160. package/src/platforma/dto/heartbeat.dto.ts +30 -30
  161. package/src/platforma/handlers/emit-certificate.handler.ts +117 -117
  162. package/src/platforma/handlers/lesson-heartbeat.handler.ts +343 -343
  163. package/src/platforma/platforma-heartbeat.service.ts +33 -33
  164. package/src/platforma/platforma-performance.service.ts +606 -606
  165. package/src/platforma/platforma-search.service.ts +48 -48
  166. package/src/platforma/platforma-video.service.ts +59 -3
  167. package/src/platforma/platforma.controller.ts +88 -0
  168. package/src/training/dto/create-training.dto.ts +36 -0
  169. package/src/training/training.service.ts +360 -163
@@ -0,0 +1,200 @@
1
+ 'use client';
2
+
3
+ import { Button } from '@/components/ui/button';
4
+ import {
5
+ DropdownMenu,
6
+ DropdownMenuContent,
7
+ DropdownMenuItem,
8
+ DropdownMenuTrigger,
9
+ } from '@/components/ui/dropdown-menu';
10
+ import { useApp } from '@hed-hog/next-app-provider';
11
+ import type HlsType from 'hls.js';
12
+ import { ChevronDown, Loader2, Subtitles, VideoOff } from 'lucide-react';
13
+ import { useCallback, useEffect, useRef, useState } from 'react';
14
+ import type { TranscriptionLocale } from '../_data/use-transcription-segments';
15
+
16
+ interface LessonVideoPreviewProps {
17
+ lessonId: string;
18
+ locales?: TranscriptionLocale[];
19
+ }
20
+
21
+ export function LessonVideoPreview({ lessonId, locales = [] }: LessonVideoPreviewProps) {
22
+ const { request } = useApp();
23
+ const videoRef = useRef<HTMLVideoElement>(null);
24
+ const hlsRef = useRef<HlsType | null>(null);
25
+
26
+ const [hlsToken, setHlsToken] = useState<string | null>(null);
27
+ const [loadError, setLoadError] = useState<string | null>(null);
28
+ const [selectedLocaleId, setSelectedLocaleId] = useState<number | null>(null);
29
+ const [subtitleLabel, setSubtitleLabel] = useState<string>('Desativada');
30
+ const [subtitleLoading, setSubtitleLoading] = useState(false);
31
+
32
+ const numericId = parseInt(lessonId, 10);
33
+ const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL ?? '';
34
+
35
+ // Filter out null-id locales (no locale assigned)
36
+ const availableLocales = locales.filter((l) => l.id !== null);
37
+
38
+ // Fetch HLS token on mount
39
+ useEffect(() => {
40
+ if (isNaN(numericId)) return;
41
+ setHlsToken(null);
42
+ setLoadError(null);
43
+ setSelectedLocaleId(null);
44
+ setSubtitleLabel('Desativada');
45
+ request<{ token: string }>({
46
+ url: `/lms/lessons/${numericId}/preview/hls-token`,
47
+ method: 'GET',
48
+ })
49
+ .then((res) => setHlsToken(res.data.token))
50
+ .catch(() => setLoadError('Nenhum stream HLS disponível para esta aula.'));
51
+ }, [numericId]);
52
+
53
+ // Mount HLS player when token is ready
54
+ useEffect(() => {
55
+ const video = videoRef.current;
56
+ if (!video || !hlsToken) return;
57
+
58
+ const src = `${apiBase}/lms/platforma/hls/${hlsToken}/master.m3u8`;
59
+
60
+ let hls: HlsType | null = null;
61
+
62
+ import('hls.js').then(({ default: Hls }) => {
63
+ if (Hls.isSupported()) {
64
+ hls = new Hls({ enableWorker: false });
65
+ hls.loadSource(src);
66
+ hls.attachMedia(video);
67
+ hlsRef.current = hls;
68
+ } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
69
+ video.src = src;
70
+ } else {
71
+ setLoadError('Este navegador não suporta reprodução HLS.');
72
+ }
73
+ });
74
+
75
+ return () => {
76
+ hls?.destroy();
77
+ hlsRef.current = null;
78
+ };
79
+ }, [hlsToken, apiBase]);
80
+
81
+ const applySubtitle = useCallback(
82
+ async (localeId: number | null, label: string) => {
83
+ const video = videoRef.current;
84
+ if (!video) return;
85
+
86
+ // Remove existing tracks
87
+ const existingTrack = video.querySelector('track');
88
+ if (existingTrack) existingTrack.remove();
89
+
90
+ setSelectedLocaleId(localeId);
91
+ setSubtitleLabel(label);
92
+
93
+ if (localeId === null) return;
94
+
95
+ setSubtitleLoading(true);
96
+ try {
97
+ const tokenRes = await request<{ token: string }>({
98
+ url: `/lms/lessons/${numericId}/preview/subtitles-token?locale_id=${localeId}`,
99
+ method: 'GET',
100
+ });
101
+
102
+ const vttUrl = `${apiBase}/lms/platforma/subtitles/${tokenRes.data.token}/captions.vtt`;
103
+ const vttRes = await fetch(vttUrl);
104
+ const vttText = await vttRes.text();
105
+ const blob = new Blob([vttText], { type: 'text/vtt' });
106
+ const blobUrl = URL.createObjectURL(blob);
107
+
108
+ const track = document.createElement('track');
109
+ track.kind = 'subtitles';
110
+ track.src = blobUrl;
111
+ track.srclang = 'pt';
112
+ track.label = label;
113
+ track.default = true;
114
+ video.appendChild(track);
115
+
116
+ track.addEventListener(
117
+ 'load',
118
+ () => {
119
+ if (video.textTracks[0]) {
120
+ video.textTracks[0].mode = 'showing';
121
+ }
122
+ },
123
+ { once: true },
124
+ );
125
+ } catch {
126
+ setSubtitleLabel('Desativada');
127
+ setSelectedLocaleId(null);
128
+ } finally {
129
+ setSubtitleLoading(false);
130
+ }
131
+ },
132
+ [numericId, apiBase, request],
133
+ );
134
+
135
+ if (loadError) {
136
+ return (
137
+ <div className="flex flex-col items-center gap-3 py-16 text-center">
138
+ <VideoOff className="size-10 text-muted-foreground/40" />
139
+ <p className="text-sm text-muted-foreground">{loadError}</p>
140
+ </div>
141
+ );
142
+ }
143
+
144
+ if (!hlsToken) {
145
+ return (
146
+ <div className="flex items-center justify-center py-16">
147
+ <Loader2 className="size-6 animate-spin text-muted-foreground" />
148
+ </div>
149
+ );
150
+ }
151
+
152
+ return (
153
+ <div className="flex flex-col gap-3">
154
+ <div className="relative w-full overflow-hidden rounded-lg bg-black" style={{ aspectRatio: '16/9' }}>
155
+ <video
156
+ ref={videoRef}
157
+ controls
158
+ className="h-full w-full"
159
+ playsInline
160
+ />
161
+ </div>
162
+
163
+ {availableLocales.length > 0 && (
164
+ <div className="flex items-center gap-2">
165
+ <Subtitles className="size-4 text-muted-foreground shrink-0" />
166
+ <span className="text-xs text-muted-foreground">Legendas:</span>
167
+ <DropdownMenu>
168
+ <DropdownMenuTrigger asChild>
169
+ <Button variant="outline" size="sm" className="h-7 gap-1 text-xs" disabled={subtitleLoading}>
170
+ {subtitleLoading ? (
171
+ <Loader2 className="size-3 animate-spin" />
172
+ ) : (
173
+ subtitleLabel
174
+ )}
175
+ <ChevronDown className="size-3" />
176
+ </Button>
177
+ </DropdownMenuTrigger>
178
+ <DropdownMenuContent align="start">
179
+ <DropdownMenuItem
180
+ onClick={() => applySubtitle(null, 'Desativada')}
181
+ className={selectedLocaleId === null ? 'font-medium' : ''}
182
+ >
183
+ Desativada
184
+ </DropdownMenuItem>
185
+ {availableLocales.map((loc) => (
186
+ <DropdownMenuItem
187
+ key={loc.id}
188
+ onClick={() => applySubtitle(loc.id!, loc.name ?? loc.code ?? String(loc.id))}
189
+ className={selectedLocaleId === loc.id ? 'font-medium' : ''}
190
+ >
191
+ {loc.name ?? loc.code ?? `Idioma ${loc.id}`}
192
+ </DropdownMenuItem>
193
+ ))}
194
+ </DropdownMenuContent>
195
+ </DropdownMenu>
196
+ </div>
197
+ )}
198
+ </div>
199
+ );
200
+ }
@@ -185,6 +185,7 @@ export function TreeRowLesson({
185
185
  const attachedResourceCount = getLessonAttachedResourceCount(data);
186
186
  const uploadedVideoCount = getLessonUploadedVideoCount(data);
187
187
  const hasTranscription =
188
+ Boolean(data.hasTranscription) ||
188
189
  Boolean(data.transcription?.trim()) ||
189
190
  Boolean(data.transcriptionSegments?.some((segment) => segment.text.trim()));
190
191
 
@@ -67,6 +67,7 @@ export interface LessonInstructor {
67
67
 
68
68
  export interface TranscriptionSegment {
69
69
  id: number;
70
+ localeId?: number | null;
70
71
  startSeconds: number;
71
72
  endSeconds: number;
72
73
  text: string;
@@ -102,6 +103,8 @@ export interface Lesson {
102
103
  autoDuration?: boolean;
103
104
  transcription?: string;
104
105
  transcriptionSegments?: TranscriptionSegment[];
106
+ /** Denormalized flag from the backend (course_lesson.has_transcription). */
107
+ hasTranscription?: boolean;
105
108
  localeId?: number | null;
106
109
  videoConversionJobId?: number;
107
110
  // Questão
@@ -15,7 +15,7 @@
15
15
  * ⚠️ KNOWN MISMATCHES (documented with inline comments):
16
16
  * 1. exameVinculado (number) ↔ linkedExam (string) — converted with String()/Number()
17
17
  * 2. Session.order — not returned by API; inferred from sorted array position.
18
- * 3. Resource.size / url — not returned by API; left as empty string / undefined.
18
+ * 3. Resource.url — not returned by API; left as undefined.
19
19
  */
20
20
 
21
21
  import type {
@@ -69,6 +69,22 @@ function normalizeVideoProvider(
69
69
  return undefined;
70
70
  }
71
71
 
72
+ /**
73
+ * Format a byte count into a human-readable file size (B/KB/MB/GB).
74
+ * Returns an empty string when the size is unknown or zero.
75
+ */
76
+ function formatFileSize(bytes?: number | null): string {
77
+ if (!bytes || bytes <= 0) return '';
78
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
79
+ const exponent = Math.min(
80
+ Math.floor(Math.log(bytes) / Math.log(1024)),
81
+ units.length - 1
82
+ );
83
+ const value = bytes / 1024 ** exponent;
84
+ const formatted = exponent === 0 ? String(value) : value.toFixed(1);
85
+ return `${formatted} ${units[exponent]}`;
86
+ }
87
+
72
88
  /** Normalize a raw API resource to the frontend Resource shape. */
73
89
  function normalizeResource(raw: ApiLessonResource): Resource {
74
90
  const withUpload = raw as ApiLessonResource & {
@@ -83,12 +99,7 @@ function normalizeResource(raw: ApiLessonResource): Resource {
83
99
  fileId: raw.fileId ?? undefined,
84
100
  name: raw.nome,
85
101
  type: raw.type ?? '',
86
- /**
87
- * ⚠️ BACKEND NOTE: file size is not returned in the API response.
88
- * TODO[BACKEND]: Include `size` in the lesson resource response from
89
- * CourseStructureService.getLessonById().
90
- */
91
- size: '',
102
+ size: formatFileSize(raw.tamanho),
92
103
  public: raw.is_public ?? true,
93
104
  uploadedAt:
94
105
  withUpload.uploadedAt ??
@@ -184,6 +195,7 @@ export function normalizeLesson(raw: ApiLesson, order = 0): Lesson {
184
195
  videoUrl: raw.videoUrl,
185
196
  autoDuration: raw.duracaoAutomatica,
186
197
  transcription: raw.transcricao,
198
+ hasTranscription: Boolean(raw.temTranscricao),
187
199
  localeId: raw.locale_id ?? null,
188
200
  videoConversionJobId: raw.videoConversionJobId,
189
201
  /**
@@ -21,6 +21,8 @@ export interface ApiLessonResource {
21
21
  id: number;
22
22
  nome: string;
23
23
  fileId?: number | null;
24
+ /** File size in bytes (0 when unknown). */
25
+ tamanho?: number;
24
26
  /** MIME category or custom type label; may be null. */
25
27
  type?: string | null;
26
28
  is_public?: boolean;
@@ -96,6 +98,8 @@ export interface ApiLesson {
96
98
  videoUrl?: string;
97
99
  duracaoAutomatica?: boolean;
98
100
  transcricao?: string;
101
+ /** Whether the lesson already has transcription segments (denormalized flag). */
102
+ temTranscricao?: boolean;
99
103
  videoConversionJobId?: number;
100
104
  /**
101
105
  * ID of the linked exam (when tipo === 'questao').
@@ -0,0 +1,40 @@
1
+ 'use client';
2
+
3
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
4
+
5
+ export type CourseAiCostByJobType = {
6
+ jobType: string;
7
+ costUsd: number;
8
+ runs: number;
9
+ };
10
+
11
+ export type CourseAiCostByLesson = {
12
+ lessonId: number | null;
13
+ lessonTitle: string | null;
14
+ costUsd: number;
15
+ runs: number;
16
+ };
17
+
18
+ export type CourseAiCosts = {
19
+ currency: string;
20
+ totalCostUsd: number;
21
+ byJobType: CourseAiCostByJobType[];
22
+ byLesson: CourseAiCostByLesson[];
23
+ };
24
+
25
+ export function useCourseAiCostsQuery(courseId: string | null) {
26
+ const { request } = useApp();
27
+
28
+ return useQuery<CourseAiCosts | null>({
29
+ queryKey: ['course-ai-costs', courseId],
30
+ enabled: Boolean(courseId),
31
+ queryFn: async () => {
32
+ const response = await request<CourseAiCosts>({
33
+ url: `/lms/courses/${courseId}/structure/ai-costs`,
34
+ method: 'GET',
35
+ });
36
+ return response.data ?? null;
37
+ },
38
+ initialData: null,
39
+ });
40
+ }
@@ -3,6 +3,30 @@
3
3
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
4
4
  import { useMutation, useQueryClient } from '@tanstack/react-query';
5
5
 
6
+ export type CourseTranscriptionLocale = {
7
+ id: number;
8
+ code: string;
9
+ name: string;
10
+ region: string | null;
11
+ };
12
+
13
+ export function useCourseTranscriptionLocalesQuery(courseId: string | null) {
14
+ const { request } = useApp();
15
+
16
+ return useQuery<CourseTranscriptionLocale[]>({
17
+ queryKey: ['course-transcription-locales', courseId],
18
+ enabled: Boolean(courseId),
19
+ queryFn: async () => {
20
+ const res = await request<CourseTranscriptionLocale[]>({
21
+ url: `/lms/courses/${courseId}/structure/transcription-locales`,
22
+ method: 'GET',
23
+ });
24
+ return res.data ?? [];
25
+ },
26
+ initialData: [],
27
+ });
28
+ }
29
+
6
30
  export type ScormVisualSettings = {
7
31
  primaryColor?: string;
8
32
  fontFamily?: string;
@@ -66,6 +90,7 @@ export function useCreateCourseExportMutation(courseId: string | null) {
66
90
  mutationFn: async (payload: {
67
91
  format: 'scorm_1_2';
68
92
  visualSettings?: ScormVisualSettings;
93
+ subtitleLocaleIds?: number[];
69
94
  }) => {
70
95
  const res = await request<{
71
96
  exportId: number;
@@ -29,6 +29,7 @@ import { useApp } from '@hed-hog/next-app-provider';
29
29
  import { useStructureStore } from '../_components/store';
30
30
  import type {
31
31
  LessonFormValues,
32
+ LessonStatus,
32
33
  Resource,
33
34
  SessionFormValues,
34
35
  } from '../_components/types';
@@ -605,6 +606,153 @@ export function useBulkDeleteMutation() {
605
606
  });
606
607
  }
607
608
 
609
+ // ─────────────────────────────────────────────────────────────────────────────
610
+ // useBulkUpdateMutation
611
+ // ─────────────────────────────────────────────────────────────────────────────
612
+
613
+ interface BulkUpdateVars {
614
+ /** Lessons to update (publish state and/or production status). */
615
+ lessons: Array<{ lessonId: string; sessionId: string }>;
616
+ /** Sessions to update (publish state only). */
617
+ sessionIds: Array<{ sessionId: string }>;
618
+ /** Field changes to apply to every selected item. */
619
+ changes: { published?: boolean; status?: LessonStatus };
620
+ }
621
+
622
+ /**
623
+ * Bulk field update: iterate PATCH calls since no batch endpoint exists.
624
+ *
625
+ * Strategy mirrors `useBulkDeleteMutation`:
626
+ * 1. PATCH each lesson with the requested changes (published / production status).
627
+ * When publishing (`published === true`), send `confirmarPublicacaoComStatus`
628
+ * so the backend doesn't reject lessons whose status isn't `publicada`.
629
+ * 2. PATCH each session (only `published` applies to sessions).
630
+ * 3. Use Promise.allSettled so one failure doesn't abort the rest.
631
+ * 4. Apply successful items to the store + cache; show partial-error toast.
632
+ */
633
+ export function useBulkUpdateMutation() {
634
+ const t = useTranslations('lms.courseStructure');
635
+ const { request } = useApp();
636
+ const queryClient = useQueryClient();
637
+ const courseId = useStructureStore((s) => s.courseId);
638
+ const updateLessonInStore = useStructureStore((s) => s.updateLesson);
639
+ const updateSessionInStore = useStructureStore((s) => s.updateSession);
640
+
641
+ return useMutation({
642
+ mutationFn: async ({ lessons, sessionIds, changes }: BulkUpdateVars) => {
643
+ const lessonPayload = toUpdateLessonPayload({
644
+ ...(changes.published !== undefined && { published: changes.published }),
645
+ ...(changes.status !== undefined && { status: changes.status }),
646
+ ...(changes.published === true && {
647
+ confirmarPublicacaoComStatus: true,
648
+ }),
649
+ });
650
+ const lessonResults = await Promise.allSettled(
651
+ lessons.map(({ lessonId, sessionId }) =>
652
+ apiUpdateLesson(request, courseId, sessionId, lessonId, lessonPayload)
653
+ )
654
+ );
655
+ // Sessions only carry a `published` flag.
656
+ const sessionPayload = toUpdateSessionPayload(
657
+ changes.published !== undefined ? { published: changes.published } : {}
658
+ );
659
+ const sessionResults =
660
+ changes.published !== undefined
661
+ ? await Promise.allSettled(
662
+ sessionIds.map(({ sessionId }) =>
663
+ apiUpdateSession(request, courseId, sessionId, sessionPayload)
664
+ )
665
+ )
666
+ : [];
667
+ return {
668
+ lessons,
669
+ sessionIds,
670
+ changes,
671
+ lessonResults,
672
+ sessionResults,
673
+ };
674
+ },
675
+ onSuccess: ({
676
+ lessons,
677
+ sessionIds,
678
+ changes,
679
+ lessonResults,
680
+ sessionResults,
681
+ }) => {
682
+ const successLessonIds = lessons
683
+ .filter((_, i) => lessonResults[i]?.status === 'fulfilled')
684
+ .map((l) => l.lessonId);
685
+ const successSessionIds = sessionIds
686
+ .filter((_, i) => sessionResults[i]?.status === 'fulfilled')
687
+ .map((s) => s.sessionId);
688
+ const errorCount = [...lessonResults, ...sessionResults].filter(
689
+ (r) => r.status === 'rejected'
690
+ ).length;
691
+
692
+ const lessonPatch: Partial<LessonFormValues> = {
693
+ ...(changes.published !== undefined && { published: changes.published }),
694
+ // Publishing forces production status to `publicada` on the backend.
695
+ ...(changes.published === true
696
+ ? { status: 'publicada' as LessonStatus }
697
+ : changes.status !== undefined && { status: changes.status }),
698
+ };
699
+
700
+ successLessonIds.forEach((id) => updateLessonInStore(id, lessonPatch));
701
+ if (changes.published !== undefined) {
702
+ successSessionIds.forEach((id) =>
703
+ updateSessionInStore(id, {
704
+ published: changes.published,
705
+ } as SessionFormValues)
706
+ );
707
+ }
708
+
709
+ // Patch the structure cache so the tree reflects changes immediately.
710
+ const lessonSet = new Set(successLessonIds);
711
+ const sessionSet = new Set(successSessionIds);
712
+ queryClient.setQueryData<CourseStructureCacheData>(
713
+ courseStructureQueryKey(courseId),
714
+ (old) => {
715
+ if (!old) return old;
716
+ return {
717
+ ...old,
718
+ lessons: old.lessons.map((l) =>
719
+ lessonSet.has(l.id) ? { ...l, ...lessonPatch } : l
720
+ ),
721
+ sessions:
722
+ changes.published === undefined
723
+ ? old.sessions
724
+ : old.sessions.map((s) =>
725
+ sessionSet.has(s.id)
726
+ ? { ...s, published: changes.published! }
727
+ : s
728
+ ),
729
+ };
730
+ }
731
+ );
732
+ void queryClient.invalidateQueries({
733
+ queryKey: courseStructureQueryKey(courseId),
734
+ });
735
+
736
+ const successCount = successLessonIds.length + successSessionIds.length;
737
+ if (errorCount === 0) {
738
+ toast.success(
739
+ `${successCount} ${successCount === 1 ? 'item atualizado' : 'itens atualizados'}`
740
+ );
741
+ } else {
742
+ toast.error(
743
+ `${errorCount} falha${errorCount > 1 ? 's' : ''} — ${successCount} atualizado${successCount !== 1 ? 's' : ''}`
744
+ );
745
+ }
746
+ },
747
+ onError: () => {
748
+ void queryClient.invalidateQueries({
749
+ queryKey: courseStructureQueryKey(courseId),
750
+ });
751
+ toast.error(t('mutations.lesson.saveError'));
752
+ },
753
+ });
754
+ }
755
+
608
756
  // ─────────────────────────────────────────────────────────────────────────────
609
757
  // useReorderSessionsMutation
610
758
  // ─────────────────────────────────────────────────────────────────────────────