@hed-hog/lms 0.0.306 → 0.0.310

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 (120) hide show
  1. package/dist/course/course-structure.controller.d.ts +60 -0
  2. package/dist/course/course-structure.controller.d.ts.map +1 -1
  3. package/dist/course/course-structure.controller.js +79 -0
  4. package/dist/course/course-structure.controller.js.map +1 -1
  5. package/dist/course/course-structure.service.d.ts +61 -1
  6. package/dist/course/course-structure.service.d.ts.map +1 -1
  7. package/dist/course/course-structure.service.js +326 -1
  8. package/dist/course/course-structure.service.js.map +1 -1
  9. package/dist/course/course.controller.d.ts +52 -4
  10. package/dist/course/course.controller.d.ts.map +1 -1
  11. package/dist/course/course.service.d.ts +52 -5
  12. package/dist/course/course.service.d.ts.map +1 -1
  13. package/dist/course/course.service.js +78 -57
  14. package/dist/course/course.service.js.map +1 -1
  15. package/dist/course/dto/create-course-structure-lesson.dto.d.ts.map +1 -1
  16. package/dist/course/dto/create-course-structure-lesson.dto.js +5 -1
  17. package/dist/course/dto/create-course-structure-lesson.dto.js.map +1 -1
  18. package/dist/course/dto/create-course.dto.d.ts +1 -1
  19. package/dist/course/dto/create-course.dto.d.ts.map +1 -1
  20. package/dist/course/dto/create-course.dto.js +4 -1
  21. package/dist/course/dto/create-course.dto.js.map +1 -1
  22. package/dist/course/dto/move-lesson.dto.d.ts +10 -0
  23. package/dist/course/dto/move-lesson.dto.d.ts.map +1 -0
  24. package/dist/course/dto/move-lesson.dto.js +28 -0
  25. package/dist/course/dto/move-lesson.dto.js.map +1 -0
  26. package/dist/course/dto/paste-lessons.dto.d.ts +4 -0
  27. package/dist/course/dto/paste-lessons.dto.d.ts.map +1 -0
  28. package/dist/course/dto/paste-lessons.dto.js +24 -0
  29. package/dist/course/dto/paste-lessons.dto.js.map +1 -0
  30. package/dist/course/dto/reorder-lessons.dto.d.ts +5 -0
  31. package/dist/course/dto/reorder-lessons.dto.d.ts.map +1 -0
  32. package/dist/course/dto/reorder-lessons.dto.js +24 -0
  33. package/dist/course/dto/reorder-lessons.dto.js.map +1 -0
  34. package/dist/course/dto/reorder-sessions.dto.d.ts +5 -0
  35. package/dist/course/dto/reorder-sessions.dto.d.ts.map +1 -0
  36. package/dist/course/dto/reorder-sessions.dto.js +24 -0
  37. package/dist/course/dto/reorder-sessions.dto.js.map +1 -0
  38. package/dist/training/training.controller.js +1 -1
  39. package/dist/training/training.controller.js.map +1 -1
  40. package/hedhog/data/image_type.yaml +20 -0
  41. package/hedhog/data/menu.yaml +2 -2
  42. package/hedhog/data/route.yaml +60 -6
  43. package/hedhog/frontend/app/_components/class-form-sheet.tsx.ejs +146 -165
  44. package/hedhog/frontend/app/_components/course-avatar.tsx.ejs +70 -0
  45. package/hedhog/frontend/app/_components/course-form-sheet.tsx.ejs +372 -22
  46. package/hedhog/frontend/app/certificates/models/CanvasStage.tsx.ejs +10 -1
  47. package/hedhog/frontend/app/certificates/models/TopBar.tsx.ejs +44 -3
  48. package/hedhog/frontend/app/certificates/models/page.tsx.ejs +32 -0
  49. package/hedhog/frontend/app/classes/[id]/page.tsx.ejs +437 -77
  50. package/hedhog/frontend/app/classes/page.tsx.ejs +311 -289
  51. package/hedhog/frontend/app/courses/[id]/_components/CourseCertificateCard.tsx.ejs +10 -7
  52. package/hedhog/frontend/app/courses/[id]/_components/CourseClassificationCard.tsx.ejs +23 -32
  53. package/hedhog/frontend/app/courses/[id]/_components/CourseContentCard.tsx.ejs +3 -9
  54. package/hedhog/frontend/app/courses/[id]/_components/CourseDangerZoneCard.tsx.ejs +26 -16
  55. package/hedhog/frontend/app/courses/[id]/_components/CourseFlagsCard.tsx.ejs +19 -5
  56. package/hedhog/frontend/app/courses/[id]/_components/CourseMainInfoCard.tsx.ejs +10 -14
  57. package/hedhog/frontend/app/courses/[id]/_components/CourseMediaCard.tsx.ejs +131 -107
  58. package/hedhog/frontend/app/courses/[id]/_components/CourseRelationsCard.tsx.ejs +10 -7
  59. package/hedhog/frontend/app/courses/[id]/_components/CourseSectionCard.tsx.ejs +38 -19
  60. package/hedhog/frontend/app/courses/[id]/_components/CourseSummaryCard.tsx.ejs +1 -1
  61. package/hedhog/frontend/app/courses/[id]/_components/course-edit-types.ts.ejs +1 -1
  62. package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +336 -1057
  63. package/hedhog/frontend/app/courses/[id]/structure/_components/confirm-dialog.tsx.ejs +45 -0
  64. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-dnd.tsx.ejs +362 -0
  65. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-panel.tsx.ejs +111 -0
  66. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-skeleton.tsx.ejs +64 -0
  67. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree.tsx.ejs +134 -0
  68. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-course.tsx.ejs +113 -0
  69. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +314 -0
  70. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-panel.tsx.ejs +62 -0
  71. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-session.tsx.ejs +174 -0
  72. package/hedhog/frontend/app/courses/[id]/structure/_components/drag-handle.tsx.ejs +58 -0
  73. package/hedhog/frontend/app/courses/[id]/structure/_components/drag-overlay.tsx.ejs +52 -0
  74. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-bulk.tsx.ejs +276 -0
  75. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +1216 -0
  76. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +1827 -0
  77. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-session.tsx.ejs +443 -0
  78. package/hedhog/frontend/app/courses/[id]/structure/_components/highlighted-text.tsx.ejs +41 -0
  79. package/hedhog/frontend/app/courses/[id]/structure/_components/mock-data.ts.ejs +184 -0
  80. package/hedhog/frontend/app/courses/[id]/structure/_components/multi-select-bar.tsx.ejs +264 -0
  81. package/hedhog/frontend/app/courses/[id]/structure/_components/search-filter.tsx.ejs +96 -0
  82. package/hedhog/frontend/app/courses/[id]/structure/_components/session-picker-dialog.tsx.ejs +74 -0
  83. package/hedhog/frontend/app/courses/[id]/structure/_components/shortcuts-help.tsx.ejs +136 -0
  84. package/hedhog/frontend/app/courses/[id]/structure/_components/sortable-tree-row.tsx.ejs +80 -0
  85. package/hedhog/frontend/app/courses/[id]/structure/_components/store.ts.ejs +948 -0
  86. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-context-menu.tsx.ejs +525 -0
  87. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-display-settings-popover.tsx.ejs +150 -0
  88. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-helpers.ts.ejs +182 -0
  89. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-course.tsx.ejs +52 -0
  90. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +271 -0
  91. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-session.tsx.ejs +167 -0
  92. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row.tsx.ejs +108 -0
  93. package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +122 -0
  94. package/hedhog/frontend/app/courses/[id]/structure/_components/use-course-structure-shortcuts.ts.ejs +318 -0
  95. package/hedhog/frontend/app/courses/[id]/structure/_components/use-tree-display-settings.ts.ejs +97 -0
  96. package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +347 -0
  97. package/hedhog/frontend/app/courses/[id]/structure/_data/course-structure-contract.ts.ejs +195 -0
  98. package/hedhog/frontend/app/courses/[id]/structure/_data/services/course-structure.service.ts.ejs +420 -0
  99. package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +254 -0
  100. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-mutations.ts.ejs +987 -0
  101. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-query.ts.ejs +86 -0
  102. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure.ts.ejs +160 -0
  103. package/hedhog/frontend/app/courses/[id]/structure/page.tsx.ejs +10 -3212
  104. package/hedhog/frontend/app/courses/page.tsx.ejs +45 -26
  105. package/hedhog/frontend/app/{training → paths}/page.tsx.ejs +29 -7
  106. package/hedhog/frontend/messages/en.json +91 -11
  107. package/hedhog/frontend/messages/pt.json +91 -11
  108. package/hedhog/table/course.yaml +1 -1
  109. package/hedhog/table/image_type.yaml +14 -0
  110. package/package.json +7 -7
  111. package/src/course/course-structure.controller.ts +63 -0
  112. package/src/course/course-structure.service.ts +390 -3
  113. package/src/course/course.service.ts +59 -27
  114. package/src/course/dto/create-course-structure-lesson.dto.ts +3 -2
  115. package/src/course/dto/create-course.dto.ts +4 -1
  116. package/src/course/dto/move-lesson.dto.ts +17 -0
  117. package/src/course/dto/paste-lessons.dto.ts +9 -0
  118. package/src/course/dto/reorder-lessons.dto.ts +10 -0
  119. package/src/course/dto/reorder-sessions.dto.ts +10 -0
  120. package/src/training/training.controller.ts +1 -1
@@ -1,427 +1,78 @@
1
1
  'use client';
2
2
 
3
- import { createDefaultTemplate } from '@/app/(app)/(libraries)/lms/_lib/editor/types';
4
- import { CreateLmsPersonSheet } from '@/app/(app)/(libraries)/lms/_components/create-lms-person-sheet';
5
- import { EmptyState, Page, PageHeader } from '@/components/entity-list';
3
+ import { use, useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
+
5
+ import { Page, PageHeader } from '@/components/entity-list';
6
+ import { Badge } from '@/components/ui/badge';
6
7
  import { Button } from '@/components/ui/button';
7
8
  import {
8
- Dialog,
9
- DialogContent,
10
- DialogDescription,
11
- DialogFooter,
12
- DialogHeader,
13
- DialogTitle,
14
- } from '@/components/ui/dialog';
15
- import { Form } from '@/components/ui/form';
16
- import { Skeleton } from '@/components/ui/skeleton';
9
+ ResizableHandle,
10
+ ResizablePanel,
11
+ ResizablePanelGroup,
12
+ } from '@/components/ui/resizable';
13
+ import {
14
+ Sheet,
15
+ SheetContent,
16
+ SheetHeader,
17
+ SheetTitle,
18
+ } from '@/components/ui/sheet';
19
+ import { useIsMobile } from '@/hooks/use-mobile';
17
20
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
18
- import { zodResolver } from '@hookform/resolvers/zod';
19
- import { AlertTriangle, BookOpen, Loader2, Save } from 'lucide-react';
20
- import { useTranslations } from 'next-intl';
21
+ import { useIsMutating } from '@tanstack/react-query';
22
+ import {
23
+ AlertCircle,
24
+ BookOpen,
25
+ Layers,
26
+ Menu,
27
+ RefreshCw,
28
+ Video,
29
+ } from 'lucide-react';
21
30
  import { useRouter } from 'next/navigation';
22
- import { type ChangeEvent, use, useEffect, useMemo, useState } from 'react';
23
- import { useForm } from 'react-hook-form';
24
- import { toast } from 'sonner';
25
- import { z } from 'zod';
26
-
27
- import { CourseCertificateCard } from './_components/CourseCertificateCard';
28
- import { CourseClassificationCard } from './_components/CourseClassificationCard';
29
- import { CourseContentCard } from './_components/CourseContentCard';
30
- import { CourseDangerZoneCard } from './_components/CourseDangerZoneCard';
31
- import { CourseFlagsCard } from './_components/CourseFlagsCard';
32
- import { CourseMainInfoCard } from './_components/CourseMainInfoCard';
33
- import { CourseMediaCard } from './_components/CourseMediaCard';
34
- import { CourseRelationsCard } from './_components/CourseRelationsCard';
35
- import { CourseSummaryCard } from './_components/CourseSummaryCard';
36
- import type {
37
- CourseEditFormValues,
38
- PickerOption,
39
- } from './_components/course-edit-types';
40
-
41
- const API_COURSES_CACHE_KEY = 'lms:courses:api-cache';
42
-
43
- type ApiCourseDetail = {
44
- id: number;
45
- code: string;
46
- slug: string;
47
- title: string;
48
- description: string;
49
- primaryColor?: string | null;
50
- secondaryColor?: string | null;
51
- level: 'beginner' | 'intermediate' | 'advanced';
52
- status: 'draft' | 'published' | 'archived';
53
- offeringType: 'scheduled' | 'on_demand' | 'blended';
54
- categories: string[];
55
- isFeatured: boolean;
56
- hasCertificate: boolean;
57
- isListed: boolean;
58
- enrollmentCount: number;
59
- requirements: string;
60
- objectives: string;
61
- targetAudience: string;
62
- lessonCount: number;
63
- sessionCount: number;
64
- averageCompletion: number;
65
- certificatesIssued: number;
66
- instructorIds?: number[];
67
- instructors?: Array<{
68
- id: number;
69
- name: string;
70
- avatarId: number | null;
71
- }>;
72
- logoFileId?: number | null;
73
- logoFilename?: string | null;
74
- bannerFileId?: number | null;
75
- bannerFilename?: string | null;
76
- certificateModel?: string | null;
77
- };
78
-
79
- type ApiCategory = {
80
- id: number;
81
- slug: string;
82
- name: string;
83
- };
84
-
85
- type ApiCategoryList = {
86
- data: ApiCategory[];
87
- total: number;
88
- page: number;
89
- pageSize: number;
90
- };
91
-
92
- type ApiInstructor = {
93
- id: number;
94
- personId: number;
95
- name: string;
96
- avatarId?: number | null;
97
- qualificationSlugs: string[];
98
- };
99
-
100
- type ApiInstructorList = {
101
- data: ApiInstructor[];
102
- total: number;
103
- page: number;
104
- pageSize: number;
105
- };
106
-
107
- type ApiCertificateTemplate = {
108
- id: number;
109
- name: string;
110
- slug?: string | null;
111
- description?: string | null;
112
- status?: 'draft' | 'active' | 'inactive';
113
- };
114
-
115
- type ApiCertificateTemplateList = {
116
- data: ApiCertificateTemplate[];
117
- total: number;
118
- page: number;
119
- pageSize: number;
120
- lastPage?: number;
121
- };
122
-
123
- type Locale = {
124
- id?: number;
125
- code: string;
126
- name: string;
127
- };
128
-
129
- type CursoDetailView = {
130
- id: number;
131
- codigo: string;
132
- nomeInterno: string;
133
- tituloComercial: string;
134
- descricaoPublica: string;
135
- nivel: CourseEditFormValues['nivel'];
136
- status: CourseEditFormValues['status'];
137
- tipoOferta: CourseEditFormValues['tipoOferta'];
138
- categorias: string[];
139
- primaryColor: string;
140
- secondaryColor: string;
141
- instrutores: string[];
142
- preRequisitos: string;
143
- modeloCertificado: string;
144
- certificado: boolean;
145
- destaque: boolean;
146
- listado: boolean;
147
- objetivos: string;
148
- publicoAlvo: string;
149
- totalAlunos: number;
150
- conclusaoMedia: number;
151
- totalAulas: number;
152
- totalSessoes: number;
153
- certificadosEmitidos: number;
154
- };
155
-
156
- function getCursoEditSchema(t: (key: string) => string) {
157
- return z.object({
158
- codigo: z
159
- .string()
160
- .trim()
161
- .min(2, t('validation.codeMin'))
162
- .max(16, t('validation.codeMax'))
163
- .regex(/^[A-Za-z0-9-]+$/, t('validation.codeFormat')),
164
- nomeInterno: z.string().trim().min(3, t('validation.internalNameMin')),
165
- tituloComercial: z.string().trim().min(3, t('validation.titleMin')),
166
- descricaoPublica: z.string().trim().min(10, t('validation.descriptionMin')),
167
- objetivos: z.string().optional(),
168
- publicoAlvo: z.string().optional(),
169
- nivel: z.enum(['iniciante', 'intermediario', 'avancado']),
170
- status: z.enum(['ativo', 'rascunho', 'arquivado']),
171
- tipoOferta: z.enum(['agendado', 'sob_demanda', 'hibrido']),
172
- categorias: z.array(z.string()).min(1, t('validation.categoryRequired')),
173
- primaryColor: z.string().regex(/^#([0-9A-Fa-f]{6})$/, t('validation.colorInvalid')),
174
- secondaryColor: z.string().regex(/^#([0-9A-Fa-f]{6})$/, t('validation.colorInvalid')),
175
- instrutores: z.array(z.string()).optional(),
176
- preRequisitos: z.string().optional(),
177
- modeloCertificado: z.string().optional(),
178
- certificado: z.boolean().default(false),
179
- destaque: z.boolean().default(false),
180
- listado: z.boolean().default(false),
181
- });
182
- }
183
-
184
- function getNiveis(t: (key: string) => string) {
185
- return [
186
- { value: 'iniciante', label: t('levels.beginner') },
187
- { value: 'intermediario', label: t('levels.intermediate') },
188
- { value: 'avancado', label: t('levels.advanced') },
189
- ];
190
- }
191
-
192
- function getStatusOptions(t: (key: string) => string) {
193
- return [
194
- { value: 'ativo', label: t('status.active') },
195
- { value: 'rascunho', label: t('status.draft') },
196
- { value: 'arquivado', label: t('status.archived') },
197
- ];
198
- }
199
-
200
- function getOfferingTypeOptions(t: (key: string) => string) {
201
- return [
202
- {
203
- value: 'sob_demanda',
204
- label: t('offeringType.options.onDemand.label'),
205
- description: t('offeringType.options.onDemand.description'),
206
- },
207
- {
208
- value: 'agendado',
209
- label: t('offeringType.options.scheduled.label'),
210
- description: t('offeringType.options.scheduled.description'),
211
- },
212
- {
213
- value: 'hibrido',
214
- label: t('offeringType.options.blended.label'),
215
- description: t('offeringType.options.blended.description'),
216
- },
217
- ] as const;
218
- }
219
-
220
- function normalizeEnumValue(value?: string | null) {
221
- return String(value ?? '')
222
- .trim()
223
- .normalize('NFD')
224
- .replace(/[\u0300-\u036f]/g, '')
225
- .toLowerCase();
226
- }
227
-
228
- function toPtLevel(level: ApiCourseDetail['level']): CourseEditFormValues['nivel'] {
229
- const normalized = normalizeEnumValue(level);
230
- if (normalized === 'beginner' || normalized === 'iniciante') return 'iniciante';
231
- if (normalized === 'intermediate' || normalized === 'intermediario') {
232
- return 'intermediario';
233
- }
234
- return 'avancado';
235
- }
236
-
237
- function toApiLevel(level: CourseEditFormValues['nivel']) {
238
- if (level === 'iniciante') return 'beginner';
239
- if (level === 'intermediario') return 'intermediate';
240
- return 'advanced';
241
- }
242
-
243
- function toPtStatus(status: ApiCourseDetail['status']): CourseEditFormValues['status'] {
244
- const normalized = normalizeEnumValue(status);
245
- if (normalized === 'published' || normalized === 'active' || normalized === 'ativo') {
246
- return 'ativo';
247
- }
248
- if (normalized === 'archived' || normalized === 'arquivado') {
249
- return 'arquivado';
250
- }
251
- return 'rascunho';
252
- }
253
-
254
- function toApiStatus(status: CourseEditFormValues['status']) {
255
- if (status === 'ativo') return 'published';
256
- if (status === 'arquivado') return 'archived';
257
- return 'draft';
258
- }
259
-
260
- function toPtOfferingType(
261
- value: ApiCourseDetail['offeringType']
262
- ): CourseEditFormValues['tipoOferta'] {
263
- const normalized = normalizeEnumValue(value);
264
- if (normalized === 'scheduled' || normalized === 'agendado') {
265
- return 'agendado';
266
- }
267
- if (normalized === 'blended' || normalized === 'hibrido') {
268
- return 'hibrido';
269
- }
270
- return 'sob_demanda';
271
- }
272
-
273
- function toApiOfferingType(value: CourseEditFormValues['tipoOferta']) {
274
- if (value === 'agendado') return 'scheduled';
275
- if (value === 'hibrido') return 'blended';
276
- return 'on_demand';
277
- }
278
-
279
- function getContrastColor(hex: string) {
280
- const cleaned = hex.replace('#', '');
281
- if (cleaned.length !== 6) return '#FFFFFF';
282
-
283
- const r = parseInt(cleaned.slice(0, 2), 16);
284
- const g = parseInt(cleaned.slice(2, 4), 16);
285
- const b = parseInt(cleaned.slice(4, 6), 16);
286
- const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
287
31
 
288
- return luminance > 0.6 ? '#111827' : '#FFFFFF';
289
- }
290
-
291
- function slugify(value: string) {
292
- return value
293
- .trim()
294
- .toLowerCase()
295
- .normalize('NFD')
296
- .replace(/[\u0300-\u036f]/g, '')
297
- .replace(/[^a-z0-9]+/g, '-')
298
- .replace(/^-+|-+$/g, '');
299
- }
300
-
301
- function clearCoursesListCache() {
302
- if (typeof window === 'undefined') return;
303
- window.localStorage.removeItem(API_COURSES_CACHE_KEY);
304
- }
305
-
306
- function getInstructorAvatarUrl(avatarId?: number | null) {
307
- return typeof avatarId === 'number' && avatarId > 0
308
- ? `${process.env.NEXT_PUBLIC_API_BASE_URL}/person/avatar/${avatarId}`
309
- : null;
310
- }
311
-
312
- function mapApiCourseToView(course: ApiCourseDetail): CursoDetailView {
313
- return {
314
- id: course.id,
315
- codigo: course.code,
316
- nomeInterno: course.slug,
317
- tituloComercial: course.title,
318
- descricaoPublica: course.description ?? '',
319
- nivel: toPtLevel(course.level),
320
- status: toPtStatus(course.status),
321
- tipoOferta: toPtOfferingType(course.offeringType),
322
- categorias: course.categories ?? [],
323
- primaryColor: course.primaryColor || '#1D4ED8',
324
- secondaryColor: course.secondaryColor || '#111827',
325
- instrutores: (course.instructorIds ?? []).map(String),
326
- preRequisitos: course.requirements ?? '',
327
- modeloCertificado: course.certificateModel ?? '',
328
- certificado: course.hasCertificate ?? false,
329
- destaque: course.isFeatured ?? false,
330
- listado: course.isListed ?? false,
331
- objetivos: course.objectives ?? '',
332
- publicoAlvo: course.targetAudience ?? '',
333
- totalAlunos: course.enrollmentCount ?? 0,
334
- conclusaoMedia: course.averageCompletion ?? 0,
335
- totalAulas: course.lessonCount ?? 0,
336
- totalSessoes: course.sessionCount ?? 0,
337
- certificadosEmitidos: course.certificatesIssued ?? 0,
338
- };
32
+ import { ConfirmDialog } from './structure/_components/confirm-dialog';
33
+ import { CourseTreePanel } from './structure/_components/course-tree-panel';
34
+ import { CourseTreeSkeleton } from './structure/_components/course-tree-skeleton';
35
+ import { DetailPanel } from './structure/_components/detail-panel';
36
+ import type { SearchFilterHandle } from './structure/_components/search-filter';
37
+ import { SessionPickerDialog } from './structure/_components/session-picker-dialog';
38
+ import {
39
+ ShortcutsHelp,
40
+ ShortcutsHelpTrigger,
41
+ } from './structure/_components/shortcuts-help';
42
+ import { useStructureStore } from './structure/_components/store';
43
+ import { TreeDisplaySettingsPopover } from './structure/_components/tree-display-settings-popover';
44
+ import { useCourseStructureShortcuts } from './structure/_components/use-course-structure-shortcuts';
45
+ import {
46
+ useDuplicateLessonMutation,
47
+ useDuplicateSessionMutation,
48
+ usePasteLessonsMutation,
49
+ usePasteSessionsMutation,
50
+ } from './structure/_data/use-course-structure-mutations';
51
+ import { useCourseStructureQuery } from './structure/_data/use-course-structure-query';
52
+
53
+ interface Props {
54
+ params: Promise<{ id: string }>;
339
55
  }
340
56
 
341
- function LoadingSkeleton() {
342
- return (
343
- <div className="space-y-6">
344
- <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
345
- {Array.from({ length: 4 }).map((_, index) => (
346
- <Skeleton key={index} className="h-24 rounded-2xl" />
347
- ))}
348
- </div>
349
-
350
- <div className="grid gap-6 xl:grid-cols-[minmax(0,2fr)_minmax(320px,1fr)]">
351
- <div className="space-y-6">
352
- {Array.from({ length: 4 }).map((_, index) => (
353
- <Skeleton key={index} className="h-64 rounded-2xl" />
354
- ))}
355
- </div>
356
- <div className="space-y-6">
357
- {Array.from({ length: 4 }).map((_, index) => (
358
- <Skeleton key={index} className="h-56 rounded-2xl" />
359
- ))}
360
- </div>
361
- </div>
362
- </div>
363
- );
364
- }
57
+ type ApiCourseSummary = {
58
+ name?: string;
59
+ enrollmentCount?: number;
60
+ averageCompletion?: number;
61
+ lessonCount?: number;
62
+ sessionCount?: number;
63
+ };
365
64
 
366
- export default function CursoEditPage({
367
- params,
368
- }: {
369
- params: Promise<{ id: string }>;
370
- }) {
65
+ export default function CourseStructurePage({ params }: Props) {
371
66
  const { id } = use(params);
67
+ const isMobile = useIsMobile();
372
68
  const router = useRouter();
373
- const t = useTranslations('lms.CursoEditPage');
374
- const { request, currentLocaleCode, locales } = useApp();
375
-
376
- const [saving, setSaving] = useState(false);
377
- const [deleting, setDeleting] = useState(false);
378
- const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
379
- const [instructorSheetOpen, setInstructorSheetOpen] = useState(false);
380
- const [logoPreview, setLogoPreview] = useState<string | null>(null);
381
- const [bannerPreview, setBannerPreview] = useState<string | null>(null);
382
- const [uploadingLogo, setUploadingLogo] = useState(false);
383
- const [uploadingBanner, setUploadingBanner] = useState(false);
384
- const [createdCategoryOptions, setCreatedCategoryOptions] = useState<PickerOption[]>([]);
385
- const [createdTemplateOptions, setCreatedTemplateOptions] = useState<PickerOption[]>([]);
386
- const [persistedCertificateModel, setPersistedCertificateModel] = useState('');
387
-
388
- const form = useForm<CourseEditFormValues>({
389
- resolver: zodResolver(getCursoEditSchema(t)),
390
- defaultValues: {
391
- codigo: '',
392
- nomeInterno: '',
393
- tituloComercial: '',
394
- descricaoPublica: '',
395
- objetivos: '',
396
- publicoAlvo: '',
397
- nivel: 'iniciante',
398
- status: 'rascunho',
399
- tipoOferta: 'sob_demanda',
400
- categorias: [],
401
- primaryColor: '#1D4ED8',
402
- secondaryColor: '#111827',
403
- instrutores: [],
404
- preRequisitos: '',
405
- modeloCertificado: '',
406
- certificado: false,
407
- destaque: false,
408
- listado: false,
409
- },
410
- });
411
-
412
- const NIVEIS = useMemo(() => getNiveis(t), [t]);
413
- const STATUS_OPTIONS = useMemo(() => getStatusOptions(t), [t]);
414
- const OFFERING_TYPE_OPTIONS = useMemo(() => getOfferingTypeOptions(t), [t]);
69
+ const { request } = useApp();
415
70
 
416
- const {
417
- data: apiCourse,
418
- isLoading,
419
- isFetching,
420
- refetch: refetchCourse,
421
- } = useQuery<ApiCourseDetail>({
71
+ const { data: courseSummary } = useQuery<ApiCourseSummary>({
422
72
  queryKey: ['lms-course-detail', id],
73
+ enabled: Boolean(id),
423
74
  queryFn: async () => {
424
- const response = await request<ApiCourseDetail>({
75
+ const response = await request<ApiCourseSummary>({
425
76
  url: `/lms/courses/${id}`,
426
77
  method: 'GET',
427
78
  });
@@ -429,682 +80,310 @@ export default function CursoEditPage({
429
80
  },
430
81
  });
431
82
 
432
- const { data: categoryListData, refetch: refetchCategoryOptions } =
433
- useQuery<ApiCategoryList>({
434
- queryKey: ['lms-edit-course-categories'],
435
- queryFn: async () => {
436
- const response = await request<ApiCategoryList | ApiCategory[]>({
437
- url: '/category',
438
- method: 'GET',
439
- params: {
440
- page: 1,
441
- pageSize: 500,
442
- status: 'all',
443
- },
444
- });
445
-
446
- const payload = response.data;
447
- if (Array.isArray(payload)) {
448
- return {
449
- data: payload,
450
- total: payload.length,
451
- page: 1,
452
- pageSize: payload.length,
453
- };
454
- }
455
-
456
- return payload;
457
- },
458
- initialData: {
459
- data: [],
460
- total: 0,
461
- page: 1,
462
- pageSize: 500,
463
- },
464
- });
465
-
466
- const { data: instructorListData, refetch: refetchInstructorOptions } =
467
- useQuery<ApiInstructorList>({
468
- queryKey: ['lms-course-edit-instructors'],
469
- queryFn: async () => {
470
- const response = await request<ApiInstructorList | ApiInstructor[]>({
471
- url: '/lms/instructors',
472
- method: 'GET',
473
- params: {
474
- page: 1,
475
- pageSize: 500,
476
- qualificationSlugs: ['course-lessons'],
477
- },
478
- });
479
-
480
- const payload = response.data;
481
- if (Array.isArray(payload)) {
482
- return {
483
- data: payload,
484
- total: payload.length,
485
- page: 1,
486
- pageSize: payload.length,
487
- };
488
- }
489
-
490
- return payload;
491
- },
492
- initialData: {
493
- data: [],
494
- total: 0,
495
- page: 1,
496
- pageSize: 500,
497
- },
498
- });
499
-
83
+ // ── API: load course structure from the real backend ─────────────────────
500
84
  const {
501
- data: certificateTemplateData,
502
- refetch: refetchCertificateTemplates,
503
- } = useQuery<ApiCertificateTemplateList>({
504
- queryKey: ['lms-course-certificate-templates'],
505
- queryFn: async () => {
506
- const response = await request<
507
- ApiCertificateTemplateList | ApiCertificateTemplate[]
508
- >({
509
- url: '/lms/certificates/templates',
510
- method: 'GET',
511
- params: {
512
- page: 1,
513
- pageSize: 100,
514
- },
515
- });
516
-
517
- const payload = response.data;
518
- if (Array.isArray(payload)) {
519
- return {
520
- data: payload,
521
- total: payload.length,
522
- page: 1,
523
- pageSize: payload.length,
524
- lastPage: 1,
525
- };
526
- }
527
-
528
- return payload;
529
- },
530
- initialData: {
531
- data: [],
532
- total: 0,
533
- page: 1,
534
- pageSize: 100,
535
- lastPage: 1,
536
- },
537
- });
538
-
539
- useEffect(() => {
540
- void refetchCourse();
541
- }, [refetchCourse]);
542
-
543
- const categoryOptions = useMemo(() => {
544
- const serverOptions = (categoryListData?.data ?? [])
545
- .filter((item) => !!item.slug)
546
- .map((item) => ({
547
- value: item.slug,
548
- label: item.name || item.slug,
549
- }));
550
-
551
- const merged = [...serverOptions, ...createdCategoryOptions];
552
- return merged.filter(
553
- (item, index, array) =>
554
- array.findIndex((candidate) => candidate.value === item.value) === index
555
- );
556
- }, [categoryListData, createdCategoryOptions]);
557
-
558
- const instructorOptions = useMemo(() => {
559
- const fromCourse = (apiCourse?.instructors ?? []).map((item) => ({
560
- value: String(item.id),
561
- label: item.name,
562
- avatarUrl: getInstructorAvatarUrl(item.avatarId),
563
- meta: `ID ${item.id}`,
564
- }));
565
-
566
- const fromDirectory = (instructorListData?.data ?? []).map((item) => ({
567
- value: String(item.id),
568
- label: item.name,
569
- avatarUrl: getInstructorAvatarUrl(item.avatarId),
570
- meta: item.qualificationSlugs?.join(' • ') || `ID ${item.id}`,
571
- }));
572
-
573
- const merged = [...fromCourse, ...fromDirectory];
574
- return merged.filter(
575
- (item, index, array) =>
576
- array.findIndex((candidate) => candidate.value === item.value) === index
577
- );
578
- }, [apiCourse?.instructors, instructorListData]);
579
-
580
- const certificateOptions = useMemo(() => {
581
- const serverOptions = (certificateTemplateData?.data ?? []).map((item) => ({
582
- value: item.slug || String(item.id),
583
- label: item.name,
584
- description: item.description || null,
585
- meta: item.status ? `Status: ${item.status}` : null,
586
- }));
85
+ course: apiCourse,
86
+ sessions: apiSessions,
87
+ lessons: apiLessons,
88
+ isLoading,
89
+ isError,
90
+ refetch,
91
+ } = useCourseStructureQuery(id);
587
92
 
588
- const merged = [...serverOptions, ...createdTemplateOptions];
589
- return merged.filter(
590
- (item, index, array) =>
591
- array.findIndex((candidate) => candidate.value === item.value) === index
592
- );
593
- }, [certificateTemplateData, createdTemplateOptions]);
594
-
595
- const cursoData = useMemo(
596
- () => (apiCourse ? mapApiCourseToView(apiCourse) : null),
597
- [apiCourse]
598
- );
93
+ const setStructureFromApi = useStructureStore((s) => s.setStructureFromApi);
94
+ const setCourseId = useStructureStore((s) => s.setCourseId);
599
95
 
600
96
  useEffect(() => {
601
- if (!apiCourse) return;
602
-
603
- const nextCertificateModel =
604
- apiCourse.certificateModel ?? persistedCertificateModel ?? '';
605
-
606
- form.reset({
607
- codigo: apiCourse.code,
608
- nomeInterno: apiCourse.slug,
609
- tituloComercial: apiCourse.title,
610
- descricaoPublica: apiCourse.description ?? '',
611
- objetivos: apiCourse.objectives ?? '',
612
- publicoAlvo: apiCourse.targetAudience ?? '',
613
- nivel: toPtLevel(apiCourse.level),
614
- status: toPtStatus(apiCourse.status),
615
- tipoOferta: toPtOfferingType(apiCourse.offeringType),
616
- categorias: apiCourse.categories ?? [],
617
- primaryColor: apiCourse.primaryColor || '#1D4ED8',
618
- secondaryColor: apiCourse.secondaryColor || '#111827',
619
- instrutores: (apiCourse.instructorIds ?? []).map(String),
620
- preRequisitos: apiCourse.requirements ?? '',
621
- modeloCertificado: nextCertificateModel,
622
- certificado: apiCourse.hasCertificate ?? false,
623
- destaque: apiCourse.isFeatured ?? false,
624
- listado: apiCourse.isListed ?? false,
625
- });
97
+ setCourseId(id);
98
+ }, [id, setCourseId]);
626
99
 
627
- setPersistedCertificateModel(nextCertificateModel);
628
- }, [apiCourse, form, persistedCertificateModel]);
100
+ const isMutating = useIsMutating();
629
101
 
630
102
  useEffect(() => {
631
- const previews = [
632
- { fileId: apiCourse?.logoFileId, setter: setLogoPreview },
633
- { fileId: apiCourse?.bannerFileId, setter: setBannerPreview },
634
- ];
635
-
636
- previews.forEach(({ fileId, setter }) => {
637
- if (!fileId) return;
638
-
639
- void (async () => {
640
- try {
641
- const response = await request<{ url?: string }>({
642
- url: `/file/open/${fileId}`,
643
- method: 'PUT',
103
+ if (isMutating > 0) return;
104
+ if (
105
+ !isLoading &&
106
+ !isError &&
107
+ (apiSessions.length > 0 || apiLessons.length > 0 || apiCourse)
108
+ ) {
109
+ setStructureFromApi({
110
+ course:
111
+ apiCourse && courseSummary?.name
112
+ ? { ...apiCourse, name: courseSummary.name }
113
+ : apiCourse,
114
+ sessions: apiSessions,
115
+ lessons: apiLessons,
116
+ });
117
+ }
118
+ }, [
119
+ apiCourse,
120
+ apiSessions,
121
+ apiLessons,
122
+ isLoading,
123
+ isError,
124
+ isMutating,
125
+ setStructureFromApi,
126
+ ]);
127
+
128
+ const course = useStructureStore((s) => s.course);
129
+ const sessions = useStructureStore((s) => s.sessions);
130
+ const lessons = useStructureStore((s) => s.lessons);
131
+
132
+ const mobileSheetOpen = useStructureStore((s) => s.mobileSheetOpen);
133
+ const setMobileSheetOpen = useStructureStore((s) => s.setMobileSheetOpen);
134
+
135
+ const searchRef = useRef<SearchFilterHandle>(null);
136
+ const detailPanelRef = useRef<HTMLDivElement>(null);
137
+ const [shortcutsOpen, setShortcutsOpen] = useState(false);
138
+
139
+ const duplicateLessonMutation = useDuplicateLessonMutation();
140
+ const duplicateSessionMutation = useDuplicateSessionMutation();
141
+ const pasteLessonsMutation = usePasteLessonsMutation();
142
+ const pasteSessionsMutation = usePasteSessionsMutation();
143
+
144
+ const activeItemId = useStructureStore((s) => s.activeItemId);
145
+ const activeItemType = useStructureStore((s) => s.activeItemType);
146
+ const allLessons = useStructureStore((s) => s.lessons);
147
+ const copiedType = useStructureStore((s) => s.copiedType);
148
+ const copiedIds = useStructureStore((s) => s.copiedIds);
149
+
150
+ useCourseStructureShortcuts({
151
+ searchRef,
152
+ detailPanelRef,
153
+ onToggleHelp: useCallback(() => setShortcutsOpen((v) => !v), []),
154
+ onDuplicate: useCallback(() => {
155
+ if (!activeItemId) return;
156
+ if (activeItemType === 'lesson') {
157
+ const lesson = allLessons.find((l) => l.id === activeItemId);
158
+ if (lesson) {
159
+ duplicateLessonMutation.mutate({
160
+ sessionId: lesson.sessionId,
161
+ lessonId: lesson.id,
644
162
  });
645
-
646
- if (response?.data?.url) {
647
- setter(response.data.url);
648
- }
649
- } catch {
650
- // Ignore preview failures and keep file actions available.
651
163
  }
652
- })();
653
- });
654
- }, [apiCourse?.bannerFileId, apiCourse?.logoFileId, request]);
655
-
656
- async function openUploadedFile(fileId?: number | null) {
657
- if (!fileId) return;
658
-
659
- try {
660
- const response = await request<{ url?: string }>({
661
- url: `/file/open/${fileId}`,
662
- method: 'PUT',
663
- });
664
-
665
- const url = response?.data?.url;
666
- if (!url) {
667
- toast.error(t('toasts.openFileError'));
668
- return;
164
+ } else if (activeItemType === 'session') {
165
+ duplicateSessionMutation.mutate({ sessionId: activeItemId });
669
166
  }
670
-
671
- window.open(url, '_blank', 'noopener,noreferrer');
672
- } catch {
673
- toast.error(t('toasts.openFileError'));
674
- }
675
- }
676
-
677
- async function handleFileSelect(
678
- event: ChangeEvent<HTMLInputElement>,
679
- setter: (value: string | null) => void,
680
- type: 'logo' | 'banner'
681
- ) {
682
- const file = event.target.files?.[0];
683
- if (!file) return;
684
-
685
- if (!file.type.startsWith('image/')) {
686
- toast.error(t('toasts.onlyImages'));
687
- return;
688
- }
689
-
690
- if (file.size > 5 * 1024 * 1024) {
691
- toast.error(t('toasts.maxSize'));
692
- return;
693
- }
694
-
695
- const objectUrl = URL.createObjectURL(file);
696
- setter(objectUrl);
697
- type === 'logo' ? setUploadingLogo(true) : setUploadingBanner(true);
698
-
699
- try {
700
- const formData = new FormData();
701
- formData.append('file', file);
702
- formData.append('destination', 'lms/courses');
703
-
704
- const uploadResponse = await request<{ id: number; filename: string }>({
705
- url: '/file',
706
- method: 'POST',
707
- data: formData,
708
- headers: {
709
- 'Content-Type': 'multipart/form-data',
710
- },
711
- });
712
-
713
- const fileId = uploadResponse?.data?.id;
714
- if (!fileId) {
715
- throw new Error('invalid file id');
167
+ }, [
168
+ activeItemId,
169
+ activeItemType,
170
+ allLessons,
171
+ duplicateLessonMutation,
172
+ duplicateSessionMutation,
173
+ ]),
174
+ onPaste: useCallback(() => {
175
+ if (!copiedIds.length) return;
176
+ if (copiedType === 'lesson') {
177
+ let targetSessionId: string | null = null;
178
+ if (activeItemType === 'session') {
179
+ targetSessionId = activeItemId;
180
+ } else if (activeItemType === 'lesson') {
181
+ const lesson = allLessons.find((l) => l.id === activeItemId);
182
+ targetSessionId = lesson?.sessionId ?? null;
183
+ }
184
+ if (targetSessionId) {
185
+ pasteLessonsMutation.mutate({
186
+ targetSessionId,
187
+ lessonIds: copiedIds,
188
+ });
189
+ }
190
+ } else if (copiedType === 'session') {
191
+ pasteSessionsMutation.mutate({ sessionIds: copiedIds });
716
192
  }
193
+ }, [
194
+ activeItemId,
195
+ activeItemType,
196
+ allLessons,
197
+ copiedIds,
198
+ copiedType,
199
+ pasteLessonsMutation,
200
+ pasteSessionsMutation,
201
+ ]),
202
+ });
717
203
 
718
- await request({
719
- url: `/lms/courses/${id}`,
720
- method: 'PATCH',
721
- data: type === 'logo' ? { logoFileId: fileId } : { bannerFileId: fileId },
722
- });
723
-
724
- clearCoursesListCache();
725
- await refetchCourse();
726
- toast.success(t('toasts.fileSelected', { name: file.name }));
727
- } catch {
728
- toast.error(t('toasts.fileUploadError'));
729
- } finally {
730
- type === 'logo' ? setUploadingLogo(false) : setUploadingBanner(false);
731
- }
732
- }
733
-
734
- async function handleCreateCategory(values: Record<string, string>) {
735
- const name = String(values.name ?? '').trim();
736
- const slug = slugify(values.slug || values.name || '');
737
-
738
- if (!name || !slug) {
739
- toast.error('Informe nome e slug para criar a categoria.');
740
- return null;
741
- }
742
-
743
- const localeCode =
744
- currentLocaleCode || (locales?.[0] as Locale | undefined)?.code || 'pt-BR';
745
-
746
- try {
747
- await request({
748
- url: '/category',
749
- method: 'POST',
750
- data: {
751
- locale: {
752
- [localeCode]: {
753
- name,
754
- },
755
- },
756
- slug,
757
- category_id: null,
758
- color: '#000000',
759
- icon: '',
760
- status: 'active',
761
- },
762
- });
763
-
764
- const option = { value: slug, label: name };
765
- setCreatedCategoryOptions((current) => [option, ...current]);
766
- await refetchCategoryOptions();
767
- toast.success('Categoria criada com sucesso.');
768
- return option;
769
- } catch {
770
- toast.error('Não foi possível criar a categoria.');
771
- return null;
772
- }
773
- }
774
-
775
- async function handleCreateCertificateTemplate(values: Record<string, string>) {
776
- const name = String(values.name ?? '').trim();
777
- const description = String(values.description ?? '').trim();
778
-
779
- if (!name) {
780
- toast.error('Informe o nome do modelo.');
781
- return null;
782
- }
783
-
784
- const initialTemplate = createDefaultTemplate();
785
- initialTemplate.name = name;
786
-
787
- try {
788
- const response = await request<ApiCertificateTemplate>({
789
- url: '/lms/certificates/templates',
790
- method: 'POST',
791
- data: {
792
- name,
793
- description: description || undefined,
794
- status: 'draft',
795
- templateContent: JSON.stringify(initialTemplate),
796
- },
797
- });
798
-
799
- const created = response.data;
800
- const option = {
801
- value: created.slug || String(created.id),
802
- label: created.name,
803
- description: created.description || null,
804
- meta: created.status ? `Status: ${created.status}` : null,
805
- };
806
-
807
- setCreatedTemplateOptions((current) => [option, ...current]);
808
- setPersistedCertificateModel(option.value);
809
- await refetchCertificateTemplates();
810
- toast.success('Modelo de certificado criado com sucesso.');
811
- return option;
812
- } catch {
813
- toast.error('Não foi possível criar o modelo de certificado.');
814
- return null;
815
- }
816
- }
817
-
818
- async function onSubmit(data: CourseEditFormValues) {
819
- setSaving(true);
820
-
821
- try {
822
- await request({
823
- url: `/lms/courses/${id}`,
824
- method: 'PATCH',
825
- data: {
826
- code: data.codigo.trim().toUpperCase(),
827
- slug: data.nomeInterno.trim(),
828
- title: data.tituloComercial.trim(),
829
- description: data.descricaoPublica.trim(),
830
- requirements: data.preRequisitos?.trim() || undefined,
831
- objectives: data.objetivos?.trim() || undefined,
832
- targetAudience: data.publicoAlvo?.trim() || undefined,
833
- level: toApiLevel(data.nivel),
834
- status: toApiStatus(data.status),
835
- offeringType: toApiOfferingType(data.tipoOferta),
836
- categorySlugs: data.categorias,
837
- primaryColor: data.primaryColor,
838
- primaryContrastColor: getContrastColor(data.primaryColor),
839
- secondaryColor: data.secondaryColor,
840
- secondaryContrastColor: getContrastColor(data.secondaryColor),
841
- hasCertificate: data.certificado,
842
- isFeatured: data.destaque,
843
- isListed: data.listado,
844
- certificateModel: data.modeloCertificado || undefined,
845
- instructorIds: (data.instrutores ?? []).map((item) => Number(item)),
846
- },
847
- });
848
-
849
- setPersistedCertificateModel(data.modeloCertificado || '');
850
- clearCoursesListCache();
851
- toast.success(t('toasts.courseUpdated'));
852
- await refetchCourse();
853
- } finally {
854
- setSaving(false);
855
- }
856
- }
857
-
858
- const handleInstructorCreated = async (instructor: {
859
- id: number;
860
- personId: number;
861
- name: string;
862
- avatarId?: number | null;
863
- email?: string | null;
864
- phone?: string | null;
865
- qualificationSlugs: string[];
866
- }) => {
867
- const createdId = String(instructor.id);
868
- const current = form.getValues('instrutores') ?? [];
869
-
870
- if (!current.includes(createdId)) {
871
- form.setValue('instrutores', [...current, createdId], {
872
- shouldDirty: true,
873
- shouldTouch: true,
874
- shouldValidate: true,
875
- });
876
- }
877
-
878
- await refetchInstructorOptions();
879
- };
880
-
881
- async function handleDelete() {
882
- setDeleting(true);
883
-
884
- try {
885
- await request({
886
- url: `/lms/courses/${id}`,
887
- method: 'DELETE',
888
- });
889
-
890
- clearCoursesListCache();
891
- setDeleteDialogOpen(false);
892
- toast.success(t('toasts.courseDeleted'));
893
- router.push('/lms/courses');
894
- } finally {
895
- setDeleting(false);
896
- }
897
- }
898
-
899
- const handleNewStructure = () => {
900
- router.push(`/lms/courses/${id}/structure`);
901
- };
902
-
903
- const handleManageClasses = () => {
904
- router.push(`/lms/classes?courseId=${id}`);
905
- };
204
+ const totalMinutes = useMemo(
205
+ () => lessons.reduce((sum, l) => sum + l.duration, 0),
206
+ [lessons]
207
+ );
208
+ const hours = Math.floor(totalMinutes / 60);
209
+ const mins = totalMinutes % 60;
210
+ const durationLabel = hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
211
+
212
+ const publishedLessons = useMemo(
213
+ () =>
214
+ lessons.filter(
215
+ (l) => l.status === 'publicada' || l.visibility === 'publico'
216
+ ).length,
217
+ [lessons]
218
+ );
906
219
 
907
- const loading = isLoading || isFetching;
220
+ const metricsBadges = useMemo(
221
+ () => (
222
+ <div className="flex items-center gap-1.5 flex-wrap">
223
+ <Badge
224
+ variant="outline"
225
+ className="gap-1 text-[0.65rem] h-5 px-1.5 font-normal"
226
+ >
227
+ <Layers className="size-3" />
228
+ {sessions.length} {sessions.length === 1 ? 'sessão' : 'sessões'}
229
+ </Badge>
230
+ <Badge
231
+ variant="outline"
232
+ className="gap-1 text-[0.65rem] h-5 px-1.5 font-normal"
233
+ >
234
+ <Video className="size-3" />
235
+ {lessons.length} {lessons.length === 1 ? 'aula' : 'aulas'}
236
+ </Badge>
237
+ <Badge
238
+ variant="outline"
239
+ className="gap-1 text-[0.65rem] h-5 px-1.5 font-normal"
240
+ >
241
+ <BookOpen className="size-3" />
242
+ {durationLabel}
243
+ </Badge>
244
+ {publishedLessons > 0 && (
245
+ <Badge className="gap-1 text-[0.65rem] h-5 px-1.5 font-normal bg-emerald-500/15 text-emerald-700 dark:text-emerald-400 border-0 hover:bg-emerald-500/20">
246
+ <span className="size-1.5 rounded-full bg-emerald-500 inline-block" />
247
+ {publishedLessons} publicadas
248
+ </Badge>
249
+ )}
250
+ </div>
251
+ ),
252
+ [sessions.length, lessons.length, durationLabel, publishedLessons]
253
+ );
908
254
 
909
255
  return (
910
256
  <Page>
911
257
  <PageHeader
912
- title={cursoData?.tituloComercial ?? ''}
913
- description={t('pageHeader.description', {
914
- id,
915
- creator: '-',
916
- })}
917
258
  breadcrumbs={[
918
- { label: t('breadcrumbs.home'), href: '/' },
919
- { label: t('breadcrumbs.courses'), href: '/lms/courses' },
920
- { label: t('breadcrumbs.editCourse') },
921
- ]}
922
- actions={[
923
- {
924
- label: t('actions.goToStructure'),
925
- onClick: handleNewStructure,
926
- variant: 'default',
927
- },
928
- {
929
- label: t('actions.manageClasses'),
930
- onClick: handleManageClasses,
931
- variant: 'outline',
932
- },
259
+ { label: 'LMS', href: '/lms' },
260
+ { label: 'Cursos', href: '/lms/courses' },
261
+ { label: course.name },
933
262
  ]}
263
+ title={course.name}
264
+ extraContent={metricsBadges}
265
+ actions={
266
+ <>
267
+ <Button
268
+ variant="outline"
269
+ size="sm"
270
+ onClick={() => router.push(`/lms/classes?courseId=${id}`)}
271
+ >
272
+ Gerenciar Turmas
273
+ </Button>
274
+ <TreeDisplaySettingsPopover />
275
+ <ShortcutsHelpTrigger onOpen={() => setShortcutsOpen(true)} />
276
+ </>
277
+ }
934
278
  />
935
279
 
936
- {loading ? (
937
- <LoadingSkeleton />
938
- ) : !cursoData ? (
939
- <EmptyState
940
- icon={<BookOpen className="size-12 text-muted-foreground/40" />}
941
- title="Curso não encontrado"
942
- description={`Não foi possível localizar o curso com id ${id}.`}
943
- actionLabel="Voltar para cursos"
944
- onAction={() => router.push('/lms/courses')}
945
- className="py-20"
946
- />
947
- ) : (
948
- <Form {...form}>
949
- <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
950
- <CourseSummaryCard course={cursoData} />
951
-
952
- <div className="grid gap-6 xl:grid-cols-[minmax(0,2fr)_minmax(320px,1fr)]">
953
- <div className="space-y-6">
954
- <CourseMainInfoCard form={form} t={t} />
955
- <CourseClassificationCard
956
- form={form}
957
- t={t}
958
- levels={NIVEIS}
959
- statuses={STATUS_OPTIONS}
960
- offeringTypes={OFFERING_TYPE_OPTIONS}
961
- />
962
- <CourseRelationsCard
963
- form={form}
964
- t={t}
965
- categoryOptions={categoryOptions}
966
- instructorOptions={instructorOptions}
967
- onCreateCategory={handleCreateCategory}
968
- onCreateInstructor={() => setInstructorSheetOpen(true)}
969
- />
970
- <CourseContentCard form={form} />
971
- </div>
972
-
973
- <div className="space-y-6">
974
- <CourseMediaCard
975
- logoPreview={logoPreview}
976
- bannerPreview={bannerPreview}
977
- uploadingLogo={uploadingLogo}
978
- uploadingBanner={uploadingBanner}
979
- onLogoSelect={(event) =>
980
- handleFileSelect(event, setLogoPreview, 'logo')
981
- }
982
- onBannerSelect={(event) =>
983
- handleFileSelect(event, setBannerPreview, 'banner')
984
- }
985
- logoFile={
986
- apiCourse?.logoFileId
987
- ? {
988
- id: apiCourse.logoFileId,
989
- name: apiCourse.logoFilename || `#${apiCourse.logoFileId}`,
990
- }
991
- : undefined
992
- }
993
- bannerFile={
994
- apiCourse?.bannerFileId
995
- ? {
996
- id: apiCourse.bannerFileId,
997
- name:
998
- apiCourse.bannerFilename || `#${apiCourse.bannerFileId}`,
999
- }
1000
- : undefined
1001
- }
1002
- onOpenLogoFile={() => openUploadedFile(apiCourse?.logoFileId)}
1003
- onOpenBannerFile={() => openUploadedFile(apiCourse?.bannerFileId)}
1004
- t={t}
1005
- />
1006
-
1007
- <CourseCertificateCard
1008
- form={form}
1009
- t={t}
1010
- options={certificateOptions}
1011
- onCreateTemplate={handleCreateCertificateTemplate}
1012
- />
1013
-
1014
- <CourseFlagsCard form={form} t={t} />
1015
-
1016
- <CourseDangerZoneCard
1017
- t={t}
1018
- onDelete={() => setDeleteDialogOpen(true)}
1019
- />
1020
- </div>
1021
- </div>
1022
-
1023
- <div className="flex flex-col gap-3 rounded-2xl border border-border/70 bg-background/80 p-4 backdrop-blur sm:flex-row sm:items-center sm:justify-between">
1024
- <p className="text-sm text-muted-foreground">
1025
- Revise os vínculos e a mídia antes de salvar para evitar retrabalho.
1026
- </p>
1027
-
1028
- <div className="flex items-center gap-2">
1029
- <Button
1030
- type="button"
1031
- variant="outline"
1032
- onClick={() => router.push('/lms/courses')}
1033
- >
1034
- {t('form.actions.cancel')}
1035
- </Button>
1036
- <Button type="submit" disabled={saving} className="gap-2">
1037
- {saving ? (
1038
- <Loader2 className="h-4 w-4 animate-spin" />
1039
- ) : (
1040
- <Save className="h-4 w-4" />
1041
- )}
1042
- {t('form.actions.saveChanges')}
1043
- </Button>
280
+ <ShortcutsHelp open={shortcutsOpen} onOpenChange={setShortcutsOpen} />
281
+ <ConfirmDialog />
282
+ <SessionPickerDialog />
283
+
284
+ {/* ── Desktop / Tablet ─────────────────────────────────────────────── */}
285
+ {!isMobile && (
286
+ <div className="flex-1 min-h-0 rounded-lg border overflow-hidden h-[calc(100dvh-10rem)]">
287
+ <ResizablePanelGroup direction="horizontal" className="h-full">
288
+ <ResizablePanel
289
+ defaultSize={28}
290
+ minSize={18}
291
+ maxSize={45}
292
+ className="flex flex-col"
293
+ >
294
+ {isLoading ? (
295
+ <CourseTreeSkeleton />
296
+ ) : isError ? (
297
+ <div className="flex flex-col items-center justify-center h-full gap-3 p-4 text-center">
298
+ <AlertCircle className="size-8 text-destructive" />
299
+ <p className="text-sm text-muted-foreground">
300
+ Não foi possível carregar a estrutura do curso.
301
+ </p>
302
+ <Button
303
+ variant="outline"
304
+ size="sm"
305
+ onClick={() => refetch()}
306
+ className="gap-2"
307
+ >
308
+ <RefreshCw className="size-3.5" />
309
+ Tentar novamente
310
+ </Button>
311
+ </div>
312
+ ) : (
313
+ <CourseTreePanel ref={searchRef} />
314
+ )}
315
+ </ResizablePanel>
316
+
317
+ <ResizableHandle withHandle />
318
+
319
+ <ResizablePanel defaultSize={72} className="flex flex-col min-h-0">
320
+ <div
321
+ ref={detailPanelRef}
322
+ className="flex flex-col h-full min-h-0"
323
+ >
324
+ <DetailPanel />
1044
325
  </div>
1045
- </div>
1046
- </form>
1047
- </Form>
326
+ </ResizablePanel>
327
+ </ResizablePanelGroup>
328
+ </div>
1048
329
  )}
1049
330
 
1050
- <CreateLmsPersonSheet
1051
- open={instructorSheetOpen}
1052
- onOpenChange={setInstructorSheetOpen}
1053
- onCreated={handleInstructorCreated}
1054
- title="Cadastrar instrutor"
1055
- description="Cadastre um novo instrutor e adicione-o ao curso."
1056
- submitLabel="Cadastrar instrutor"
1057
- successMessage="Instrutor cadastrado com sucesso."
1058
- errorMessage="Não foi possível cadastrar o instrutor."
1059
- defaultQualificationSlugs={['course-lessons']}
1060
- />
1061
-
1062
- <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
1063
- <DialogContent className="max-w-3xl">
1064
- <DialogHeader>
1065
- <DialogTitle className="flex items-center gap-2">
1066
- <AlertTriangle className="size-5 text-destructive" />
1067
- {t('deleteDialog.title')}
1068
- </DialogTitle>
1069
- <DialogDescription asChild>
1070
- <div className="flex flex-col gap-3">
1071
- <p>
1072
- {t('deleteDialog.description')}{' '}
1073
- <strong className="text-foreground">
1074
- {cursoData?.tituloComercial}
1075
- </strong>
1076
- ?
1077
- </p>
1078
- {(cursoData?.totalAlunos ?? 0) > 0 ? (
1079
- <div className="flex items-center gap-2 rounded-md bg-amber-50 px-3 py-2.5 text-xs font-medium text-amber-700">
1080
- <AlertTriangle className="size-3.5 shrink-0" />
1081
- <span>
1082
- {t('deleteDialog.warning', {
1083
- students: cursoData?.totalAlunos ?? 0,
1084
- certificates: cursoData?.certificadosEmitidos ?? 0,
1085
- })}
1086
- </span>
1087
- </div>
1088
- ) : null}
1089
- </div>
1090
- </DialogDescription>
1091
- </DialogHeader>
1092
- <DialogFooter className="gap-2">
1093
- <Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
1094
- {t('deleteDialog.actions.cancel')}
1095
- </Button>
331
+ {/* ── Mobile ───────────────────────────────────────────────────────── */}
332
+ {isMobile && (
333
+ <>
334
+ <div className="flex items-center gap-2 shrink-0">
1096
335
  <Button
1097
- variant="destructive"
1098
- onClick={handleDelete}
1099
- disabled={deleting}
336
+ variant="outline"
337
+ onClick={() => setMobileSheetOpen(true)}
1100
338
  className="gap-2"
339
+ aria-label="Abrir estrutura do curso"
1101
340
  >
1102
- {deleting ? <Loader2 className="size-4 animate-spin" /> : null}
1103
- {t('deleteDialog.actions.delete')}
341
+ <Menu className="size-4" />
342
+ Estrutura
1104
343
  </Button>
1105
- </DialogFooter>
1106
- </DialogContent>
1107
- </Dialog>
344
+ </div>
345
+
346
+ <div
347
+ ref={detailPanelRef}
348
+ className="flex-1 min-h-0 border rounded-lg overflow-hidden"
349
+ >
350
+ <DetailPanel />
351
+ </div>
352
+
353
+ <Sheet open={mobileSheetOpen} onOpenChange={setMobileSheetOpen}>
354
+ <SheetContent side="left" className="w-[320px] p-0 flex flex-col">
355
+ <SheetHeader className="px-4 py-3 border-b shrink-0">
356
+ <SheetTitle className="text-sm">
357
+ {course.title} — Estrutura
358
+ </SheetTitle>
359
+ </SheetHeader>
360
+ <div className="flex-1 min-h-0 overflow-hidden">
361
+ {isLoading ? (
362
+ <CourseTreeSkeleton />
363
+ ) : isError ? (
364
+ <div className="flex flex-col items-center justify-center h-full gap-3 p-4 text-center">
365
+ <AlertCircle className="size-8 text-destructive" />
366
+ <p className="text-sm text-muted-foreground">
367
+ Não foi possível carregar a estrutura do curso.
368
+ </p>
369
+ <Button
370
+ variant="outline"
371
+ size="sm"
372
+ onClick={() => refetch()}
373
+ className="gap-2"
374
+ >
375
+ <RefreshCw className="size-3.5" />
376
+ Tentar novamente
377
+ </Button>
378
+ </div>
379
+ ) : (
380
+ <CourseTreePanel />
381
+ )}
382
+ </div>
383
+ </SheetContent>
384
+ </Sheet>
385
+ </>
386
+ )}
1108
387
  </Page>
1109
388
  );
1110
389
  }