@hed-hog/lms 0.0.314 → 0.0.316

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 (67) hide show
  1. package/dist/enterprise/dto/enterprise-profile.dto.d.ts +13 -0
  2. package/dist/enterprise/dto/enterprise-profile.dto.d.ts.map +1 -0
  3. package/dist/enterprise/dto/enterprise-profile.dto.js +3 -0
  4. package/dist/enterprise/dto/enterprise-profile.dto.js.map +1 -0
  5. package/dist/enterprise/enterprise.controller.d.ts +3 -0
  6. package/dist/enterprise/enterprise.controller.d.ts.map +1 -1
  7. package/dist/enterprise/enterprise.controller.js +14 -0
  8. package/dist/enterprise/enterprise.controller.js.map +1 -1
  9. package/dist/enterprise/enterprise.service.d.ts +3 -0
  10. package/dist/enterprise/enterprise.service.d.ts.map +1 -1
  11. package/dist/enterprise/enterprise.service.js +128 -1
  12. package/dist/enterprise/enterprise.service.js.map +1 -1
  13. package/dist/instructor/instructor.controller.d.ts +23 -0
  14. package/dist/instructor/instructor.controller.d.ts.map +1 -1
  15. package/dist/instructor/instructor.controller.js +41 -0
  16. package/dist/instructor/instructor.controller.js.map +1 -1
  17. package/dist/instructor/instructor.service.d.ts +25 -0
  18. package/dist/instructor/instructor.service.d.ts.map +1 -1
  19. package/dist/instructor/instructor.service.js +126 -8
  20. package/dist/instructor/instructor.service.js.map +1 -1
  21. package/hedhog/data/menu.yaml +23 -7
  22. package/hedhog/data/role.yaml +17 -1
  23. package/hedhog/data/route.yaml +48 -0
  24. package/hedhog/frontend/app/_components/class-form-sheet.tsx.ejs +2 -1
  25. package/hedhog/frontend/app/courses/[id]/structure/_components/confirm-dialog.tsx.ejs +44 -44
  26. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-dnd.tsx.ejs +362 -362
  27. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-panel.tsx.ejs +111 -111
  28. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree.tsx.ejs +134 -134
  29. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-course.tsx.ejs +113 -113
  30. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +314 -314
  31. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-panel.tsx.ejs +62 -62
  32. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-session.tsx.ejs +173 -173
  33. package/hedhog/frontend/app/courses/[id]/structure/_components/drag-handle.tsx.ejs +58 -58
  34. package/hedhog/frontend/app/courses/[id]/structure/_components/drag-overlay.tsx.ejs +51 -51
  35. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-bulk.tsx.ejs +276 -276
  36. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +1216 -1216
  37. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +1824 -1824
  38. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-session.tsx.ejs +443 -443
  39. package/hedhog/frontend/app/courses/[id]/structure/_components/highlighted-text.tsx.ejs +40 -40
  40. package/hedhog/frontend/app/courses/[id]/structure/_components/mock-data.ts.ejs +185 -185
  41. package/hedhog/frontend/app/courses/[id]/structure/_components/multi-select-bar.tsx.ejs +264 -264
  42. package/hedhog/frontend/app/courses/[id]/structure/_components/search-filter.tsx.ejs +95 -95
  43. package/hedhog/frontend/app/courses/[id]/structure/_components/session-picker-dialog.tsx.ejs +73 -73
  44. package/hedhog/frontend/app/courses/[id]/structure/_components/shortcuts-help.tsx.ejs +136 -136
  45. package/hedhog/frontend/app/courses/[id]/structure/_components/sortable-tree-row.tsx.ejs +80 -80
  46. package/hedhog/frontend/app/courses/[id]/structure/_components/store.ts.ejs +949 -949
  47. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-context-menu.tsx.ejs +525 -525
  48. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-helpers.ts.ejs +181 -181
  49. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-course.tsx.ejs +51 -51
  50. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +271 -271
  51. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-session.tsx.ejs +167 -167
  52. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row.tsx.ejs +108 -108
  53. package/hedhog/frontend/app/courses/[id]/structure/_components/use-course-structure-shortcuts.ts.ejs +318 -318
  54. package/hedhog/frontend/app/courses/[id]/structure/page.tsx.ejs +10 -10
  55. package/hedhog/frontend/app/enterprise/_components/enterprise-course-create-sheet.tsx.ejs +2 -1
  56. package/hedhog/frontend/app/instructors/_components/instructor-form-sheet.tsx.ejs +438 -0
  57. package/hedhog/frontend/app/instructors/_components/instructor-types.ts.ejs +40 -0
  58. package/hedhog/frontend/app/instructors/page.tsx.ejs +696 -0
  59. package/hedhog/frontend/app/training/page.tsx.ejs +2339 -0
  60. package/hedhog/query/add_route_role.sql +15 -0
  61. package/hedhog/table/enterprise_user.yaml +1 -1
  62. package/package.json +6 -6
  63. package/src/enterprise/dto/enterprise-profile.dto.ts +17 -0
  64. package/src/enterprise/enterprise.controller.ts +9 -1
  65. package/src/enterprise/enterprise.service.ts +147 -4
  66. package/src/instructor/instructor.controller.ts +36 -9
  67. package/src/instructor/instructor.service.ts +140 -10
@@ -1,1216 +1,1216 @@
1
- 'use client';
2
-
3
- // ── Imports ───────────────────────────────────────────────────────────────────
4
-
5
- import { zodResolver } from '@hookform/resolvers/zod';
6
- import {
7
- AlertTriangle,
8
- BookOpen,
9
- CheckCircle2,
10
- CircleDot,
11
- Clock,
12
- Layers,
13
- Loader2,
14
- Plus,
15
- Save,
16
- Undo2,
17
- Video,
18
- } from 'lucide-react';
19
- import { useTranslations } from 'next-intl';
20
- import { useRouter } from 'next/navigation';
21
- import { type ChangeEvent, useEffect, useMemo, useState } from 'react';
22
- import { useForm } from 'react-hook-form';
23
- import { toast } from 'sonner';
24
- import { z } from 'zod';
25
-
26
- import { CreateLmsPersonSheet } from '@/app/(app)/(libraries)/lms/_components/create-lms-person-sheet';
27
- import { createDefaultTemplate } from '@/app/(app)/(libraries)/lms/_lib/editor/types';
28
- import { RichTextEditor } from '@/components/rich-text-editor';
29
- import { Badge } from '@/components/ui/badge';
30
- import { Button } from '@/components/ui/button';
31
- import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
32
- import {
33
- Dialog,
34
- DialogContent,
35
- DialogDescription,
36
- DialogFooter,
37
- DialogHeader,
38
- DialogTitle,
39
- } from '@/components/ui/dialog';
40
- import {
41
- Form,
42
- FormControl,
43
- FormField,
44
- FormItem,
45
- FormLabel,
46
- FormMessage,
47
- } from '@/components/ui/form';
48
- import { Input } from '@/components/ui/input';
49
- import { ScrollArea } from '@/components/ui/scroll-area';
50
- import { Separator } from '@/components/ui/separator';
51
- import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
52
- import { useApp, useQuery } from '@hed-hog/next-app-provider';
53
- import { useMutation, useQueryClient } from '@tanstack/react-query';
54
-
55
- import type {
56
- CourseEditFormValues,
57
- PickerOption,
58
- } from '../../_components/course-edit-types';
59
- import { CourseCertificateCard } from '../../_components/CourseCertificateCard';
60
- import { CourseClassificationCard } from '../../_components/CourseClassificationCard';
61
- import { CourseContentCard } from '../../_components/CourseContentCard';
62
- import { CourseDangerZoneCard } from '../../_components/CourseDangerZoneCard';
63
- import { CourseFlagsCard } from '../../_components/CourseFlagsCard';
64
- import { CourseMediaCard } from '../../_components/CourseMediaCard';
65
- import { CourseRelationsCard } from '../../_components/CourseRelationsCard';
66
- import { useCreateSessionMutation } from '../_data/use-course-structure-mutations';
67
- import { courseStructureQueryKey } from '../_data/use-course-structure-query';
68
- import { useStructureStore } from './store';
69
-
70
- // ── API types (local to this component) ──────────────────────────────────────
71
-
72
- type ApiCourseDetail = {
73
- id: number;
74
- name: string;
75
- slug: string;
76
- title: string;
77
- description: string;
78
- primaryColor?: string | null;
79
- secondaryColor?: string | null;
80
- level: 'beginner' | 'intermediate' | 'advanced';
81
- status: 'draft' | 'published' | 'archived';
82
- offeringType: 'scheduled' | 'on_demand' | 'blended';
83
- categories: string[];
84
- isFeatured: boolean;
85
- hasCertificate: boolean;
86
- isListed: boolean;
87
- enrollmentCount: number;
88
- requirements: string;
89
- objectives: string;
90
- targetAudience: string;
91
- lessonCount: number;
92
- sessionCount: number;
93
- averageCompletion: number;
94
- certificatesIssued: number;
95
- instructorIds?: number[];
96
- instructors?: Array<{ id: number; name: string; avatarId: number | null }>;
97
- logoFileId?: number | null;
98
- logoFilename?: string | null;
99
- logoImageType?: {
100
- suggestedWidth: number | null;
101
- suggestedHeight: number | null;
102
- allowedExtensions: string | null;
103
- aspectRatio: string | null;
104
- } | null;
105
- bannerFileId?: number | null;
106
- bannerFilename?: string | null;
107
- bannerImageType?: {
108
- suggestedWidth: number | null;
109
- suggestedHeight: number | null;
110
- allowedExtensions: string | null;
111
- aspectRatio: string | null;
112
- } | null;
113
- certificateModel?: string | null;
114
- };
115
-
116
- type ApiCategory = { id: number; slug: string; name: string };
117
- type ApiCategoryList = {
118
- data: ApiCategory[];
119
- total: number;
120
- page: number;
121
- pageSize: number;
122
- };
123
- type ApiInstructor = {
124
- id: number;
125
- personId: number;
126
- name: string;
127
- avatarId?: number | null;
128
- qualificationSlugs: string[];
129
- };
130
- type ApiInstructorList = {
131
- data: ApiInstructor[];
132
- total: number;
133
- page: number;
134
- pageSize: number;
135
- };
136
- type ApiCertificateTemplate = {
137
- id: number;
138
- name: string;
139
- slug?: string | null;
140
- description?: string | null;
141
- status?: 'draft' | 'active' | 'inactive';
142
- };
143
- type ApiCertificateTemplateList = {
144
- data: ApiCertificateTemplate[];
145
- total: number;
146
- page: number;
147
- pageSize: number;
148
- lastPage?: number;
149
- };
150
- type Locale = { id?: number; code: string; name: string };
151
-
152
- // ── Helpers ───────────────────────────────────────────────────────────────────
153
-
154
- function normalizeEnumValue(value?: string | null) {
155
- return String(value ?? '')
156
- .trim()
157
- .normalize('NFD')
158
- .replace(/[\u0300-\u036f]/g, '')
159
- .toLowerCase();
160
- }
161
-
162
- function toPtLevel(
163
- level: ApiCourseDetail['level']
164
- ): CourseEditFormValues['nivel'] {
165
- const n = normalizeEnumValue(level);
166
- if (n === 'beginner' || n === 'iniciante') return 'iniciante';
167
- if (n === 'intermediate' || n === 'intermediario') return 'intermediario';
168
- return 'avancado';
169
- }
170
-
171
- function toApiLevel(level: CourseEditFormValues['nivel']) {
172
- if (level === 'iniciante') return 'beginner';
173
- if (level === 'intermediario') return 'intermediate';
174
- return 'advanced';
175
- }
176
-
177
- function toPtStatus(
178
- status: ApiCourseDetail['status']
179
- ): CourseEditFormValues['status'] {
180
- const n = normalizeEnumValue(status);
181
- if (n === 'published' || n === 'active' || n === 'ativo') return 'ativo';
182
- if (n === 'archived' || n === 'arquivado') return 'arquivado';
183
- return 'rascunho';
184
- }
185
-
186
- function toApiStatus(status: CourseEditFormValues['status']) {
187
- if (status === 'ativo') return 'published';
188
- if (status === 'arquivado') return 'archived';
189
- return 'draft';
190
- }
191
-
192
- function toPtOfferingType(
193
- value: ApiCourseDetail['offeringType']
194
- ): CourseEditFormValues['tipoOferta'] {
195
- const n = normalizeEnumValue(value);
196
- if (n === 'scheduled' || n === 'agendado') return 'agendado';
197
- if (n === 'blended' || n === 'hibrido') return 'hibrido';
198
- return 'sob_demanda';
199
- }
200
-
201
- function toApiOfferingType(value: CourseEditFormValues['tipoOferta']) {
202
- if (value === 'agendado') return 'scheduled';
203
- if (value === 'hibrido') return 'blended';
204
- return 'on_demand';
205
- }
206
-
207
- function getContrastColor(hex: string) {
208
- const cleaned = hex.replace('#', '');
209
- if (cleaned.length !== 6) return '#FFFFFF';
210
- const r = parseInt(cleaned.slice(0, 2), 16);
211
- const g = parseInt(cleaned.slice(2, 4), 16);
212
- const b = parseInt(cleaned.slice(4, 6), 16);
213
- const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
214
- return luminance > 0.6 ? '#111827' : '#FFFFFF';
215
- }
216
-
217
- function slugify(value: string) {
218
- return value
219
- .trim()
220
- .toLowerCase()
221
- .normalize('NFD')
222
- .replace(/[\u0300-\u036f]/g, '')
223
- .replace(/[^a-z0-9]+/g, '-')
224
- .replace(/^-+|-+$/g, '');
225
- }
226
-
227
- function getInstructorAvatarUrl(avatarId?: number | null) {
228
- return typeof avatarId === 'number' && avatarId > 0
229
- ? `${process.env.NEXT_PUBLIC_API_BASE_URL}/person/avatar/${avatarId}`
230
- : null;
231
- }
232
-
233
- // ── Schema ────────────────────────────────────────────────────────────────────
234
-
235
- function buildSchema(t: (key: string) => string) {
236
- return z.object({
237
- slug: z
238
- .string()
239
- .trim()
240
- .min(2, t('validation.codeMin'))
241
- .max(32, t('validation.codeMax'))
242
- .regex(
243
- /^[a-z0-9]+(?:-[a-z0-9]+)*$/,
244
- 'Código: apenas letras minúsculas, números e hífens'
245
- ),
246
- nomeInterno: z.string().trim().min(3, t('validation.internalNameMin')),
247
- tituloComercial: z.string().trim().min(3, t('validation.titleMin')),
248
- descricaoPublica: z.string().trim(),
249
- objetivos: z.string().optional(),
250
- publicoAlvo: z.string().optional(),
251
- nivel: z.enum(['iniciante', 'intermediario', 'avancado']),
252
- status: z.enum(['ativo', 'rascunho', 'arquivado']),
253
- tipoOferta: z.enum(['agendado', 'sob_demanda', 'hibrido']),
254
- categorias: z.array(z.string()).optional().default([]),
255
- primaryColor: z
256
- .string()
257
- .regex(/^#([0-9A-Fa-f]{6})$/, t('validation.colorInvalid')),
258
- secondaryColor: z
259
- .string()
260
- .regex(/^#([0-9A-Fa-f]{6})$/, t('validation.colorInvalid')),
261
- instrutores: z.array(z.string()).optional(),
262
- preRequisitos: z.string().optional(),
263
- modeloCertificado: z.string().optional(),
264
- certificado: z.boolean().default(false),
265
- destaque: z.boolean().default(false),
266
- listado: z.boolean().default(false),
267
- });
268
- }
269
-
270
- // ── Component ─────────────────────────────────────────────────────────────────
271
-
272
- export function EditorCourse() {
273
- const courseId = useStructureStore((s) => s.courseId);
274
- const course = useStructureStore((s) => s.course);
275
- const sessions = useStructureStore((s) => s.sessions);
276
- const lessons = useStructureStore((s) => s.lessons);
277
- const updateCourseInStore = useStructureStore((s) => s.updateCourse);
278
-
279
- const { request, currentLocaleCode, locales } = useApp();
280
- const t = useTranslations('lms.CursoEditPage');
281
- const router = useRouter();
282
- const queryClient = useQueryClient();
283
- const createSessionMutation = useCreateSessionMutation();
284
-
285
- // ── UI state ────────────────────────────────────────────────────────────────
286
- const [activeTab, setActiveTab] = useState('estrutura');
287
- const [instructorSheetOpen, setInstructorSheetOpen] = useState(false);
288
- const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
289
- const [deleting, setDeleting] = useState(false);
290
- const [logoPreview, setLogoPreview] = useState<string | null>(null);
291
- const [bannerPreview, setBannerPreview] = useState<string | null>(null);
292
- const [uploadingLogo, setUploadingLogo] = useState(false);
293
- const [uploadingBanner, setUploadingBanner] = useState(false);
294
- const [createdCategoryOptions, setCreatedCategoryOptions] = useState<
295
- PickerOption[]
296
- >([]);
297
- const [createdTemplateOptions, setCreatedTemplateOptions] = useState<
298
- PickerOption[]
299
- >([]);
300
- const [persistedCertificateModel, setPersistedCertificateModel] =
301
- useState('');
302
-
303
- // ── Queries ─────────────────────────────────────────────────────────────────
304
- const { data: apiCourse, refetch: refetchCourse } = useQuery<ApiCourseDetail>(
305
- {
306
- queryKey: ['lms-course-detail', courseId],
307
- enabled: Boolean(courseId),
308
- queryFn: async () => {
309
- const response = await request<ApiCourseDetail>({
310
- url: `/lms/courses/${courseId}`,
311
- method: 'GET',
312
- });
313
- return response.data;
314
- },
315
- }
316
- );
317
-
318
- const { data: categoryListData, refetch: refetchCategoryOptions } =
319
- useQuery<ApiCategoryList>({
320
- queryKey: ['lms-edit-course-categories'],
321
- queryFn: async () => {
322
- const response = await request<ApiCategoryList | ApiCategory[]>({
323
- url: '/category',
324
- method: 'GET',
325
- params: { page: 1, pageSize: 500, status: 'all' },
326
- });
327
- const payload = response.data;
328
- if (Array.isArray(payload))
329
- return {
330
- data: payload,
331
- total: payload.length,
332
- page: 1,
333
- pageSize: payload.length,
334
- };
335
- return payload;
336
- },
337
- initialData: { data: [], total: 0, page: 1, pageSize: 500 },
338
- });
339
-
340
- const { data: instructorListData, refetch: refetchInstructorOptions } =
341
- useQuery<ApiInstructorList>({
342
- queryKey: ['lms-course-edit-instructors'],
343
- queryFn: async () => {
344
- const response = await request<ApiInstructorList | ApiInstructor[]>({
345
- url: '/lms/instructors',
346
- method: 'GET',
347
- params: {
348
- page: 1,
349
- pageSize: 500,
350
- qualificationSlugs: ['course-lessons'],
351
- },
352
- });
353
- const payload = response.data;
354
- if (Array.isArray(payload))
355
- return {
356
- data: payload,
357
- total: payload.length,
358
- page: 1,
359
- pageSize: payload.length,
360
- };
361
- return payload;
362
- },
363
- initialData: { data: [], total: 0, page: 1, pageSize: 500 },
364
- });
365
-
366
- const {
367
- data: certificateTemplateData,
368
- refetch: refetchCertificateTemplates,
369
- } = useQuery<ApiCertificateTemplateList>({
370
- queryKey: ['lms-course-certificate-templates'],
371
- queryFn: async () => {
372
- const response = await request<
373
- ApiCertificateTemplateList | ApiCertificateTemplate[]
374
- >({
375
- url: '/lms/certificates/templates',
376
- method: 'GET',
377
- params: { page: 1, pageSize: 100 },
378
- });
379
- const payload = response.data;
380
- if (Array.isArray(payload))
381
- return {
382
- data: payload,
383
- total: payload.length,
384
- page: 1,
385
- pageSize: payload.length,
386
- lastPage: 1,
387
- };
388
- return payload;
389
- },
390
- initialData: { data: [], total: 0, page: 1, pageSize: 100, lastPage: 1 },
391
- });
392
-
393
- // ── Save mutation ───────────────────────────────────────────────────────────
394
- const { mutate: saveCourse, isPending: saving } = useMutation({
395
- mutationFn: async (data: CourseEditFormValues) => {
396
- await request({
397
- url: `/lms/courses/${courseId}`,
398
- method: 'PATCH',
399
- data: {
400
- name: data.nomeInterno.trim(),
401
- slug: data.slug.trim().toLowerCase(),
402
- title: data.tituloComercial.trim(),
403
- description: data.descricaoPublica.trim(),
404
- requirements: data.preRequisitos?.trim() || undefined,
405
- objectives: data.objetivos?.trim() || undefined,
406
- targetAudience: data.publicoAlvo?.trim() || undefined,
407
- level: toApiLevel(data.nivel),
408
- status: toApiStatus(data.status),
409
- offeringType: toApiOfferingType(data.tipoOferta),
410
- categorySlugs: data.categorias,
411
- primaryColor: data.primaryColor,
412
- primaryContrastColor: getContrastColor(data.primaryColor),
413
- secondaryColor: data.secondaryColor,
414
- secondaryContrastColor: getContrastColor(data.secondaryColor),
415
- hasCertificate: data.certificado,
416
- isFeatured: data.destaque,
417
- isListed: data.listado,
418
- certificateModel: data.modeloCertificado || undefined,
419
- instructorIds: (data.instrutores ?? []).map(Number),
420
- },
421
- });
422
- return data;
423
- },
424
- onSuccess: (data) => {
425
- setPersistedCertificateModel(data.modeloCertificado || '');
426
- updateCourseInStore({
427
- title: data.tituloComercial,
428
- slug: data.slug,
429
- name: data.nomeInterno,
430
- description: data.descricaoPublica,
431
- published: data.status === 'ativo',
432
- });
433
- void queryClient.invalidateQueries({
434
- queryKey: courseStructureQueryKey(courseId),
435
- });
436
- form.reset(data);
437
- toast.success(t('toasts.courseUpdated'));
438
- },
439
- onError: () => {
440
- toast.error('Erro ao salvar curso');
441
- },
442
- });
443
-
444
- // ── Form ─────────────────────────────────────────────────────────────────────
445
- const schema = useMemo(() => buildSchema(t), [t]);
446
-
447
- const form = useForm<CourseEditFormValues>({
448
- resolver: zodResolver(schema),
449
- defaultValues: {
450
- slug: '',
451
- nomeInterno: '',
452
- tituloComercial: '',
453
- descricaoPublica: '',
454
- objetivos: '',
455
- publicoAlvo: '',
456
- nivel: 'iniciante',
457
- status: 'rascunho',
458
- tipoOferta: 'sob_demanda',
459
- categorias: [],
460
- primaryColor: '#1D4ED8',
461
- secondaryColor: '#111827',
462
- instrutores: [],
463
- preRequisitos: '',
464
- modeloCertificado: '',
465
- certificado: false,
466
- destaque: false,
467
- listado: false,
468
- },
469
- });
470
-
471
- const { isDirty } = form.formState;
472
-
473
- const NIVEIS = useMemo(
474
- () => [
475
- { value: 'iniciante', label: t('levels.beginner') },
476
- { value: 'intermediario', label: t('levels.intermediate') },
477
- { value: 'avancado', label: t('levels.advanced') },
478
- ],
479
- [t]
480
- );
481
- const STATUS_OPTIONS = useMemo(
482
- () => [
483
- { value: 'ativo', label: t('status.active') },
484
- { value: 'rascunho', label: t('status.draft') },
485
- { value: 'arquivado', label: t('status.archived') },
486
- ],
487
- [t]
488
- );
489
- const OFFERING_TYPE_OPTIONS = useMemo(
490
- () =>
491
- [
492
- {
493
- value: 'sob_demanda',
494
- label: t('offeringType.options.onDemand.label'),
495
- description: t('offeringType.options.onDemand.description'),
496
- },
497
- {
498
- value: 'agendado',
499
- label: t('offeringType.options.scheduled.label'),
500
- description: t('offeringType.options.scheduled.description'),
501
- },
502
- {
503
- value: 'hibrido',
504
- label: t('offeringType.options.blended.label'),
505
- description: t('offeringType.options.blended.description'),
506
- },
507
- ] as const,
508
- [t]
509
- );
510
-
511
- // ── Derived options ──────────────────────────────────────────────────────────
512
- const categoryOptions = useMemo(() => {
513
- const serverOptions = (categoryListData?.data ?? [])
514
- .filter((item) => !!item.slug)
515
- .map((item) => ({ value: item.slug, label: item.name || item.slug }));
516
- return [...serverOptions, ...createdCategoryOptions].filter(
517
- (item, i, arr) => arr.findIndex((c) => c.value === item.value) === i
518
- );
519
- }, [categoryListData, createdCategoryOptions]);
520
-
521
- const instructorOptions = useMemo(() => {
522
- const fromCourse = (apiCourse?.instructors ?? []).map((item) => ({
523
- value: String(item.id),
524
- label: item.name,
525
- avatarUrl: getInstructorAvatarUrl(item.avatarId),
526
- meta: `ID ${item.id}`,
527
- }));
528
- const fromDirectory = (instructorListData?.data ?? []).map((item) => ({
529
- value: String(item.id),
530
- label: item.name,
531
- avatarUrl: getInstructorAvatarUrl(item.avatarId),
532
- meta: item.qualificationSlugs?.join(' • ') || `ID ${item.id}`,
533
- }));
534
- return [...fromCourse, ...fromDirectory].filter(
535
- (item, i, arr) => arr.findIndex((c) => c.value === item.value) === i
536
- );
537
- }, [apiCourse?.instructors, instructorListData]);
538
-
539
- const certificateOptions = useMemo(() => {
540
- const serverOptions = (certificateTemplateData?.data ?? []).map((item) => ({
541
- value: item.slug || String(item.id),
542
- label: item.name,
543
- description: item.description || null,
544
- meta: item.status ? `Status: ${item.status}` : null,
545
- }));
546
- return [...serverOptions, ...createdTemplateOptions].filter(
547
- (item, i, arr) => arr.findIndex((c) => c.value === item.value) === i
548
- );
549
- }, [certificateTemplateData, createdTemplateOptions]);
550
-
551
- // ── Structural stats ─────────────────────────────────────────────────────────
552
- const totalMinutes = lessons.reduce((sum, l) => sum + l.duration, 0);
553
- const hours = Math.floor(totalMinutes / 60);
554
- const minutes = totalMinutes % 60;
555
- const publishedCount = lessons.filter(
556
- (l) => l.visibility === 'publico' || l.status === 'publicada'
557
- ).length;
558
-
559
- // ── Effects ──────────────────────────────────────────────────────────────────
560
- useEffect(() => {
561
- if (!apiCourse) return;
562
- const nextCertificateModel =
563
- apiCourse.certificateModel ?? persistedCertificateModel ?? '';
564
- form.reset({
565
- slug: apiCourse.slug,
566
- nomeInterno: apiCourse.name,
567
- tituloComercial: apiCourse.title,
568
- descricaoPublica: apiCourse.description ?? '',
569
- objetivos: apiCourse.objectives ?? '',
570
- publicoAlvo: apiCourse.targetAudience ?? '',
571
- nivel: toPtLevel(apiCourse.level),
572
- status: toPtStatus(apiCourse.status),
573
- tipoOferta: toPtOfferingType(apiCourse.offeringType),
574
- categorias: apiCourse.categories ?? [],
575
- primaryColor: apiCourse.primaryColor || '#1D4ED8',
576
- secondaryColor: apiCourse.secondaryColor || '#111827',
577
- instrutores: (apiCourse.instructorIds ?? []).map(String),
578
- preRequisitos: apiCourse.requirements ?? '',
579
- modeloCertificado: nextCertificateModel,
580
- certificado: apiCourse.hasCertificate ?? false,
581
- destaque: apiCourse.isFeatured ?? false,
582
- listado: apiCourse.isListed ?? false,
583
- });
584
- setPersistedCertificateModel(nextCertificateModel);
585
- }, [apiCourse]); // eslint-disable-line react-hooks/exhaustive-deps
586
-
587
- useEffect(() => {
588
- const previews = [
589
- { fileId: apiCourse?.logoFileId, setter: setLogoPreview },
590
- { fileId: apiCourse?.bannerFileId, setter: setBannerPreview },
591
- ];
592
- previews.forEach(({ fileId, setter }) => {
593
- if (!fileId) return;
594
- void (async () => {
595
- try {
596
- const response = await request<{ url?: string }>({
597
- url: `/file/open/${fileId}`,
598
- method: 'PUT',
599
- });
600
- if (response?.data?.url) setter(response.data.url);
601
- } catch {
602
- // ignore preview failures
603
- }
604
- })();
605
- });
606
- }, [apiCourse?.bannerFileId, apiCourse?.logoFileId, request]);
607
-
608
- // ── File handlers ────────────────────────────────────────────────────────────
609
- async function openUploadedFile(fileId?: number | null) {
610
- if (!fileId) return;
611
- try {
612
- const response = await request<{ url?: string }>({
613
- url: `/file/open/${fileId}`,
614
- method: 'PUT',
615
- });
616
- const url = response?.data?.url;
617
- if (!url) {
618
- toast.error(t('toasts.openFileError'));
619
- return;
620
- }
621
- window.open(url, '_blank', 'noopener,noreferrer');
622
- } catch {
623
- toast.error(t('toasts.openFileError'));
624
- }
625
- }
626
-
627
- async function handleFileSelect(
628
- event: ChangeEvent<HTMLInputElement>,
629
- setter: (v: string | null) => void,
630
- type: 'logo' | 'banner'
631
- ) {
632
- const file = event.target.files?.[0];
633
- if (!file) return;
634
- if (!file.type.startsWith('image/')) {
635
- toast.error(t('toasts.onlyImages'));
636
- return;
637
- }
638
- if (file.size > 5 * 1024 * 1024) {
639
- toast.error(t('toasts.maxSize'));
640
- return;
641
- }
642
- const objectUrl = URL.createObjectURL(file);
643
- setter(objectUrl);
644
- type === 'logo' ? setUploadingLogo(true) : setUploadingBanner(true);
645
- try {
646
- const formData = new FormData();
647
- formData.append('file', file);
648
- formData.append('destination', 'lms/courses');
649
- const uploadResponse = await request<{ id: number; filename: string }>({
650
- url: '/file',
651
- method: 'POST',
652
- data: formData,
653
- headers: { 'Content-Type': 'multipart/form-data' },
654
- });
655
- const fileId = uploadResponse?.data?.id;
656
- if (!fileId) throw new Error('invalid file id');
657
- await request({
658
- url: `/lms/courses/${courseId}`,
659
- method: 'PATCH',
660
- data:
661
- type === 'logo' ? { logoFileId: fileId } : { bannerFileId: fileId },
662
- });
663
- await refetchCourse();
664
- toast.success(t('toasts.fileSelected', { name: file.name }));
665
- } catch {
666
- toast.error(t('toasts.fileUploadError'));
667
- } finally {
668
- type === 'logo' ? setUploadingLogo(false) : setUploadingBanner(false);
669
- }
670
- }
671
-
672
- async function handleRemoveFile(type: 'logo' | 'banner') {
673
- const fileId =
674
- type === 'logo' ? apiCourse?.logoFileId : apiCourse?.bannerFileId;
675
- try {
676
- await request({
677
- url: `/lms/courses/${courseId}`,
678
- method: 'PATCH',
679
- data: type === 'logo' ? { logoFileId: null } : { bannerFileId: null },
680
- });
681
- if (fileId) {
682
- await request({ url: `/file/${fileId}`, method: 'DELETE' });
683
- }
684
- if (type === 'logo') setLogoPreview(null);
685
- else setBannerPreview(null);
686
- await refetchCourse();
687
- } catch {
688
- toast.error(t('toasts.fileUploadError'));
689
- }
690
- }
691
-
692
- async function handleCreateCategory(values: Record<string, string>) {
693
- const name = String(values.name ?? '').trim();
694
- const slug = slugify(values.slug || values.name || '');
695
- if (!name || !slug) {
696
- toast.error('Informe nome e slug para criar a categoria.');
697
- return null;
698
- }
699
- const localeCode =
700
- currentLocaleCode ||
701
- (locales?.[0] as Locale | undefined)?.code ||
702
- 'pt-BR';
703
- try {
704
- await request({
705
- url: '/category',
706
- method: 'POST',
707
- data: {
708
- locale: { [localeCode]: { name } },
709
- slug,
710
- category_id: null,
711
- color: '#000000',
712
- icon: '',
713
- status: 'active',
714
- },
715
- });
716
- const option = { value: slug, label: name };
717
- setCreatedCategoryOptions((current) => [option, ...current]);
718
- await refetchCategoryOptions();
719
- toast.success('Categoria criada com sucesso.');
720
- return option;
721
- } catch {
722
- toast.error('Não foi possível criar a categoria.');
723
- return null;
724
- }
725
- }
726
-
727
- async function handleCreateCertificateTemplate(
728
- values: Record<string, string>
729
- ) {
730
- const name = String(values.name ?? '').trim();
731
- const description = String(values.description ?? '').trim();
732
- if (!name) {
733
- toast.error('Informe o nome do modelo.');
734
- return null;
735
- }
736
- const initialTemplate = createDefaultTemplate();
737
- initialTemplate.name = name;
738
- try {
739
- const response = await request<ApiCertificateTemplate>({
740
- url: '/lms/certificates/templates',
741
- method: 'POST',
742
- data: {
743
- name,
744
- description: description || undefined,
745
- status: 'draft',
746
- templateContent: JSON.stringify(initialTemplate),
747
- },
748
- });
749
- const created = response.data;
750
- const option = {
751
- value: created.slug || String(created.id),
752
- label: created.name,
753
- description: created.description || null,
754
- meta: created.status ? `Status: ${created.status}` : null,
755
- };
756
- setCreatedTemplateOptions((current) => [option, ...current]);
757
- setPersistedCertificateModel(option.value);
758
- await refetchCertificateTemplates();
759
- toast.success('Modelo de certificado criado com sucesso.');
760
- return option;
761
- } catch {
762
- toast.error('Não foi possível criar o modelo de certificado.');
763
- return null;
764
- }
765
- }
766
-
767
- const handleInstructorCreated = async (instructor: {
768
- id: number;
769
- personId: number;
770
- name: string;
771
- avatarId?: number | null;
772
- email?: string | null;
773
- phone?: string | null;
774
- qualificationSlugs: string[];
775
- }) => {
776
- const createdId = String(instructor.id);
777
- const current = form.getValues('instrutores') ?? [];
778
- if (!current.includes(createdId)) {
779
- form.setValue('instrutores', [...current, createdId], {
780
- shouldDirty: true,
781
- shouldTouch: true,
782
- shouldValidate: true,
783
- });
784
- }
785
- await refetchInstructorOptions();
786
- };
787
-
788
- async function handleDelete() {
789
- setDeleting(true);
790
- try {
791
- await request({ url: `/lms/courses/${courseId}`, method: 'DELETE' });
792
- setDeleteDialogOpen(false);
793
- toast.success(t('toasts.courseDeleted'));
794
- router.push('/lms/courses');
795
- } finally {
796
- setDeleting(false);
797
- }
798
- }
799
-
800
- function onSubmit(data: CourseEditFormValues) {
801
- saveCourse(data);
802
- }
803
-
804
- function onFormError(errors: Record<string, unknown>) {
805
- const first = Object.values(errors)[0] as { message?: string } | undefined;
806
- toast.error(first?.message ?? 'Verifique os campos obrigatórios');
807
- }
808
-
809
- // ── Render ────────────────────────────────────────────────────────────────────
810
- return (
811
- <Form {...form}>
812
- <form
813
- onSubmit={form.handleSubmit(onSubmit, onFormError)}
814
- className="flex flex-col h-full min-h-0"
815
- >
816
- {/* ── Header ───────────────────────────────────────────────────────── */}
817
- <div className="flex items-center gap-3 px-4 py-3 border-b bg-muted/30 shrink-0">
818
- <div className="flex size-9 items-center justify-center rounded-lg bg-primary/10 shrink-0">
819
- <BookOpen className="size-4 text-primary" />
820
- </div>
821
- <div className="flex-1 min-w-0">
822
- <div className="flex items-center gap-1.5">
823
- <span className="text-sm font-semibold truncate">Curso</span>
824
- {isDirty && (
825
- <CircleDot className="size-3 text-amber-500 shrink-0" />
826
- )}
827
- </div>
828
- <p className="text-[0.65rem] text-muted-foreground truncate">
829
- {course.slug}
830
- </p>
831
- </div>
832
- <Badge
833
- variant={course.published ? 'default' : 'secondary'}
834
- className="shrink-0 text-xs"
835
- >
836
- {course.published ? 'Publicado' : 'Rascunho'}
837
- </Badge>
838
- </div>
839
-
840
- {/* ── Tabs ─────────────────────────────────────────────────────────── */}
841
- <Tabs
842
- value={activeTab}
843
- onValueChange={setActiveTab}
844
- className="flex flex-col flex-1 min-h-0"
845
- >
846
- <TabsList>
847
- {(['estrutura', 'sobre', 'midia', 'extra'] as const).map((tab) => (
848
- <TabsTrigger key={tab} value={tab}>
849
- {tab === 'estrutura'
850
- ? 'Estrutura'
851
- : tab === 'sobre'
852
- ? 'Sobre'
853
- : tab === 'midia'
854
- ? 'Mídia'
855
- : 'Extra'}
856
- </TabsTrigger>
857
- ))}
858
- </TabsList>
859
-
860
- <div className="flex-1 min-h-0 overflow-hidden">
861
- <ScrollArea className="h-full">
862
- <div className="p-3 flex flex-col gap-3">
863
- {/* ── Tab: Estrutura ──────────────────────────────────────── */}
864
- <TabsContent
865
- value="estrutura"
866
- className="mt-0 flex flex-col gap-3"
867
- >
868
- {/* Stat chips */}
869
- <Card className="bg-muted/20 py-2 gap-2">
870
- <CardHeader className="px-3 pt-2 pb-0">
871
- <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
872
- Resumo do conteúdo
873
- </CardTitle>
874
- </CardHeader>
875
- <CardContent className="px-3 pb-2">
876
- <div className="grid grid-cols-2 gap-2">
877
- <StatChip
878
- icon={<Layers className="size-3 text-blue-500" />}
879
- label="Sessões"
880
- value={sessions.length}
881
- />
882
- <StatChip
883
- icon={<Video className="size-3 text-violet-500" />}
884
- label="Aulas"
885
- value={lessons.length}
886
- />
887
- <StatChip
888
- icon={<Clock className="size-3 text-amber-500" />}
889
- label="Duração"
890
- value={
891
- hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`
892
- }
893
- />
894
- <StatChip
895
- icon={
896
- <CheckCircle2 className="size-3 text-emerald-500" />
897
- }
898
- label="Publicadas"
899
- value={publishedCount}
900
- />
901
- </div>
902
- </CardContent>
903
- </Card>
904
-
905
- {/* Dados principais */}
906
- <Card className="bg-muted/20 py-2 gap-2">
907
- <CardHeader className="px-3 pt-2 pb-0">
908
- <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
909
- Dados principais
910
- </CardTitle>
911
- </CardHeader>
912
- <CardContent className="px-3 pb-2 flex flex-col gap-3">
913
- <FormField
914
- control={form.control}
915
- name="tituloComercial"
916
- render={({ field }) => (
917
- <FormItem>
918
- <FormLabel className="text-xs">Título</FormLabel>
919
- <FormControl>
920
- <Input {...field} className="h-8 text-sm" />
921
- </FormControl>
922
- <FormMessage className="text-xs" />
923
- </FormItem>
924
- )}
925
- />
926
- <div className="grid grid-cols-2 gap-2">
927
- <FormField
928
- control={form.control}
929
- name="nomeInterno"
930
- render={({ field }) => (
931
- <FormItem>
932
- <FormLabel className="text-xs">
933
- Nome Interno
934
- </FormLabel>
935
- <FormControl>
936
- <Input {...field} className="h-8 text-xs" />
937
- </FormControl>
938
- <FormMessage className="text-xs" />
939
- </FormItem>
940
- )}
941
- />
942
- <FormField
943
- control={form.control}
944
- name="slug"
945
- render={({ field }) => (
946
- <FormItem>
947
- <FormLabel className="text-xs">Slug</FormLabel>
948
- <FormControl>
949
- <Input
950
- {...field}
951
- className="h-8 text-xs font-mono"
952
- onChange={(e) =>
953
- field.onChange(e.target.value.toLowerCase())
954
- }
955
- />
956
- </FormControl>
957
- <FormMessage className="text-xs" />
958
- </FormItem>
959
- )}
960
- />
961
- </div>
962
- </CardContent>
963
- </Card>
964
-
965
- {/* Descrição */}
966
- <Card className="bg-muted/20 py-2 gap-2">
967
- <CardHeader className="px-3 pt-2 pb-0">
968
- <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
969
- Descrição
970
- </CardTitle>
971
- </CardHeader>
972
- <CardContent className="px-3 pb-2">
973
- <FormField
974
- control={form.control}
975
- name="descricaoPublica"
976
- render={({ field }) => (
977
- <FormItem>
978
- <FormControl>
979
- <RichTextEditor
980
- value={field.value}
981
- onChange={field.onChange}
982
- />
983
- </FormControl>
984
- <FormMessage className="text-xs" />
985
- </FormItem>
986
- )}
987
- />
988
- </CardContent>
989
- </Card>
990
- </TabsContent>
991
-
992
- {/* ── Tab: Sobre ──────────────────────────────────────────── */}
993
- <TabsContent value="sobre" className="mt-0 flex flex-col gap-3">
994
- <CourseClassificationCard
995
- form={form}
996
- t={t}
997
- levels={NIVEIS}
998
- statuses={STATUS_OPTIONS}
999
- offeringTypes={OFFERING_TYPE_OPTIONS}
1000
- />
1001
- <CourseRelationsCard
1002
- form={form}
1003
- t={t}
1004
- categoryOptions={categoryOptions}
1005
- instructorOptions={instructorOptions}
1006
- onCreateCategory={handleCreateCategory}
1007
- onCreateInstructor={() => setInstructorSheetOpen(true)}
1008
- />
1009
- <CourseContentCard form={form} />
1010
- </TabsContent>
1011
-
1012
- {/* ── Tab: Mídia ──────────────────────────────────────────── */}
1013
- <TabsContent value="midia" className="mt-0">
1014
- <CourseMediaCard
1015
- logoPreview={logoPreview}
1016
- bannerPreview={bannerPreview}
1017
- uploadingLogo={uploadingLogo}
1018
- uploadingBanner={uploadingBanner}
1019
- onLogoSelect={(e: React.ChangeEvent<HTMLInputElement>) =>
1020
- handleFileSelect(e, setLogoPreview, 'logo')
1021
- }
1022
- onBannerSelect={(e: React.ChangeEvent<HTMLInputElement>) =>
1023
- handleFileSelect(e, setBannerPreview, 'banner')
1024
- }
1025
- logoFile={
1026
- apiCourse?.logoFileId
1027
- ? {
1028
- id: apiCourse.logoFileId,
1029
- name:
1030
- apiCourse.logoFilename ||
1031
- `#${apiCourse.logoFileId}`,
1032
- }
1033
- : undefined
1034
- }
1035
- bannerFile={
1036
- apiCourse?.bannerFileId
1037
- ? {
1038
- id: apiCourse.bannerFileId,
1039
- name:
1040
- apiCourse.bannerFilename ||
1041
- `#${apiCourse.bannerFileId}`,
1042
- }
1043
- : undefined
1044
- }
1045
- onOpenLogoFile={() =>
1046
- openUploadedFile(apiCourse?.logoFileId)
1047
- }
1048
- onOpenBannerFile={() =>
1049
- openUploadedFile(apiCourse?.bannerFileId)
1050
- }
1051
- onRemoveLogoFile={
1052
- apiCourse?.logoFileId
1053
- ? () => handleRemoveFile('logo')
1054
- : undefined
1055
- }
1056
- onRemoveBannerFile={
1057
- apiCourse?.bannerFileId
1058
- ? () => handleRemoveFile('banner')
1059
- : undefined
1060
- }
1061
- logoImageType={apiCourse?.logoImageType}
1062
- bannerImageType={apiCourse?.bannerImageType}
1063
- t={t}
1064
- />
1065
- </TabsContent>
1066
-
1067
- {/* ── Tab: Extra ──────────────────────────────────────────── */}
1068
- <TabsContent value="extra" className="mt-0 flex flex-col gap-3">
1069
- <CourseCertificateCard
1070
- form={form}
1071
- t={t}
1072
- options={certificateOptions}
1073
- onCreateTemplate={handleCreateCertificateTemplate}
1074
- />
1075
- <CourseFlagsCard form={form} t={t} />
1076
- <CourseDangerZoneCard
1077
- t={t}
1078
- onDelete={() => setDeleteDialogOpen(true)}
1079
- />
1080
- </TabsContent>
1081
- </div>
1082
- </ScrollArea>
1083
- </div>
1084
- </Tabs>
1085
-
1086
- {/* ── Footer ───────────────────────────────────────────────────────── */}
1087
- <div className="shrink-0 border-t bg-background">
1088
- <Separator />
1089
- <div className="flex items-center gap-2 px-3 py-2">
1090
- <Button
1091
- type="button"
1092
- variant="ghost"
1093
- size="sm"
1094
- className="h-7 text-xs"
1095
- disabled={!isDirty || saving}
1096
- onClick={() => form.reset()}
1097
- >
1098
- <Undo2 className="size-3 mr-1" />
1099
- Cancelar
1100
- </Button>
1101
- <div className="flex-1" />
1102
- <Button
1103
- type="button"
1104
- variant="outline"
1105
- size="sm"
1106
- className="h-7 text-xs"
1107
- disabled={createSessionMutation.isPending}
1108
- onClick={() => createSessionMutation.mutate()}
1109
- >
1110
- <Plus className="size-3 mr-1" />
1111
- Nova sessão
1112
- </Button>
1113
- <Button
1114
- type="submit"
1115
- size="sm"
1116
- className="h-7 text-xs"
1117
- disabled={saving}
1118
- >
1119
- {saving ? (
1120
- <Loader2 className="size-3 mr-1 animate-spin" />
1121
- ) : (
1122
- <Save className="size-3 mr-1" />
1123
- )}
1124
- Salvar
1125
- </Button>
1126
- </div>
1127
- </div>
1128
- </form>
1129
-
1130
- {/* ── Instructor sheet ─────────────────────────────────────────────── */}
1131
- <CreateLmsPersonSheet
1132
- open={instructorSheetOpen}
1133
- onOpenChange={setInstructorSheetOpen}
1134
- onCreated={handleInstructorCreated}
1135
- title="Cadastrar instrutor"
1136
- description="Cadastre um novo instrutor e adicione-o ao curso."
1137
- submitLabel="Cadastrar instrutor"
1138
- successMessage="Instrutor cadastrado com sucesso."
1139
- errorMessage="Não foi possível cadastrar o instrutor."
1140
- defaultQualificationSlugs={['course-lessons']}
1141
- />
1142
-
1143
- {/* ── Delete dialog ────────────────────────────────────────────────── */}
1144
- <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
1145
- <DialogContent>
1146
- <DialogHeader>
1147
- <DialogTitle className="flex items-center gap-2">
1148
- <AlertTriangle className="size-5 text-destructive" />
1149
- {t('deleteDialog.title')}
1150
- </DialogTitle>
1151
- <DialogDescription asChild>
1152
- <div className="flex flex-col gap-3">
1153
- <p>
1154
- {t('deleteDialog.description')}{' '}
1155
- <strong className="text-foreground">{course.title}</strong>?
1156
- </p>
1157
- {(apiCourse?.enrollmentCount ?? 0) > 0 ? (
1158
- <div className="flex items-center gap-2 rounded-md bg-amber-50 px-3 py-2.5 text-xs font-medium text-amber-700">
1159
- <AlertTriangle className="size-3.5 shrink-0" />
1160
- <span>
1161
- {t('deleteDialog.warning', {
1162
- students: apiCourse?.enrollmentCount ?? 0,
1163
- certificates: apiCourse?.certificatesIssued ?? 0,
1164
- })}
1165
- </span>
1166
- </div>
1167
- ) : null}
1168
- </div>
1169
- </DialogDescription>
1170
- </DialogHeader>
1171
- <DialogFooter className="gap-2">
1172
- <Button
1173
- variant="outline"
1174
- onClick={() => setDeleteDialogOpen(false)}
1175
- >
1176
- {t('deleteDialog.actions.cancel')}
1177
- </Button>
1178
- <Button
1179
- variant="destructive"
1180
- onClick={handleDelete}
1181
- disabled={deleting}
1182
- className="gap-2"
1183
- >
1184
- {deleting ? <Loader2 className="size-4 animate-spin" /> : null}
1185
- {t('deleteDialog.actions.delete')}
1186
- </Button>
1187
- </DialogFooter>
1188
- </DialogContent>
1189
- </Dialog>
1190
- </Form>
1191
- );
1192
- }
1193
-
1194
- // ── Helpers ───────────────────────────────────────────────────────────────────
1195
-
1196
- function StatChip({
1197
- icon,
1198
- label,
1199
- value,
1200
- }: {
1201
- icon: React.ReactNode;
1202
- label: string;
1203
- value: string | number;
1204
- }) {
1205
- return (
1206
- <div className="flex items-center gap-2 rounded-md border bg-background px-2.5 py-2">
1207
- <span className="text-muted-foreground shrink-0">{icon}</span>
1208
- <div className="min-w-0">
1209
- <p className="text-[0.65rem] text-muted-foreground truncate">{label}</p>
1210
- <p className="text-sm font-semibold tabular-nums leading-none">
1211
- {value}
1212
- </p>
1213
- </div>
1214
- </div>
1215
- );
1216
- }
1
+ 'use client';
2
+
3
+ // ── Imports ───────────────────────────────────────────────────────────────────
4
+
5
+ import { zodResolver } from '@hookform/resolvers/zod';
6
+ import {
7
+ AlertTriangle,
8
+ BookOpen,
9
+ CheckCircle2,
10
+ CircleDot,
11
+ Clock,
12
+ Layers,
13
+ Loader2,
14
+ Plus,
15
+ Save,
16
+ Undo2,
17
+ Video,
18
+ } from 'lucide-react';
19
+ import { useTranslations } from 'next-intl';
20
+ import { useRouter } from 'next/navigation';
21
+ import { type ChangeEvent, useEffect, useMemo, useState } from 'react';
22
+ import { useForm } from 'react-hook-form';
23
+ import { toast } from 'sonner';
24
+ import { z } from 'zod';
25
+
26
+ import { CreateLmsPersonSheet } from '@/app/(app)/(libraries)/lms/_components/create-lms-person-sheet';
27
+ import { createDefaultTemplate } from '@/app/(app)/(libraries)/lms/_lib/editor/types';
28
+ import { RichTextEditor } from '@/components/rich-text-editor';
29
+ import { Badge } from '@/components/ui/badge';
30
+ import { Button } from '@/components/ui/button';
31
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
32
+ import {
33
+ Dialog,
34
+ DialogContent,
35
+ DialogDescription,
36
+ DialogFooter,
37
+ DialogHeader,
38
+ DialogTitle,
39
+ } from '@/components/ui/dialog';
40
+ import {
41
+ Form,
42
+ FormControl,
43
+ FormField,
44
+ FormItem,
45
+ FormLabel,
46
+ FormMessage,
47
+ } from '@/components/ui/form';
48
+ import { Input } from '@/components/ui/input';
49
+ import { ScrollArea } from '@/components/ui/scroll-area';
50
+ import { Separator } from '@/components/ui/separator';
51
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
52
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
53
+ import { useMutation, useQueryClient } from '@tanstack/react-query';
54
+
55
+ import type {
56
+ CourseEditFormValues,
57
+ PickerOption,
58
+ } from '../../_components/course-edit-types';
59
+ import { CourseCertificateCard } from '../../_components/CourseCertificateCard';
60
+ import { CourseClassificationCard } from '../../_components/CourseClassificationCard';
61
+ import { CourseContentCard } from '../../_components/CourseContentCard';
62
+ import { CourseDangerZoneCard } from '../../_components/CourseDangerZoneCard';
63
+ import { CourseFlagsCard } from '../../_components/CourseFlagsCard';
64
+ import { CourseMediaCard } from '../../_components/CourseMediaCard';
65
+ import { CourseRelationsCard } from '../../_components/CourseRelationsCard';
66
+ import { useCreateSessionMutation } from '../_data/use-course-structure-mutations';
67
+ import { courseStructureQueryKey } from '../_data/use-course-structure-query';
68
+ import { useStructureStore } from './store';
69
+
70
+ // ── API types (local to this component) ──────────────────────────────────────
71
+
72
+ type ApiCourseDetail = {
73
+ id: number;
74
+ name: string;
75
+ slug: string;
76
+ title: string;
77
+ description: string;
78
+ primaryColor?: string | null;
79
+ secondaryColor?: string | null;
80
+ level: 'beginner' | 'intermediate' | 'advanced';
81
+ status: 'draft' | 'published' | 'archived';
82
+ offeringType: 'scheduled' | 'on_demand' | 'blended';
83
+ categories: string[];
84
+ isFeatured: boolean;
85
+ hasCertificate: boolean;
86
+ isListed: boolean;
87
+ enrollmentCount: number;
88
+ requirements: string;
89
+ objectives: string;
90
+ targetAudience: string;
91
+ lessonCount: number;
92
+ sessionCount: number;
93
+ averageCompletion: number;
94
+ certificatesIssued: number;
95
+ instructorIds?: number[];
96
+ instructors?: Array<{ id: number; name: string; avatarId: number | null }>;
97
+ logoFileId?: number | null;
98
+ logoFilename?: string | null;
99
+ logoImageType?: {
100
+ suggestedWidth: number | null;
101
+ suggestedHeight: number | null;
102
+ allowedExtensions: string | null;
103
+ aspectRatio: string | null;
104
+ } | null;
105
+ bannerFileId?: number | null;
106
+ bannerFilename?: string | null;
107
+ bannerImageType?: {
108
+ suggestedWidth: number | null;
109
+ suggestedHeight: number | null;
110
+ allowedExtensions: string | null;
111
+ aspectRatio: string | null;
112
+ } | null;
113
+ certificateModel?: string | null;
114
+ };
115
+
116
+ type ApiCategory = { id: number; slug: string; name: string };
117
+ type ApiCategoryList = {
118
+ data: ApiCategory[];
119
+ total: number;
120
+ page: number;
121
+ pageSize: number;
122
+ };
123
+ type ApiInstructor = {
124
+ id: number;
125
+ personId: number;
126
+ name: string;
127
+ avatarId?: number | null;
128
+ qualificationSlugs: string[];
129
+ };
130
+ type ApiInstructorList = {
131
+ data: ApiInstructor[];
132
+ total: number;
133
+ page: number;
134
+ pageSize: number;
135
+ };
136
+ type ApiCertificateTemplate = {
137
+ id: number;
138
+ name: string;
139
+ slug?: string | null;
140
+ description?: string | null;
141
+ status?: 'draft' | 'active' | 'inactive';
142
+ };
143
+ type ApiCertificateTemplateList = {
144
+ data: ApiCertificateTemplate[];
145
+ total: number;
146
+ page: number;
147
+ pageSize: number;
148
+ lastPage?: number;
149
+ };
150
+ type Locale = { id?: number; code: string; name: string };
151
+
152
+ // ── Helpers ───────────────────────────────────────────────────────────────────
153
+
154
+ function normalizeEnumValue(value?: string | null) {
155
+ return String(value ?? '')
156
+ .trim()
157
+ .normalize('NFD')
158
+ .replace(/[\u0300-\u036f]/g, '')
159
+ .toLowerCase();
160
+ }
161
+
162
+ function toPtLevel(
163
+ level: ApiCourseDetail['level']
164
+ ): CourseEditFormValues['nivel'] {
165
+ const n = normalizeEnumValue(level);
166
+ if (n === 'beginner' || n === 'iniciante') return 'iniciante';
167
+ if (n === 'intermediate' || n === 'intermediario') return 'intermediario';
168
+ return 'avancado';
169
+ }
170
+
171
+ function toApiLevel(level: CourseEditFormValues['nivel']) {
172
+ if (level === 'iniciante') return 'beginner';
173
+ if (level === 'intermediario') return 'intermediate';
174
+ return 'advanced';
175
+ }
176
+
177
+ function toPtStatus(
178
+ status: ApiCourseDetail['status']
179
+ ): CourseEditFormValues['status'] {
180
+ const n = normalizeEnumValue(status);
181
+ if (n === 'published' || n === 'active' || n === 'ativo') return 'ativo';
182
+ if (n === 'archived' || n === 'arquivado') return 'arquivado';
183
+ return 'rascunho';
184
+ }
185
+
186
+ function toApiStatus(status: CourseEditFormValues['status']) {
187
+ if (status === 'ativo') return 'published';
188
+ if (status === 'arquivado') return 'archived';
189
+ return 'draft';
190
+ }
191
+
192
+ function toPtOfferingType(
193
+ value: ApiCourseDetail['offeringType']
194
+ ): CourseEditFormValues['tipoOferta'] {
195
+ const n = normalizeEnumValue(value);
196
+ if (n === 'scheduled' || n === 'agendado') return 'agendado';
197
+ if (n === 'blended' || n === 'hibrido') return 'hibrido';
198
+ return 'sob_demanda';
199
+ }
200
+
201
+ function toApiOfferingType(value: CourseEditFormValues['tipoOferta']) {
202
+ if (value === 'agendado') return 'scheduled';
203
+ if (value === 'hibrido') return 'blended';
204
+ return 'on_demand';
205
+ }
206
+
207
+ function getContrastColor(hex: string) {
208
+ const cleaned = hex.replace('#', '');
209
+ if (cleaned.length !== 6) return '#FFFFFF';
210
+ const r = parseInt(cleaned.slice(0, 2), 16);
211
+ const g = parseInt(cleaned.slice(2, 4), 16);
212
+ const b = parseInt(cleaned.slice(4, 6), 16);
213
+ const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
214
+ return luminance > 0.6 ? '#111827' : '#FFFFFF';
215
+ }
216
+
217
+ function slugify(value: string) {
218
+ return value
219
+ .trim()
220
+ .toLowerCase()
221
+ .normalize('NFD')
222
+ .replace(/[\u0300-\u036f]/g, '')
223
+ .replace(/[^a-z0-9]+/g, '-')
224
+ .replace(/^-+|-+$/g, '');
225
+ }
226
+
227
+ function getInstructorAvatarUrl(avatarId?: number | null) {
228
+ return typeof avatarId === 'number' && avatarId > 0
229
+ ? `${process.env.NEXT_PUBLIC_API_BASE_URL}/person/avatar/${avatarId}`
230
+ : null;
231
+ }
232
+
233
+ // ── Schema ────────────────────────────────────────────────────────────────────
234
+
235
+ function buildSchema(t: (key: string) => string) {
236
+ return z.object({
237
+ slug: z
238
+ .string()
239
+ .trim()
240
+ .min(2, t('validation.codeMin'))
241
+ .max(32, t('validation.codeMax'))
242
+ .regex(
243
+ /^[a-z0-9]+(?:-[a-z0-9]+)*$/,
244
+ 'Código: apenas letras minúsculas, números e hífens'
245
+ ),
246
+ nomeInterno: z.string().trim().min(3, t('validation.internalNameMin')),
247
+ tituloComercial: z.string().trim().min(3, t('validation.titleMin')),
248
+ descricaoPublica: z.string().trim(),
249
+ objetivos: z.string().optional(),
250
+ publicoAlvo: z.string().optional(),
251
+ nivel: z.enum(['iniciante', 'intermediario', 'avancado']),
252
+ status: z.enum(['ativo', 'rascunho', 'arquivado']),
253
+ tipoOferta: z.enum(['agendado', 'sob_demanda', 'hibrido']),
254
+ categorias: z.array(z.string()).optional().default([]),
255
+ primaryColor: z
256
+ .string()
257
+ .regex(/^#([0-9A-Fa-f]{6})$/, t('validation.colorInvalid')),
258
+ secondaryColor: z
259
+ .string()
260
+ .regex(/^#([0-9A-Fa-f]{6})$/, t('validation.colorInvalid')),
261
+ instrutores: z.array(z.string()).optional(),
262
+ preRequisitos: z.string().optional(),
263
+ modeloCertificado: z.string().optional(),
264
+ certificado: z.boolean().default(false),
265
+ destaque: z.boolean().default(false),
266
+ listado: z.boolean().default(false),
267
+ });
268
+ }
269
+
270
+ // ── Component ─────────────────────────────────────────────────────────────────
271
+
272
+ export function EditorCourse() {
273
+ const courseId = useStructureStore((s) => s.courseId);
274
+ const course = useStructureStore((s) => s.course);
275
+ const sessions = useStructureStore((s) => s.sessions);
276
+ const lessons = useStructureStore((s) => s.lessons);
277
+ const updateCourseInStore = useStructureStore((s) => s.updateCourse);
278
+
279
+ const { request, currentLocaleCode, locales } = useApp();
280
+ const t = useTranslations('lms.CursoEditPage');
281
+ const router = useRouter();
282
+ const queryClient = useQueryClient();
283
+ const createSessionMutation = useCreateSessionMutation();
284
+
285
+ // ── UI state ────────────────────────────────────────────────────────────────
286
+ const [activeTab, setActiveTab] = useState('estrutura');
287
+ const [instructorSheetOpen, setInstructorSheetOpen] = useState(false);
288
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
289
+ const [deleting, setDeleting] = useState(false);
290
+ const [logoPreview, setLogoPreview] = useState<string | null>(null);
291
+ const [bannerPreview, setBannerPreview] = useState<string | null>(null);
292
+ const [uploadingLogo, setUploadingLogo] = useState(false);
293
+ const [uploadingBanner, setUploadingBanner] = useState(false);
294
+ const [createdCategoryOptions, setCreatedCategoryOptions] = useState<
295
+ PickerOption[]
296
+ >([]);
297
+ const [createdTemplateOptions, setCreatedTemplateOptions] = useState<
298
+ PickerOption[]
299
+ >([]);
300
+ const [persistedCertificateModel, setPersistedCertificateModel] =
301
+ useState('');
302
+
303
+ // ── Queries ─────────────────────────────────────────────────────────────────
304
+ const { data: apiCourse, refetch: refetchCourse } = useQuery<ApiCourseDetail>(
305
+ {
306
+ queryKey: ['lms-course-detail', courseId],
307
+ enabled: Boolean(courseId),
308
+ queryFn: async () => {
309
+ const response = await request<ApiCourseDetail>({
310
+ url: `/lms/courses/${courseId}`,
311
+ method: 'GET',
312
+ });
313
+ return response.data;
314
+ },
315
+ }
316
+ );
317
+
318
+ const { data: categoryListData, refetch: refetchCategoryOptions } =
319
+ useQuery<ApiCategoryList>({
320
+ queryKey: ['lms-edit-course-categories'],
321
+ queryFn: async () => {
322
+ const response = await request<ApiCategoryList | ApiCategory[]>({
323
+ url: '/category',
324
+ method: 'GET',
325
+ params: { page: 1, pageSize: 500, status: 'all' },
326
+ });
327
+ const payload = response.data;
328
+ if (Array.isArray(payload))
329
+ return {
330
+ data: payload,
331
+ total: payload.length,
332
+ page: 1,
333
+ pageSize: payload.length,
334
+ };
335
+ return payload;
336
+ },
337
+ initialData: { data: [], total: 0, page: 1, pageSize: 500 },
338
+ });
339
+
340
+ const { data: instructorListData, refetch: refetchInstructorOptions } =
341
+ useQuery<ApiInstructorList>({
342
+ queryKey: ['lms-course-edit-instructors'],
343
+ queryFn: async () => {
344
+ const response = await request<ApiInstructorList | ApiInstructor[]>({
345
+ url: '/lms/instructors',
346
+ method: 'GET',
347
+ params: {
348
+ page: 1,
349
+ pageSize: 500,
350
+ qualificationSlugs: ['course-lessons'],
351
+ },
352
+ });
353
+ const payload = response.data;
354
+ if (Array.isArray(payload))
355
+ return {
356
+ data: payload,
357
+ total: payload.length,
358
+ page: 1,
359
+ pageSize: payload.length,
360
+ };
361
+ return payload;
362
+ },
363
+ initialData: { data: [], total: 0, page: 1, pageSize: 500 },
364
+ });
365
+
366
+ const {
367
+ data: certificateTemplateData,
368
+ refetch: refetchCertificateTemplates,
369
+ } = useQuery<ApiCertificateTemplateList>({
370
+ queryKey: ['lms-course-certificate-templates'],
371
+ queryFn: async () => {
372
+ const response = await request<
373
+ ApiCertificateTemplateList | ApiCertificateTemplate[]
374
+ >({
375
+ url: '/lms/certificates/templates',
376
+ method: 'GET',
377
+ params: { page: 1, pageSize: 100 },
378
+ });
379
+ const payload = response.data;
380
+ if (Array.isArray(payload))
381
+ return {
382
+ data: payload,
383
+ total: payload.length,
384
+ page: 1,
385
+ pageSize: payload.length,
386
+ lastPage: 1,
387
+ };
388
+ return payload;
389
+ },
390
+ initialData: { data: [], total: 0, page: 1, pageSize: 100, lastPage: 1 },
391
+ });
392
+
393
+ // ── Save mutation ───────────────────────────────────────────────────────────
394
+ const { mutate: saveCourse, isPending: saving } = useMutation({
395
+ mutationFn: async (data: CourseEditFormValues) => {
396
+ await request({
397
+ url: `/lms/courses/${courseId}`,
398
+ method: 'PATCH',
399
+ data: {
400
+ name: data.nomeInterno.trim(),
401
+ slug: data.slug.trim().toLowerCase(),
402
+ title: data.tituloComercial.trim(),
403
+ description: data.descricaoPublica.trim(),
404
+ requirements: data.preRequisitos?.trim() || undefined,
405
+ objectives: data.objetivos?.trim() || undefined,
406
+ targetAudience: data.publicoAlvo?.trim() || undefined,
407
+ level: toApiLevel(data.nivel),
408
+ status: toApiStatus(data.status),
409
+ offeringType: toApiOfferingType(data.tipoOferta),
410
+ categorySlugs: data.categorias,
411
+ primaryColor: data.primaryColor,
412
+ primaryContrastColor: getContrastColor(data.primaryColor),
413
+ secondaryColor: data.secondaryColor,
414
+ secondaryContrastColor: getContrastColor(data.secondaryColor),
415
+ hasCertificate: data.certificado,
416
+ isFeatured: data.destaque,
417
+ isListed: data.listado,
418
+ certificateModel: data.modeloCertificado || undefined,
419
+ instructorIds: (data.instrutores ?? []).map(Number),
420
+ },
421
+ });
422
+ return data;
423
+ },
424
+ onSuccess: (data) => {
425
+ setPersistedCertificateModel(data.modeloCertificado || '');
426
+ updateCourseInStore({
427
+ title: data.tituloComercial,
428
+ slug: data.slug,
429
+ name: data.nomeInterno,
430
+ description: data.descricaoPublica,
431
+ published: data.status === 'ativo',
432
+ });
433
+ void queryClient.invalidateQueries({
434
+ queryKey: courseStructureQueryKey(courseId),
435
+ });
436
+ form.reset(data);
437
+ toast.success(t('toasts.courseUpdated'));
438
+ },
439
+ onError: () => {
440
+ toast.error('Erro ao salvar curso');
441
+ },
442
+ });
443
+
444
+ // ── Form ─────────────────────────────────────────────────────────────────────
445
+ const schema = useMemo(() => buildSchema(t), [t]);
446
+
447
+ const form = useForm<CourseEditFormValues>({
448
+ resolver: zodResolver(schema),
449
+ defaultValues: {
450
+ slug: '',
451
+ nomeInterno: '',
452
+ tituloComercial: '',
453
+ descricaoPublica: '',
454
+ objetivos: '',
455
+ publicoAlvo: '',
456
+ nivel: 'iniciante',
457
+ status: 'rascunho',
458
+ tipoOferta: 'sob_demanda',
459
+ categorias: [],
460
+ primaryColor: '#1D4ED8',
461
+ secondaryColor: '#111827',
462
+ instrutores: [],
463
+ preRequisitos: '',
464
+ modeloCertificado: '',
465
+ certificado: false,
466
+ destaque: false,
467
+ listado: false,
468
+ },
469
+ });
470
+
471
+ const { isDirty } = form.formState;
472
+
473
+ const NIVEIS = useMemo(
474
+ () => [
475
+ { value: 'iniciante', label: t('levels.beginner') },
476
+ { value: 'intermediario', label: t('levels.intermediate') },
477
+ { value: 'avancado', label: t('levels.advanced') },
478
+ ],
479
+ [t]
480
+ );
481
+ const STATUS_OPTIONS = useMemo(
482
+ () => [
483
+ { value: 'ativo', label: t('status.active') },
484
+ { value: 'rascunho', label: t('status.draft') },
485
+ { value: 'arquivado', label: t('status.archived') },
486
+ ],
487
+ [t]
488
+ );
489
+ const OFFERING_TYPE_OPTIONS = useMemo(
490
+ () =>
491
+ [
492
+ {
493
+ value: 'sob_demanda',
494
+ label: t('offeringType.options.onDemand.label'),
495
+ description: t('offeringType.options.onDemand.description'),
496
+ },
497
+ {
498
+ value: 'agendado',
499
+ label: t('offeringType.options.scheduled.label'),
500
+ description: t('offeringType.options.scheduled.description'),
501
+ },
502
+ {
503
+ value: 'hibrido',
504
+ label: t('offeringType.options.blended.label'),
505
+ description: t('offeringType.options.blended.description'),
506
+ },
507
+ ] as const,
508
+ [t]
509
+ );
510
+
511
+ // ── Derived options ──────────────────────────────────────────────────────────
512
+ const categoryOptions = useMemo(() => {
513
+ const serverOptions = (categoryListData?.data ?? [])
514
+ .filter((item) => !!item.slug)
515
+ .map((item) => ({ value: item.slug, label: item.name || item.slug }));
516
+ return [...serverOptions, ...createdCategoryOptions].filter(
517
+ (item, i, arr) => arr.findIndex((c) => c.value === item.value) === i
518
+ );
519
+ }, [categoryListData, createdCategoryOptions]);
520
+
521
+ const instructorOptions = useMemo(() => {
522
+ const fromCourse = (apiCourse?.instructors ?? []).map((item) => ({
523
+ value: String(item.id),
524
+ label: item.name,
525
+ avatarUrl: getInstructorAvatarUrl(item.avatarId),
526
+ meta: `ID ${item.id}`,
527
+ }));
528
+ const fromDirectory = (instructorListData?.data ?? []).map((item) => ({
529
+ value: String(item.id),
530
+ label: item.name,
531
+ avatarUrl: getInstructorAvatarUrl(item.avatarId),
532
+ meta: item.qualificationSlugs?.join(' • ') || `ID ${item.id}`,
533
+ }));
534
+ return [...fromCourse, ...fromDirectory].filter(
535
+ (item, i, arr) => arr.findIndex((c) => c.value === item.value) === i
536
+ );
537
+ }, [apiCourse?.instructors, instructorListData]);
538
+
539
+ const certificateOptions = useMemo(() => {
540
+ const serverOptions = (certificateTemplateData?.data ?? []).map((item) => ({
541
+ value: item.slug || String(item.id),
542
+ label: item.name,
543
+ description: item.description || null,
544
+ meta: item.status ? `Status: ${item.status}` : null,
545
+ }));
546
+ return [...serverOptions, ...createdTemplateOptions].filter(
547
+ (item, i, arr) => arr.findIndex((c) => c.value === item.value) === i
548
+ );
549
+ }, [certificateTemplateData, createdTemplateOptions]);
550
+
551
+ // ── Structural stats ─────────────────────────────────────────────────────────
552
+ const totalMinutes = lessons.reduce((sum, l) => sum + l.duration, 0);
553
+ const hours = Math.floor(totalMinutes / 60);
554
+ const minutes = totalMinutes % 60;
555
+ const publishedCount = lessons.filter(
556
+ (l) => l.visibility === 'publico' || l.status === 'publicada'
557
+ ).length;
558
+
559
+ // ── Effects ──────────────────────────────────────────────────────────────────
560
+ useEffect(() => {
561
+ if (!apiCourse) return;
562
+ const nextCertificateModel =
563
+ apiCourse.certificateModel ?? persistedCertificateModel ?? '';
564
+ form.reset({
565
+ slug: apiCourse.slug,
566
+ nomeInterno: apiCourse.name,
567
+ tituloComercial: apiCourse.title,
568
+ descricaoPublica: apiCourse.description ?? '',
569
+ objetivos: apiCourse.objectives ?? '',
570
+ publicoAlvo: apiCourse.targetAudience ?? '',
571
+ nivel: toPtLevel(apiCourse.level),
572
+ status: toPtStatus(apiCourse.status),
573
+ tipoOferta: toPtOfferingType(apiCourse.offeringType),
574
+ categorias: apiCourse.categories ?? [],
575
+ primaryColor: apiCourse.primaryColor || '#1D4ED8',
576
+ secondaryColor: apiCourse.secondaryColor || '#111827',
577
+ instrutores: (apiCourse.instructorIds ?? []).map(String),
578
+ preRequisitos: apiCourse.requirements ?? '',
579
+ modeloCertificado: nextCertificateModel,
580
+ certificado: apiCourse.hasCertificate ?? false,
581
+ destaque: apiCourse.isFeatured ?? false,
582
+ listado: apiCourse.isListed ?? false,
583
+ });
584
+ setPersistedCertificateModel(nextCertificateModel);
585
+ }, [apiCourse]); // eslint-disable-line react-hooks/exhaustive-deps
586
+
587
+ useEffect(() => {
588
+ const previews = [
589
+ { fileId: apiCourse?.logoFileId, setter: setLogoPreview },
590
+ { fileId: apiCourse?.bannerFileId, setter: setBannerPreview },
591
+ ];
592
+ previews.forEach(({ fileId, setter }) => {
593
+ if (!fileId) return;
594
+ void (async () => {
595
+ try {
596
+ const response = await request<{ url?: string }>({
597
+ url: `/file/open/${fileId}`,
598
+ method: 'PUT',
599
+ });
600
+ if (response?.data?.url) setter(response.data.url);
601
+ } catch {
602
+ // ignore preview failures
603
+ }
604
+ })();
605
+ });
606
+ }, [apiCourse?.bannerFileId, apiCourse?.logoFileId, request]);
607
+
608
+ // ── File handlers ────────────────────────────────────────────────────────────
609
+ async function openUploadedFile(fileId?: number | null) {
610
+ if (!fileId) return;
611
+ try {
612
+ const response = await request<{ url?: string }>({
613
+ url: `/file/open/${fileId}`,
614
+ method: 'PUT',
615
+ });
616
+ const url = response?.data?.url;
617
+ if (!url) {
618
+ toast.error(t('toasts.openFileError'));
619
+ return;
620
+ }
621
+ window.open(url, '_blank', 'noopener,noreferrer');
622
+ } catch {
623
+ toast.error(t('toasts.openFileError'));
624
+ }
625
+ }
626
+
627
+ async function handleFileSelect(
628
+ event: ChangeEvent<HTMLInputElement>,
629
+ setter: (v: string | null) => void,
630
+ type: 'logo' | 'banner'
631
+ ) {
632
+ const file = event.target.files?.[0];
633
+ if (!file) return;
634
+ if (!file.type.startsWith('image/')) {
635
+ toast.error(t('toasts.onlyImages'));
636
+ return;
637
+ }
638
+ if (file.size > 5 * 1024 * 1024) {
639
+ toast.error(t('toasts.maxSize'));
640
+ return;
641
+ }
642
+ const objectUrl = URL.createObjectURL(file);
643
+ setter(objectUrl);
644
+ type === 'logo' ? setUploadingLogo(true) : setUploadingBanner(true);
645
+ try {
646
+ const formData = new FormData();
647
+ formData.append('file', file);
648
+ formData.append('destination', 'lms/courses');
649
+ const uploadResponse = await request<{ id: number; filename: string }>({
650
+ url: '/file',
651
+ method: 'POST',
652
+ data: formData,
653
+ headers: { 'Content-Type': 'multipart/form-data' },
654
+ });
655
+ const fileId = uploadResponse?.data?.id;
656
+ if (!fileId) throw new Error('invalid file id');
657
+ await request({
658
+ url: `/lms/courses/${courseId}`,
659
+ method: 'PATCH',
660
+ data:
661
+ type === 'logo' ? { logoFileId: fileId } : { bannerFileId: fileId },
662
+ });
663
+ await refetchCourse();
664
+ toast.success(t('toasts.fileSelected', { name: file.name }));
665
+ } catch {
666
+ toast.error(t('toasts.fileUploadError'));
667
+ } finally {
668
+ type === 'logo' ? setUploadingLogo(false) : setUploadingBanner(false);
669
+ }
670
+ }
671
+
672
+ async function handleRemoveFile(type: 'logo' | 'banner') {
673
+ const fileId =
674
+ type === 'logo' ? apiCourse?.logoFileId : apiCourse?.bannerFileId;
675
+ try {
676
+ await request({
677
+ url: `/lms/courses/${courseId}`,
678
+ method: 'PATCH',
679
+ data: type === 'logo' ? { logoFileId: null } : { bannerFileId: null },
680
+ });
681
+ if (fileId) {
682
+ await request({ url: `/file/${fileId}`, method: 'DELETE' });
683
+ }
684
+ if (type === 'logo') setLogoPreview(null);
685
+ else setBannerPreview(null);
686
+ await refetchCourse();
687
+ } catch {
688
+ toast.error(t('toasts.fileUploadError'));
689
+ }
690
+ }
691
+
692
+ async function handleCreateCategory(values: Record<string, string>) {
693
+ const name = String(values.name ?? '').trim();
694
+ const slug = slugify(values.slug || values.name || '');
695
+ if (!name || !slug) {
696
+ toast.error('Informe nome e slug para criar a categoria.');
697
+ return null;
698
+ }
699
+ const localeCode =
700
+ currentLocaleCode ||
701
+ (locales?.[0] as Locale | undefined)?.code ||
702
+ 'pt-BR';
703
+ try {
704
+ await request({
705
+ url: '/category',
706
+ method: 'POST',
707
+ data: {
708
+ locale: { [localeCode]: { name } },
709
+ slug,
710
+ category_id: null,
711
+ color: '#000000',
712
+ icon: '',
713
+ status: 'active',
714
+ },
715
+ });
716
+ const option = { value: slug, label: name };
717
+ setCreatedCategoryOptions((current) => [option, ...current]);
718
+ await refetchCategoryOptions();
719
+ toast.success('Categoria criada com sucesso.');
720
+ return option;
721
+ } catch {
722
+ toast.error('Não foi possível criar a categoria.');
723
+ return null;
724
+ }
725
+ }
726
+
727
+ async function handleCreateCertificateTemplate(
728
+ values: Record<string, string>
729
+ ) {
730
+ const name = String(values.name ?? '').trim();
731
+ const description = String(values.description ?? '').trim();
732
+ if (!name) {
733
+ toast.error('Informe o nome do modelo.');
734
+ return null;
735
+ }
736
+ const initialTemplate = createDefaultTemplate();
737
+ initialTemplate.name = name;
738
+ try {
739
+ const response = await request<ApiCertificateTemplate>({
740
+ url: '/lms/certificates/templates',
741
+ method: 'POST',
742
+ data: {
743
+ name,
744
+ description: description || undefined,
745
+ status: 'draft',
746
+ templateContent: JSON.stringify(initialTemplate),
747
+ },
748
+ });
749
+ const created = response.data;
750
+ const option = {
751
+ value: created.slug || String(created.id),
752
+ label: created.name,
753
+ description: created.description || null,
754
+ meta: created.status ? `Status: ${created.status}` : null,
755
+ };
756
+ setCreatedTemplateOptions((current) => [option, ...current]);
757
+ setPersistedCertificateModel(option.value);
758
+ await refetchCertificateTemplates();
759
+ toast.success('Modelo de certificado criado com sucesso.');
760
+ return option;
761
+ } catch {
762
+ toast.error('Não foi possível criar o modelo de certificado.');
763
+ return null;
764
+ }
765
+ }
766
+
767
+ const handleInstructorCreated = async (instructor: {
768
+ id: number;
769
+ personId: number;
770
+ name: string;
771
+ avatarId?: number | null;
772
+ email?: string | null;
773
+ phone?: string | null;
774
+ qualificationSlugs: string[];
775
+ }) => {
776
+ const createdId = String(instructor.id);
777
+ const current = form.getValues('instrutores') ?? [];
778
+ if (!current.includes(createdId)) {
779
+ form.setValue('instrutores', [...current, createdId], {
780
+ shouldDirty: true,
781
+ shouldTouch: true,
782
+ shouldValidate: true,
783
+ });
784
+ }
785
+ await refetchInstructorOptions();
786
+ };
787
+
788
+ async function handleDelete() {
789
+ setDeleting(true);
790
+ try {
791
+ await request({ url: `/lms/courses/${courseId}`, method: 'DELETE' });
792
+ setDeleteDialogOpen(false);
793
+ toast.success(t('toasts.courseDeleted'));
794
+ router.push('/lms/courses');
795
+ } finally {
796
+ setDeleting(false);
797
+ }
798
+ }
799
+
800
+ function onSubmit(data: CourseEditFormValues) {
801
+ saveCourse(data);
802
+ }
803
+
804
+ function onFormError(errors: Record<string, unknown>) {
805
+ const first = Object.values(errors)[0] as { message?: string } | undefined;
806
+ toast.error(first?.message ?? 'Verifique os campos obrigatórios');
807
+ }
808
+
809
+ // ── Render ────────────────────────────────────────────────────────────────────
810
+ return (
811
+ <Form {...form}>
812
+ <form
813
+ onSubmit={form.handleSubmit(onSubmit, onFormError)}
814
+ className="flex flex-col h-full min-h-0"
815
+ >
816
+ {/* ── Header ───────────────────────────────────────────────────────── */}
817
+ <div className="flex items-center gap-3 px-4 py-3 border-b bg-muted/30 shrink-0">
818
+ <div className="flex size-9 items-center justify-center rounded-lg bg-primary/10 shrink-0">
819
+ <BookOpen className="size-4 text-primary" />
820
+ </div>
821
+ <div className="flex-1 min-w-0">
822
+ <div className="flex items-center gap-1.5">
823
+ <span className="text-sm font-semibold truncate">Curso</span>
824
+ {isDirty && (
825
+ <CircleDot className="size-3 text-amber-500 shrink-0" />
826
+ )}
827
+ </div>
828
+ <p className="text-[0.65rem] text-muted-foreground truncate">
829
+ {course.slug}
830
+ </p>
831
+ </div>
832
+ <Badge
833
+ variant={course.published ? 'default' : 'secondary'}
834
+ className="shrink-0 text-xs"
835
+ >
836
+ {course.published ? 'Publicado' : 'Rascunho'}
837
+ </Badge>
838
+ </div>
839
+
840
+ {/* ── Tabs ─────────────────────────────────────────────────────────── */}
841
+ <Tabs
842
+ value={activeTab}
843
+ onValueChange={setActiveTab}
844
+ className="flex flex-col flex-1 min-h-0"
845
+ >
846
+ <TabsList>
847
+ {(['estrutura', 'sobre', 'midia', 'extra'] as const).map((tab) => (
848
+ <TabsTrigger key={tab} value={tab}>
849
+ {tab === 'estrutura'
850
+ ? 'Estrutura'
851
+ : tab === 'sobre'
852
+ ? 'Sobre'
853
+ : tab === 'midia'
854
+ ? 'Mídia'
855
+ : 'Extra'}
856
+ </TabsTrigger>
857
+ ))}
858
+ </TabsList>
859
+
860
+ <div className="flex-1 min-h-0 overflow-hidden">
861
+ <ScrollArea className="h-full">
862
+ <div className="p-3 flex flex-col gap-3">
863
+ {/* ── Tab: Estrutura ──────────────────────────────────────── */}
864
+ <TabsContent
865
+ value="estrutura"
866
+ className="mt-0 flex flex-col gap-3"
867
+ >
868
+ {/* Stat chips */}
869
+ <Card className="bg-muted/20 py-2 gap-2">
870
+ <CardHeader className="px-3 pt-2 pb-0">
871
+ <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
872
+ Resumo do conteúdo
873
+ </CardTitle>
874
+ </CardHeader>
875
+ <CardContent className="px-3 pb-2">
876
+ <div className="grid grid-cols-2 gap-2">
877
+ <StatChip
878
+ icon={<Layers className="size-3 text-blue-500" />}
879
+ label="Sessões"
880
+ value={sessions.length}
881
+ />
882
+ <StatChip
883
+ icon={<Video className="size-3 text-violet-500" />}
884
+ label="Aulas"
885
+ value={lessons.length}
886
+ />
887
+ <StatChip
888
+ icon={<Clock className="size-3 text-amber-500" />}
889
+ label="Duração"
890
+ value={
891
+ hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`
892
+ }
893
+ />
894
+ <StatChip
895
+ icon={
896
+ <CheckCircle2 className="size-3 text-emerald-500" />
897
+ }
898
+ label="Publicadas"
899
+ value={publishedCount}
900
+ />
901
+ </div>
902
+ </CardContent>
903
+ </Card>
904
+
905
+ {/* Dados principais */}
906
+ <Card className="bg-muted/20 py-2 gap-2">
907
+ <CardHeader className="px-3 pt-2 pb-0">
908
+ <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
909
+ Dados principais
910
+ </CardTitle>
911
+ </CardHeader>
912
+ <CardContent className="px-3 pb-2 flex flex-col gap-3">
913
+ <FormField
914
+ control={form.control}
915
+ name="tituloComercial"
916
+ render={({ field }) => (
917
+ <FormItem>
918
+ <FormLabel className="text-xs">Título</FormLabel>
919
+ <FormControl>
920
+ <Input {...field} className="h-8 text-sm" />
921
+ </FormControl>
922
+ <FormMessage className="text-xs" />
923
+ </FormItem>
924
+ )}
925
+ />
926
+ <div className="grid grid-cols-2 gap-2">
927
+ <FormField
928
+ control={form.control}
929
+ name="nomeInterno"
930
+ render={({ field }) => (
931
+ <FormItem>
932
+ <FormLabel className="text-xs">
933
+ Nome Interno
934
+ </FormLabel>
935
+ <FormControl>
936
+ <Input {...field} className="h-8 text-xs" />
937
+ </FormControl>
938
+ <FormMessage className="text-xs" />
939
+ </FormItem>
940
+ )}
941
+ />
942
+ <FormField
943
+ control={form.control}
944
+ name="slug"
945
+ render={({ field }) => (
946
+ <FormItem>
947
+ <FormLabel className="text-xs">Slug</FormLabel>
948
+ <FormControl>
949
+ <Input
950
+ {...field}
951
+ className="h-8 text-xs font-mono"
952
+ onChange={(e) =>
953
+ field.onChange(e.target.value.toLowerCase())
954
+ }
955
+ />
956
+ </FormControl>
957
+ <FormMessage className="text-xs" />
958
+ </FormItem>
959
+ )}
960
+ />
961
+ </div>
962
+ </CardContent>
963
+ </Card>
964
+
965
+ {/* Descrição */}
966
+ <Card className="bg-muted/20 py-2 gap-2">
967
+ <CardHeader className="px-3 pt-2 pb-0">
968
+ <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
969
+ Descrição
970
+ </CardTitle>
971
+ </CardHeader>
972
+ <CardContent className="px-3 pb-2">
973
+ <FormField
974
+ control={form.control}
975
+ name="descricaoPublica"
976
+ render={({ field }) => (
977
+ <FormItem>
978
+ <FormControl>
979
+ <RichTextEditor
980
+ value={field.value}
981
+ onChange={field.onChange}
982
+ />
983
+ </FormControl>
984
+ <FormMessage className="text-xs" />
985
+ </FormItem>
986
+ )}
987
+ />
988
+ </CardContent>
989
+ </Card>
990
+ </TabsContent>
991
+
992
+ {/* ── Tab: Sobre ──────────────────────────────────────────── */}
993
+ <TabsContent value="sobre" className="mt-0 flex flex-col gap-3">
994
+ <CourseClassificationCard
995
+ form={form}
996
+ t={t}
997
+ levels={NIVEIS}
998
+ statuses={STATUS_OPTIONS}
999
+ offeringTypes={OFFERING_TYPE_OPTIONS}
1000
+ />
1001
+ <CourseRelationsCard
1002
+ form={form}
1003
+ t={t}
1004
+ categoryOptions={categoryOptions}
1005
+ instructorOptions={instructorOptions}
1006
+ onCreateCategory={handleCreateCategory}
1007
+ onCreateInstructor={() => setInstructorSheetOpen(true)}
1008
+ />
1009
+ <CourseContentCard form={form} />
1010
+ </TabsContent>
1011
+
1012
+ {/* ── Tab: Mídia ──────────────────────────────────────────── */}
1013
+ <TabsContent value="midia" className="mt-0">
1014
+ <CourseMediaCard
1015
+ logoPreview={logoPreview}
1016
+ bannerPreview={bannerPreview}
1017
+ uploadingLogo={uploadingLogo}
1018
+ uploadingBanner={uploadingBanner}
1019
+ onLogoSelect={(e: React.ChangeEvent<HTMLInputElement>) =>
1020
+ handleFileSelect(e, setLogoPreview, 'logo')
1021
+ }
1022
+ onBannerSelect={(e: React.ChangeEvent<HTMLInputElement>) =>
1023
+ handleFileSelect(e, setBannerPreview, 'banner')
1024
+ }
1025
+ logoFile={
1026
+ apiCourse?.logoFileId
1027
+ ? {
1028
+ id: apiCourse.logoFileId,
1029
+ name:
1030
+ apiCourse.logoFilename ||
1031
+ `#${apiCourse.logoFileId}`,
1032
+ }
1033
+ : undefined
1034
+ }
1035
+ bannerFile={
1036
+ apiCourse?.bannerFileId
1037
+ ? {
1038
+ id: apiCourse.bannerFileId,
1039
+ name:
1040
+ apiCourse.bannerFilename ||
1041
+ `#${apiCourse.bannerFileId}`,
1042
+ }
1043
+ : undefined
1044
+ }
1045
+ onOpenLogoFile={() =>
1046
+ openUploadedFile(apiCourse?.logoFileId)
1047
+ }
1048
+ onOpenBannerFile={() =>
1049
+ openUploadedFile(apiCourse?.bannerFileId)
1050
+ }
1051
+ onRemoveLogoFile={
1052
+ apiCourse?.logoFileId
1053
+ ? () => handleRemoveFile('logo')
1054
+ : undefined
1055
+ }
1056
+ onRemoveBannerFile={
1057
+ apiCourse?.bannerFileId
1058
+ ? () => handleRemoveFile('banner')
1059
+ : undefined
1060
+ }
1061
+ logoImageType={apiCourse?.logoImageType}
1062
+ bannerImageType={apiCourse?.bannerImageType}
1063
+ t={t}
1064
+ />
1065
+ </TabsContent>
1066
+
1067
+ {/* ── Tab: Extra ──────────────────────────────────────────── */}
1068
+ <TabsContent value="extra" className="mt-0 flex flex-col gap-3">
1069
+ <CourseCertificateCard
1070
+ form={form}
1071
+ t={t}
1072
+ options={certificateOptions}
1073
+ onCreateTemplate={handleCreateCertificateTemplate}
1074
+ />
1075
+ <CourseFlagsCard form={form} t={t} />
1076
+ <CourseDangerZoneCard
1077
+ t={t}
1078
+ onDelete={() => setDeleteDialogOpen(true)}
1079
+ />
1080
+ </TabsContent>
1081
+ </div>
1082
+ </ScrollArea>
1083
+ </div>
1084
+ </Tabs>
1085
+
1086
+ {/* ── Footer ───────────────────────────────────────────────────────── */}
1087
+ <div className="shrink-0 border-t bg-background">
1088
+ <Separator />
1089
+ <div className="flex items-center gap-2 px-3 py-2">
1090
+ <Button
1091
+ type="button"
1092
+ variant="ghost"
1093
+ size="sm"
1094
+ className="h-7 text-xs"
1095
+ disabled={!isDirty || saving}
1096
+ onClick={() => form.reset()}
1097
+ >
1098
+ <Undo2 className="size-3 mr-1" />
1099
+ Cancelar
1100
+ </Button>
1101
+ <div className="flex-1" />
1102
+ <Button
1103
+ type="button"
1104
+ variant="outline"
1105
+ size="sm"
1106
+ className="h-7 text-xs"
1107
+ disabled={createSessionMutation.isPending}
1108
+ onClick={() => createSessionMutation.mutate()}
1109
+ >
1110
+ <Plus className="size-3 mr-1" />
1111
+ Nova sessão
1112
+ </Button>
1113
+ <Button
1114
+ type="submit"
1115
+ size="sm"
1116
+ className="h-7 text-xs"
1117
+ disabled={saving}
1118
+ >
1119
+ {saving ? (
1120
+ <Loader2 className="size-3 mr-1 animate-spin" />
1121
+ ) : (
1122
+ <Save className="size-3 mr-1" />
1123
+ )}
1124
+ Salvar
1125
+ </Button>
1126
+ </div>
1127
+ </div>
1128
+ </form>
1129
+
1130
+ {/* ── Instructor sheet ─────────────────────────────────────────────── */}
1131
+ <CreateLmsPersonSheet
1132
+ open={instructorSheetOpen}
1133
+ onOpenChange={setInstructorSheetOpen}
1134
+ onCreated={handleInstructorCreated}
1135
+ title="Cadastrar instrutor"
1136
+ description="Cadastre um novo instrutor e adicione-o ao curso."
1137
+ submitLabel="Cadastrar instrutor"
1138
+ successMessage="Instrutor cadastrado com sucesso."
1139
+ errorMessage="Não foi possível cadastrar o instrutor."
1140
+ defaultQualificationSlugs={['course-lessons']}
1141
+ />
1142
+
1143
+ {/* ── Delete dialog ────────────────────────────────────────────────── */}
1144
+ <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
1145
+ <DialogContent>
1146
+ <DialogHeader>
1147
+ <DialogTitle className="flex items-center gap-2">
1148
+ <AlertTriangle className="size-5 text-destructive" />
1149
+ {t('deleteDialog.title')}
1150
+ </DialogTitle>
1151
+ <DialogDescription asChild>
1152
+ <div className="flex flex-col gap-3">
1153
+ <p>
1154
+ {t('deleteDialog.description')}{' '}
1155
+ <strong className="text-foreground">{course.title}</strong>?
1156
+ </p>
1157
+ {(apiCourse?.enrollmentCount ?? 0) > 0 ? (
1158
+ <div className="flex items-center gap-2 rounded-md bg-amber-50 px-3 py-2.5 text-xs font-medium text-amber-700">
1159
+ <AlertTriangle className="size-3.5 shrink-0" />
1160
+ <span>
1161
+ {t('deleteDialog.warning', {
1162
+ students: apiCourse?.enrollmentCount ?? 0,
1163
+ certificates: apiCourse?.certificatesIssued ?? 0,
1164
+ })}
1165
+ </span>
1166
+ </div>
1167
+ ) : null}
1168
+ </div>
1169
+ </DialogDescription>
1170
+ </DialogHeader>
1171
+ <DialogFooter className="gap-2">
1172
+ <Button
1173
+ variant="outline"
1174
+ onClick={() => setDeleteDialogOpen(false)}
1175
+ >
1176
+ {t('deleteDialog.actions.cancel')}
1177
+ </Button>
1178
+ <Button
1179
+ variant="destructive"
1180
+ onClick={handleDelete}
1181
+ disabled={deleting}
1182
+ className="gap-2"
1183
+ >
1184
+ {deleting ? <Loader2 className="size-4 animate-spin" /> : null}
1185
+ {t('deleteDialog.actions.delete')}
1186
+ </Button>
1187
+ </DialogFooter>
1188
+ </DialogContent>
1189
+ </Dialog>
1190
+ </Form>
1191
+ );
1192
+ }
1193
+
1194
+ // ── Helpers ───────────────────────────────────────────────────────────────────
1195
+
1196
+ function StatChip({
1197
+ icon,
1198
+ label,
1199
+ value,
1200
+ }: {
1201
+ icon: React.ReactNode;
1202
+ label: string;
1203
+ value: string | number;
1204
+ }) {
1205
+ return (
1206
+ <div className="flex items-center gap-2 rounded-md border bg-background px-2.5 py-2">
1207
+ <span className="text-muted-foreground shrink-0">{icon}</span>
1208
+ <div className="min-w-0">
1209
+ <p className="text-[0.65rem] text-muted-foreground truncate">{label}</p>
1210
+ <p className="text-sm font-semibold tabular-nums leading-none">
1211
+ {value}
1212
+ </p>
1213
+ </div>
1214
+ </div>
1215
+ );
1216
+ }