@hed-hog/lms 0.0.331 → 0.0.347

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/dist/class-group/class-group.controller.d.ts +8 -8
  2. package/dist/class-group/class-group.service.d.ts +8 -8
  3. package/dist/course/course.controller.d.ts +6 -1
  4. package/dist/course/course.controller.d.ts.map +1 -1
  5. package/dist/course/course.controller.js +19 -2
  6. package/dist/course/course.controller.js.map +1 -1
  7. package/dist/course/course.service.d.ts +6 -0
  8. package/dist/course/course.service.d.ts.map +1 -1
  9. package/dist/course/course.service.js +63 -28
  10. package/dist/course/course.service.js.map +1 -1
  11. package/dist/course/dto/create-course.dto.d.ts +1 -0
  12. package/dist/course/dto/create-course.dto.d.ts.map +1 -1
  13. package/dist/course/dto/create-course.dto.js +5 -0
  14. package/dist/course/dto/create-course.dto.js.map +1 -1
  15. package/dist/enterprise/enterprise.controller.d.ts +84 -12
  16. package/dist/enterprise/enterprise.controller.d.ts.map +1 -1
  17. package/dist/enterprise/enterprise.controller.js +10 -0
  18. package/dist/enterprise/enterprise.controller.js.map +1 -1
  19. package/dist/enterprise/enterprise.service.d.ts +90 -12
  20. package/dist/enterprise/enterprise.service.d.ts.map +1 -1
  21. package/dist/enterprise/enterprise.service.js +413 -40
  22. package/dist/enterprise/enterprise.service.js.map +1 -1
  23. package/dist/enterprise/training/training-admin.controller.d.ts +9 -6
  24. package/dist/enterprise/training/training-admin.controller.d.ts.map +1 -1
  25. package/dist/enterprise/training/training-admin.controller.js +10 -6
  26. package/dist/enterprise/training/training-admin.controller.js.map +1 -1
  27. package/dist/enterprise/training/training-admin.service.d.ts +11 -5
  28. package/dist/enterprise/training/training-admin.service.d.ts.map +1 -1
  29. package/dist/enterprise/training/training-admin.service.js +108 -52
  30. package/dist/enterprise/training/training-admin.service.js.map +1 -1
  31. package/dist/enterprise/training/training-viewer.controller.d.ts +3 -0
  32. package/dist/enterprise/training/training-viewer.controller.d.ts.map +1 -1
  33. package/dist/evaluation/evaluation.controller.d.ts +2 -2
  34. package/dist/evaluation/evaluation.service.d.ts +2 -2
  35. package/dist/instructor/dto/create-instructor-skill.dto.d.ts +0 -4
  36. package/dist/instructor/dto/create-instructor-skill.dto.d.ts.map +1 -1
  37. package/dist/instructor/dto/create-instructor-skill.dto.js +0 -21
  38. package/dist/instructor/dto/create-instructor-skill.dto.js.map +1 -1
  39. package/dist/instructor/dto/update-instructor-skill.dto.d.ts +0 -4
  40. package/dist/instructor/dto/update-instructor-skill.dto.d.ts.map +1 -1
  41. package/dist/instructor/dto/update-instructor-skill.dto.js +0 -22
  42. package/dist/instructor/dto/update-instructor-skill.dto.js.map +1 -1
  43. package/dist/instructor/instructor-skill.controller.d.ts +4 -4
  44. package/dist/instructor/instructor-skill.service.d.ts +4 -7
  45. package/dist/instructor/instructor-skill.service.d.ts.map +1 -1
  46. package/dist/instructor/instructor-skill.service.js +2 -89
  47. package/dist/instructor/instructor-skill.service.js.map +1 -1
  48. package/dist/instructor/instructor.controller.d.ts +21 -0
  49. package/dist/instructor/instructor.controller.d.ts.map +1 -1
  50. package/dist/instructor/instructor.controller.js +19 -0
  51. package/dist/instructor/instructor.controller.js.map +1 -1
  52. package/dist/instructor/instructor.service.d.ts +27 -0
  53. package/dist/instructor/instructor.service.d.ts.map +1 -1
  54. package/dist/instructor/instructor.service.js +79 -25
  55. package/dist/instructor/instructor.service.js.map +1 -1
  56. package/dist/lms.module.d.ts.map +1 -1
  57. package/dist/lms.module.js.map +1 -1
  58. package/dist/training/dto/create-training.dto.d.ts +1 -0
  59. package/dist/training/dto/create-training.dto.d.ts.map +1 -1
  60. package/dist/training/dto/create-training.dto.js +5 -0
  61. package/dist/training/dto/create-training.dto.js.map +1 -1
  62. package/dist/training/training.controller.d.ts +4 -0
  63. package/dist/training/training.controller.d.ts.map +1 -1
  64. package/dist/training/training.service.d.ts +8 -0
  65. package/dist/training/training.service.d.ts.map +1 -1
  66. package/dist/training/training.service.js +71 -6
  67. package/dist/training/training.service.js.map +1 -1
  68. package/hedhog/data/route.yaml +23 -1
  69. package/hedhog/frontend/app/_components/class-form-sheet.tsx.ejs +80 -33
  70. package/hedhog/frontend/app/_components/course-form-sheet.tsx.ejs +3 -3
  71. package/hedhog/frontend/app/_components/create-lms-instructor-sheet.tsx.ejs +591 -0
  72. package/hedhog/frontend/app/certificates/issued/page.tsx.ejs +6 -1
  73. package/hedhog/frontend/app/certificates/models/page.tsx.ejs +39 -7
  74. package/hedhog/frontend/app/classes/[id]/page.tsx.ejs +1 -3
  75. package/hedhog/frontend/app/classes/page.tsx.ejs +34 -7
  76. package/hedhog/frontend/app/courses/[id]/_components/CourseClassificationCard.tsx.ejs +3 -33
  77. package/hedhog/frontend/app/courses/[id]/_components/CourseContentCard.tsx.ejs +9 -9
  78. package/hedhog/frontend/app/courses/[id]/_components/CourseMainInfoCard.tsx.ejs +109 -0
  79. package/hedhog/frontend/app/courses/[id]/_components/CourseMultiEntityPicker.tsx.ejs +40 -13
  80. package/hedhog/frontend/app/courses/[id]/_components/CourseRelationsCard.tsx.ejs +76 -81
  81. package/hedhog/frontend/app/courses/[id]/_components/CourseSummaryCard.tsx.ejs +60 -0
  82. package/hedhog/frontend/app/courses/[id]/structure/_components/course-scheduled-classes-tab.tsx.ejs +406 -0
  83. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree.tsx.ejs +134 -0
  84. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-course.tsx.ejs +113 -0
  85. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +314 -0
  86. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-session.tsx.ejs +174 -0
  87. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +243 -34
  88. package/hedhog/frontend/app/courses/[id]/structure/_components/mock-data.ts.ejs +185 -0
  89. package/hedhog/frontend/app/courses/page.tsx.ejs +6 -1
  90. package/hedhog/frontend/app/enterprise/_components/enterprise-activity-timeline.tsx.ejs +87 -0
  91. package/hedhog/frontend/app/enterprise/_components/enterprise-admin-create-sheet.tsx.ejs +4 -0
  92. package/hedhog/frontend/app/enterprise/_components/enterprise-administrators-tab.tsx.ejs +31 -5
  93. package/hedhog/frontend/app/enterprise/_components/enterprise-classes-tab.tsx.ejs +79 -20
  94. package/hedhog/frontend/app/enterprise/_components/enterprise-company-identity-card.tsx.ejs +11 -2
  95. package/hedhog/frontend/app/enterprise/_components/enterprise-course-edit-sheet.tsx.ejs +201 -0
  96. package/hedhog/frontend/app/enterprise/_components/enterprise-courses-tab.tsx.ejs +55 -24
  97. package/hedhog/frontend/app/enterprise/_components/enterprise-detail-sheet.tsx.ejs +430 -296
  98. package/hedhog/frontend/app/enterprise/_components/enterprise-mocks.ts.ejs +277 -0
  99. package/hedhog/frontend/app/enterprise/_components/enterprise-overview-analytics.tsx.ejs +205 -0
  100. package/hedhog/frontend/app/enterprise/_components/enterprise-person-edit-sheet.tsx.ejs +97 -0
  101. package/hedhog/frontend/app/enterprise/_components/enterprise-sheet.tsx.ejs +82 -57
  102. package/hedhog/frontend/app/enterprise/_components/enterprise-student-create-sheet.tsx.ejs +4 -0
  103. package/hedhog/frontend/app/enterprise/_components/enterprise-students-tab.tsx.ejs +60 -22
  104. package/hedhog/frontend/app/enterprise/_components/enterprise-types.ts.ejs +54 -0
  105. package/hedhog/frontend/app/enterprise/_components/enterprise-user-create-sheet.tsx.ejs +211 -0
  106. package/hedhog/frontend/app/enterprise/page.tsx.ejs +39 -7
  107. package/hedhog/frontend/app/exams/[id]/questions/page.tsx.ejs +31 -19
  108. package/hedhog/frontend/app/exams/page.tsx.ejs +6 -1
  109. package/hedhog/frontend/app/instructor-skills/page.tsx.ejs +51 -104
  110. package/hedhog/frontend/app/instructors/_components/instructor-form-sheet.tsx.ejs +625 -366
  111. package/hedhog/frontend/app/instructors/page.tsx.ejs +6 -1
  112. package/hedhog/frontend/app/paths/page.tsx.ejs +76 -8
  113. package/hedhog/frontend/app/reports/evaluations/page.tsx.ejs +6 -1
  114. package/hedhog/frontend/app/training/page.tsx.ejs +78 -9
  115. package/hedhog/frontend/messages/en.json +101 -10
  116. package/hedhog/frontend/messages/pt.json +115 -11
  117. package/hedhog/table/enterprise_student_license_event.yaml +30 -0
  118. package/hedhog/table/instructor_skill.yaml +0 -11
  119. package/hedhog/table/learning_path.yaml +4 -0
  120. package/package.json +6 -6
  121. package/src/course/course.controller.ts +18 -0
  122. package/src/course/course.service.ts +85 -26
  123. package/src/course/dto/create-course.dto.ts +4 -0
  124. package/src/enterprise/enterprise.controller.ts +5 -0
  125. package/src/enterprise/enterprise.service.ts +507 -29
  126. package/src/enterprise/training/training-admin.controller.ts +4 -0
  127. package/src/enterprise/training/training-admin.service.ts +115 -51
  128. package/src/instructor/dto/create-instructor-skill.dto.ts +0 -17
  129. package/src/instructor/dto/update-instructor-skill.dto.ts +0 -18
  130. package/src/instructor/instructor-skill.service.ts +2 -97
  131. package/src/instructor/instructor.controller.ts +16 -0
  132. package/src/instructor/instructor.service.ts +87 -10
  133. package/src/lms.module.ts +1 -0
  134. package/src/training/dto/create-training.dto.ts +4 -0
  135. package/src/training/training.service.ts +104 -5
@@ -0,0 +1,591 @@
1
+ 'use client';
2
+
3
+ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
4
+ import { Button } from '@/components/ui/button';
5
+ import {
6
+ Field,
7
+ FieldDescription,
8
+ FieldError,
9
+ FieldLabel,
10
+ } from '@/components/ui/field';
11
+ import { Input } from '@/components/ui/input';
12
+ import { Progress } from '@/components/ui/progress';
13
+ import {
14
+ Sheet,
15
+ SheetContent,
16
+ SheetDescription,
17
+ SheetFooter,
18
+ SheetHeader,
19
+ SheetTitle,
20
+ } from '@/components/ui/sheet';
21
+ import { Switch } from '@/components/ui/switch';
22
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
23
+ import { zodResolver } from '@hookform/resolvers/zod';
24
+ import { Loader2, Trash2, Upload } from 'lucide-react';
25
+ import { useEffect, useMemo, useRef, useState } from 'react';
26
+ import { Controller, useForm } from 'react-hook-form';
27
+ import { toast } from 'sonner';
28
+ import { z } from 'zod';
29
+
30
+ const createInstructorSchema = z.object({
31
+ nome: z.string().trim().min(3, 'Nome e obrigatorio'),
32
+ email: z
33
+ .string()
34
+ .trim()
35
+ .max(255)
36
+ .refine(
37
+ (value) => !value || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
38
+ 'E-mail invalido'
39
+ ),
40
+ telefone: z
41
+ .string()
42
+ .trim()
43
+ .max(50)
44
+ .refine(
45
+ (value) => !value || /^[0-9+()\s-]{8,20}$/.test(value),
46
+ 'Telefone invalido'
47
+ ),
48
+ qualificationSlugs: z
49
+ .array(z.string())
50
+ .min(1, 'Selecione pelo menos uma atuacao'),
51
+ });
52
+
53
+ type CreateInstructorForm = z.infer<typeof createInstructorSchema>;
54
+
55
+ type CreatedInstructor = {
56
+ id: number;
57
+ personId: number;
58
+ name: string;
59
+ avatarId?: number | null;
60
+ email?: string | null;
61
+ phone?: string | null;
62
+ qualificationSlugs: string[];
63
+ };
64
+
65
+ type InstructorDetails = CreatedInstructor & {
66
+ status?: 'active' | 'inactive';
67
+ };
68
+
69
+ type CreateLmsInstructorSheetProps = {
70
+ open: boolean;
71
+ onOpenChange: (open: boolean) => void;
72
+ onCreated?: (instructor: CreatedInstructor) => void | Promise<void>;
73
+ onSaved?: (instructor: CreatedInstructor) => void | Promise<void>;
74
+ instructorId?: number | null;
75
+ title: string;
76
+ description: string;
77
+ submitLabel: string;
78
+ successMessage: string;
79
+ errorMessage: string;
80
+ defaultQualificationSlugs?: string[];
81
+ };
82
+
83
+ type UploadedFilePayload = {
84
+ id?: number;
85
+ };
86
+
87
+ type OpenFilePayload = {
88
+ url?: string;
89
+ };
90
+
91
+ const QUALIFICATION_OPTIONS = [
92
+ {
93
+ slug: 'course-lessons',
94
+ label: 'Aulas de curso',
95
+ description: 'Pode atuar em aulas gravadas e estrutura de curso.',
96
+ },
97
+ {
98
+ slug: 'class-sessions',
99
+ label: 'Sessoes de turma',
100
+ description: 'Pode atuar em contextos ao vivo e turmas.',
101
+ },
102
+ ];
103
+
104
+ function getInstructorInitials(name?: string | null) {
105
+ return String(name || '')
106
+ .split(' ')
107
+ .filter(Boolean)
108
+ .slice(0, 2)
109
+ .map((part) => part[0]?.toUpperCase() || '')
110
+ .join('');
111
+ }
112
+
113
+ function getInstructorAvatarUrl(fileId?: number | null) {
114
+ return typeof fileId === 'number' && fileId > 0
115
+ ? `${process.env.NEXT_PUBLIC_API_BASE_URL}/person/avatar/${fileId}`
116
+ : '/placeholder.png';
117
+ }
118
+
119
+ export function CreateLmsInstructorSheet({
120
+ open,
121
+ onOpenChange,
122
+ onCreated,
123
+ onSaved,
124
+ instructorId,
125
+ title,
126
+ description,
127
+ submitLabel,
128
+ successMessage,
129
+ errorMessage,
130
+ defaultQualificationSlugs,
131
+ }: CreateLmsInstructorSheetProps) {
132
+ const { request } = useApp();
133
+ const [saving, setSaving] = useState(false);
134
+ const [avatarId, setAvatarId] = useState<number | null>(null);
135
+ const [persistedAvatarId, setPersistedAvatarId] = useState<number | null>(
136
+ null
137
+ );
138
+ const [avatarPreviewUrl, setAvatarPreviewUrl] =
139
+ useState<string>('/placeholder.png');
140
+ const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
141
+ const [avatarUploadProgress, setAvatarUploadProgress] = useState(0);
142
+ const fileInputRef = useRef<HTMLInputElement>(null);
143
+ const isEditing = typeof instructorId === 'number' && instructorId > 0;
144
+
145
+ const initialQualificationSlugs = useMemo(
146
+ () =>
147
+ defaultQualificationSlugs && defaultQualificationSlugs.length > 0
148
+ ? [...new Set(defaultQualificationSlugs)]
149
+ : ['course-lessons'],
150
+ [defaultQualificationSlugs]
151
+ );
152
+
153
+ const form = useForm<CreateInstructorForm>({
154
+ resolver: zodResolver(createInstructorSchema),
155
+ defaultValues: {
156
+ nome: '',
157
+ email: '',
158
+ telefone: '',
159
+ qualificationSlugs: initialQualificationSlugs,
160
+ },
161
+ });
162
+
163
+ const {
164
+ data: instructorDetails,
165
+ isLoading: loadingInstructorDetails,
166
+ refetch: refetchInstructorDetails,
167
+ } = useQuery<InstructorDetails>({
168
+ queryKey: ['lms-instructor-sheet', instructorId],
169
+ enabled: open && isEditing,
170
+ queryFn: async () => {
171
+ const response = await request<InstructorDetails>({
172
+ url: `/lms/instructors/${instructorId}`,
173
+ method: 'GET',
174
+ });
175
+
176
+ return response.data;
177
+ },
178
+ });
179
+
180
+ useEffect(() => {
181
+ if (!open || !isEditing) {
182
+ return;
183
+ }
184
+
185
+ void refetchInstructorDetails();
186
+ }, [isEditing, open, refetchInstructorDetails]);
187
+
188
+ useEffect(() => {
189
+ if (!open) {
190
+ return;
191
+ }
192
+
193
+ const instructor = isEditing ? instructorDetails : undefined;
194
+
195
+ if (isEditing && !instructor) {
196
+ return;
197
+ }
198
+
199
+ const instructorQualificationSlugs: string[] = Array.isArray(
200
+ instructor?.qualificationSlugs
201
+ )
202
+ ? instructor.qualificationSlugs
203
+ : [];
204
+
205
+ form.reset({
206
+ nome: instructor?.name || '',
207
+ email: instructor?.email || '',
208
+ telefone: instructor?.phone || '',
209
+ qualificationSlugs:
210
+ instructorQualificationSlugs.length > 0
211
+ ? instructorQualificationSlugs
212
+ : initialQualificationSlugs,
213
+ });
214
+
215
+ const nextAvatarId = instructor?.avatarId ?? null;
216
+ setAvatarId(nextAvatarId);
217
+ setPersistedAvatarId(nextAvatarId);
218
+ setAvatarPreviewUrl(getInstructorAvatarUrl(nextAvatarId));
219
+ setIsUploadingAvatar(false);
220
+ setAvatarUploadProgress(0);
221
+ }, [form, initialQualificationSlugs, instructorDetails, isEditing, open]);
222
+
223
+ const deleteFileById = async (fileId?: number | null) => {
224
+ if (!fileId || fileId <= 0) return;
225
+
226
+ try {
227
+ await request({
228
+ url: '/file',
229
+ method: 'DELETE',
230
+ data: { ids: [fileId] },
231
+ });
232
+ } catch {
233
+ // Ignore cleanup failures to avoid interrupting the sheet flow.
234
+ }
235
+ };
236
+
237
+ const cleanupUnsavedAvatar = async () => {
238
+ if (avatarId && avatarId !== persistedAvatarId) {
239
+ await deleteFileById(avatarId);
240
+ }
241
+ };
242
+
243
+ const handleAvatarUpload = async (file: File) => {
244
+ if (!file.type.startsWith('image/')) {
245
+ toast.error('Selecione um arquivo de imagem valido.');
246
+ return;
247
+ }
248
+
249
+ if (file.size > 5 * 1024 * 1024) {
250
+ toast.error('A imagem deve ter no maximo 5MB.');
251
+ return;
252
+ }
253
+
254
+ setIsUploadingAvatar(true);
255
+ setAvatarUploadProgress(0);
256
+
257
+ try {
258
+ const formData = new FormData();
259
+ formData.append('file', file);
260
+ formData.append('destination', 'contact/person/avatar');
261
+
262
+ const previousAvatarId = avatarId;
263
+
264
+ const { data } = await request<UploadedFilePayload>({
265
+ url: '/file',
266
+ method: 'POST',
267
+ data: formData,
268
+ headers: {
269
+ 'Content-Type': 'multipart/form-data',
270
+ },
271
+ onUploadProgress: (event) => {
272
+ if (!event.total) return;
273
+ setAvatarUploadProgress(
274
+ Math.round((event.loaded * 100) / event.total)
275
+ );
276
+ },
277
+ });
278
+
279
+ const nextAvatarId = Number(data?.id);
280
+ if (!nextAvatarId) {
281
+ throw new Error('invalid avatar id');
282
+ }
283
+
284
+ const openResponse = await request<OpenFilePayload>({
285
+ url: `/file/open/${nextAvatarId}`,
286
+ method: 'PUT',
287
+ });
288
+
289
+ const openUrl = String(openResponse?.data?.url || '').trim();
290
+
291
+ if (previousAvatarId && previousAvatarId !== persistedAvatarId) {
292
+ await deleteFileById(previousAvatarId);
293
+ }
294
+
295
+ setAvatarId(nextAvatarId);
296
+ setAvatarPreviewUrl(
297
+ openUrl.length > 0
298
+ ? /^https?:\/\//i.test(openUrl)
299
+ ? openUrl
300
+ : `${String(process.env.NEXT_PUBLIC_API_BASE_URL || '')}${openUrl}`
301
+ : getInstructorAvatarUrl(nextAvatarId)
302
+ );
303
+ setAvatarUploadProgress(100);
304
+ toast.success('Foto enviada com sucesso.');
305
+ } catch {
306
+ toast.error('Nao foi possivel enviar a foto.');
307
+ setAvatarUploadProgress(0);
308
+ } finally {
309
+ setIsUploadingAvatar(false);
310
+ if (fileInputRef.current) {
311
+ fileInputRef.current.value = '';
312
+ }
313
+ }
314
+ };
315
+
316
+ const handleRemoveAvatar = async () => {
317
+ if (isUploadingAvatar) return;
318
+
319
+ if (avatarId && avatarId !== persistedAvatarId) {
320
+ await deleteFileById(avatarId);
321
+ }
322
+
323
+ setAvatarId(null);
324
+ setAvatarPreviewUrl('/placeholder.png');
325
+ setAvatarUploadProgress(0);
326
+ toast.success('Foto removida com sucesso.');
327
+ };
328
+
329
+ const handleSubmit = form.handleSubmit(async (values) => {
330
+ setSaving(true);
331
+ try {
332
+ const response = await request<CreatedInstructor>({
333
+ url: isEditing
334
+ ? `/lms/instructors/${instructorId}`
335
+ : '/lms/instructors',
336
+ method: isEditing ? 'PATCH' : 'POST',
337
+ data: {
338
+ name: values.nome.trim(),
339
+ email: values.email.trim() || undefined,
340
+ phone: values.telefone.trim() || undefined,
341
+ avatarId,
342
+ qualificationSlugs: values.qualificationSlugs,
343
+ status: 'active',
344
+ },
345
+ });
346
+
347
+ const savedInstructor = response.data;
348
+
349
+ if (!savedInstructor?.id || !savedInstructor?.name) {
350
+ throw new Error('invalid instructor payload');
351
+ }
352
+
353
+ const savedAvatarId =
354
+ typeof savedInstructor.avatarId === 'number'
355
+ ? savedInstructor.avatarId
356
+ : null;
357
+
358
+ form.reset({
359
+ nome: savedInstructor.name || values.nome.trim(),
360
+ email: savedInstructor.email || values.email.trim(),
361
+ telefone: savedInstructor.phone || values.telefone.trim(),
362
+ qualificationSlugs:
363
+ savedInstructor.qualificationSlugs?.length > 0
364
+ ? savedInstructor.qualificationSlugs
365
+ : values.qualificationSlugs,
366
+ });
367
+
368
+ setAvatarId(savedAvatarId);
369
+ setPersistedAvatarId(savedAvatarId);
370
+ setAvatarPreviewUrl(getInstructorAvatarUrl(savedAvatarId));
371
+
372
+ if (!isEditing && onCreated) {
373
+ await onCreated(savedInstructor);
374
+ }
375
+
376
+ if (onSaved) {
377
+ await onSaved(savedInstructor);
378
+ }
379
+
380
+ onOpenChange(false);
381
+ toast.success(successMessage);
382
+ } catch {
383
+ toast.error(errorMessage);
384
+ } finally {
385
+ setSaving(false);
386
+ }
387
+ });
388
+
389
+ const handleSheetOpenChange = (nextOpen: boolean) => {
390
+ if (nextOpen) {
391
+ onOpenChange(true);
392
+ return;
393
+ }
394
+
395
+ if (saving || isUploadingAvatar) {
396
+ return;
397
+ }
398
+
399
+ void cleanupUnsavedAvatar();
400
+ onOpenChange(false);
401
+ };
402
+
403
+ return (
404
+ <Sheet open={open} onOpenChange={handleSheetOpenChange}>
405
+ <SheetContent
406
+ side="right"
407
+ className="flex w-full flex-col sm:max-w-lg overflow-y-auto"
408
+ >
409
+ <SheetHeader>
410
+ <SheetTitle>{title}</SheetTitle>
411
+ <SheetDescription>{description}</SheetDescription>
412
+ </SheetHeader>
413
+
414
+ {isEditing && loadingInstructorDetails ? (
415
+ <div className="flex items-center gap-2 px-4 py-10 text-sm text-muted-foreground">
416
+ <Loader2 className="size-4 animate-spin" />
417
+ Carregando dados do instrutor...
418
+ </div>
419
+ ) : (
420
+ <form onSubmit={handleSubmit} className="mt-6 space-y-4 px-4">
421
+ <Field>
422
+ <FieldLabel>Foto</FieldLabel>
423
+ <FieldDescription>
424
+ JPG, PNG ou GIF. Tamanho maximo de 5MB.
425
+ </FieldDescription>
426
+ <div className="flex items-center gap-4 rounded-lg border p-4">
427
+ <Avatar className="h-22 w-22 rounded-full border">
428
+ <AvatarImage
429
+ src={avatarPreviewUrl}
430
+ alt={form.watch('nome') || 'Instrutor'}
431
+ />
432
+ <AvatarFallback className="rounded-md text-sm font-semibold uppercase">
433
+ {getInstructorInitials(form.watch('nome') || 'Instrutor')}
434
+ </AvatarFallback>
435
+ </Avatar>
436
+
437
+ <div className="flex-1 space-y-2">
438
+ <input
439
+ ref={fileInputRef}
440
+ type="file"
441
+ accept="image/*"
442
+ className="hidden"
443
+ onChange={(event) => {
444
+ const file = event.target.files?.[0];
445
+ if (file) {
446
+ void handleAvatarUpload(file);
447
+ }
448
+ }}
449
+ />
450
+
451
+ <div className="flex flex-wrap gap-2">
452
+ <Button
453
+ type="button"
454
+ variant="outline"
455
+ onClick={() => fileInputRef.current?.click()}
456
+ disabled={isUploadingAvatar}
457
+ >
458
+ {isUploadingAvatar ? (
459
+ <Loader2 className="mr-2 size-4 animate-spin" />
460
+ ) : (
461
+ <Upload className="mr-2 size-4" />
462
+ )}
463
+ {avatarId ? 'Trocar foto' : 'Enviar foto'}
464
+ </Button>
465
+
466
+ {(avatarId || isUploadingAvatar) && (
467
+ <Button
468
+ type="button"
469
+ variant="ghost"
470
+ onClick={() => void handleRemoveAvatar()}
471
+ disabled={!avatarId || isUploadingAvatar}
472
+ >
473
+ <Trash2 className="mr-2 size-4" />
474
+ Remover
475
+ </Button>
476
+ )}
477
+ </div>
478
+
479
+ {isUploadingAvatar && (
480
+ <div className="space-y-1">
481
+ <Progress value={avatarUploadProgress} className="h-2" />
482
+ <p className="text-xs text-muted-foreground">
483
+ Enviando foto... {avatarUploadProgress}%
484
+ </p>
485
+ </div>
486
+ )}
487
+ </div>
488
+ </div>
489
+ </Field>
490
+
491
+ <Field>
492
+ <FieldLabel htmlFor="newInstructorName">Nome</FieldLabel>
493
+ <Input
494
+ id="newInstructorName"
495
+ placeholder="Nome completo"
496
+ {...form.register('nome')}
497
+ />
498
+ <FieldError>{form.formState.errors.nome?.message}</FieldError>
499
+ </Field>
500
+
501
+ <Field>
502
+ <FieldLabel htmlFor="newInstructorEmail">Email</FieldLabel>
503
+ <Input
504
+ id="newInstructorEmail"
505
+ type="email"
506
+ placeholder="nome@exemplo.com"
507
+ {...form.register('email')}
508
+ />
509
+ <FieldError>{form.formState.errors.email?.message}</FieldError>
510
+ </Field>
511
+
512
+ <Field>
513
+ <FieldLabel htmlFor="newInstructorPhone">Telefone</FieldLabel>
514
+ <Input
515
+ id="newInstructorPhone"
516
+ placeholder="+55 (11) 90000-0000"
517
+ {...form.register('telefone')}
518
+ />
519
+ <FieldError>{form.formState.errors.telefone?.message}</FieldError>
520
+ </Field>
521
+
522
+ <Field>
523
+ <FieldLabel>Atuacoes</FieldLabel>
524
+ <FieldDescription>
525
+ Selecione em quais contextos esse instrutor pode atuar.
526
+ </FieldDescription>
527
+ <Controller
528
+ control={form.control}
529
+ name="qualificationSlugs"
530
+ render={({ field }) => (
531
+ <div className="space-y-2 rounded-lg border p-3">
532
+ {QUALIFICATION_OPTIONS.map((option) => {
533
+ const checked = field.value.includes(option.slug);
534
+
535
+ return (
536
+ <label
537
+ key={option.slug}
538
+ className="flex items-start justify-between gap-3 rounded-md border px-3 py-2"
539
+ >
540
+ <div className="space-y-1">
541
+ <p className="text-sm font-medium">
542
+ {option.label}
543
+ </p>
544
+ <p className="text-xs text-muted-foreground">
545
+ {option.description}
546
+ </p>
547
+ </div>
548
+ <Switch
549
+ checked={checked}
550
+ onCheckedChange={(nextChecked) => {
551
+ if (nextChecked) {
552
+ field.onChange([...field.value, option.slug]);
553
+ return;
554
+ }
555
+
556
+ field.onChange(
557
+ field.value.filter(
558
+ (value) => value !== option.slug
559
+ )
560
+ );
561
+ }}
562
+ />
563
+ </label>
564
+ );
565
+ })}
566
+ </div>
567
+ )}
568
+ />
569
+ <FieldError>
570
+ {form.formState.errors.qualificationSlugs?.message}
571
+ </FieldError>
572
+ </Field>
573
+
574
+ <SheetFooter className="mt-6 px-0">
575
+ <Button
576
+ type="submit"
577
+ disabled={saving || isUploadingAvatar}
578
+ className="gap-2"
579
+ >
580
+ {(saving || isUploadingAvatar) && (
581
+ <Loader2 className="mr-2 size-4 animate-spin" />
582
+ )}
583
+ {submitLabel}
584
+ </Button>
585
+ </SheetFooter>
586
+ </form>
587
+ )}
588
+ </SheetContent>
589
+ </Sheet>
590
+ );
591
+ }
@@ -43,6 +43,7 @@ import {
43
43
  import { useLocale, useTranslations } from 'next-intl';
44
44
  import { useRouter } from 'next/navigation';
45
45
  import { useEffect, useMemo, useState } from 'react';
46
+ import { usePersistedPageSize } from '@/hooks/use-persisted-page-size';
46
47
  import { toast } from 'sonner';
47
48
 
48
49
  type IssuedCertificate = {
@@ -103,7 +104,11 @@ export default function IssuedCertificatesPage() {
103
104
  const [searchQuery, setSearchQuery] = useState('');
104
105
  const [typeFilter, setTypeFilter] = useState('all');
105
106
  const [currentPage, setCurrentPage] = useState(1);
106
- const [pageSize, setPageSize] = useState(12);
107
+ const [pageSize, setPageSize] = usePersistedPageSize({
108
+ storageKey: 'pagination:global:pageSize',
109
+ defaultValue: 12,
110
+ allowedValues: PAGE_SIZES,
111
+ });
107
112
  const [isTemplateSheetOpen, setIsTemplateSheetOpen] = useState(false);
108
113
  const [isCreatingTemplate, setIsCreatingTemplate] = useState(false);
109
114
  const [templateName, setTemplateName] = useState('');
@@ -62,7 +62,8 @@ import {
62
62
  } from 'lucide-react';
63
63
  import { useLocale, useTranslations } from 'next-intl';
64
64
  import { useRouter } from 'next/navigation';
65
- import { useEffect, useMemo, useState } from 'react';
65
+ import { useEffect, useMemo, useRef, useState } from 'react';
66
+ import { usePersistedPageSize } from '@/hooks/use-persisted-page-size';
66
67
  import { Controller, useForm } from 'react-hook-form';
67
68
  import { toast } from 'sonner';
68
69
  import { z } from 'zod';
@@ -146,7 +147,11 @@ export default function ModelsPage() {
146
147
  const [searchQuery, setSearchQuery] = useState('');
147
148
  const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
148
149
  const [currentPage, setCurrentPage] = useState(1);
149
- const [pageSize, setPageSize] = useState(12);
150
+ const [pageSize, setPageSize] = usePersistedPageSize({
151
+ storageKey: 'pagination:global:pageSize',
152
+ defaultValue: 12,
153
+ allowedValues: PAGE_SIZES,
154
+ });
150
155
  const [isSheetOpen, setIsSheetOpen] = useState(false);
151
156
  const [isEditSheetOpen, setIsEditSheetOpen] = useState(false);
152
157
  const [isCreating, setIsCreating] = useState(false);
@@ -161,6 +166,11 @@ export default function ModelsPage() {
161
166
  null
162
167
  );
163
168
 
169
+ // Double-click tracking
170
+ const clickTimers = useRef<Map<number, ReturnType<typeof setTimeout>>>(
171
+ new Map()
172
+ );
173
+
164
174
  const form = useForm<CreateTemplateFormValues>({
165
175
  resolver: zodResolver(createTemplateSchema(t)),
166
176
  defaultValues: {
@@ -464,6 +474,23 @@ export default function ModelsPage() {
464
474
  }
465
475
  }
466
476
 
477
+ // ── Double-click to navigate ──────────────────────────────────────────────
478
+
479
+ function handleCardClick(template: CertificateTemplate) {
480
+ const existing = clickTimers.current.get(template.id);
481
+ if (existing) {
482
+ clearTimeout(existing);
483
+ clickTimers.current.delete(template.id);
484
+ router.push(`/lms/certificates/models/editor?templateId=${template.id}`);
485
+ } else {
486
+ const timer = setTimeout(
487
+ () => clickTimers.current.delete(template.id),
488
+ 300
489
+ );
490
+ clickTimers.current.set(template.id, timer);
491
+ }
492
+ }
493
+
467
494
  const { locales } = useApp();
468
495
 
469
496
  const handleNewModel = (): void => {
@@ -587,13 +614,17 @@ export default function ModelsPage() {
587
614
  {templates.map((template) => (
588
615
  <Card
589
616
  key={template.id}
590
- className="overflow-hidden border-border/70 py-0 shadow-sm transition-shadow duration-300 hover:shadow-md"
617
+ className="cursor-pointer overflow-hidden border-border/70 py-0 shadow-sm transition-shadow duration-300 hover:shadow-md"
618
+ onClick={() => handleCardClick(template)}
591
619
  >
592
620
  <div className="h-1 w-full bg-linear-to-r from-sky-400/70 via-cyan-300/50 to-transparent" />
593
621
  <CardContent className="space-y-4 p-5">
594
622
  <div className="flex items-start justify-between gap-2">
595
623
  {renderStatusBadge(template.status)}
596
- <div className="flex items-center gap-1">
624
+ <div
625
+ className="flex items-center gap-1"
626
+ onClick={(e) => e.stopPropagation()}
627
+ >
597
628
  <DropdownMenu>
598
629
  <DropdownMenuTrigger asChild>
599
630
  <Button
@@ -663,11 +694,12 @@ export default function ModelsPage() {
663
694
  variant="outline"
664
695
  size="sm"
665
696
  className="gap-2 w-full"
666
- onClick={() =>
697
+ onClick={(e) => {
698
+ e.stopPropagation();
667
699
  router.push(
668
700
  `/lms/certificates/models/editor?templateId=${template.id}`
669
- )
670
- }
701
+ );
702
+ }}
671
703
  >
672
704
  <FileEdit className="size-4" />
673
705
  {t('cards.actions.editTemplate')}