@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
@@ -0,0 +1,2339 @@
1
+ 'use client';
2
+
3
+ import {
4
+ CourseCategoryOption,
5
+ CourseFormSheet,
6
+ CourseSheetFormValues,
7
+ DEFAULT_COURSE_FORM_VALUES,
8
+ getCourseSheetSchema,
9
+ } from '@/app/(app)/(libraries)/lms/_components/course-form-sheet';
10
+ import {
11
+ EmptyState,
12
+ Page,
13
+ PageHeader,
14
+ PaginationFooter,
15
+ SearchBar,
16
+ ViewModeToggle,
17
+ } from '@/components/entity-list';
18
+ import { Badge } from '@/components/ui/badge';
19
+ import { Button } from '@/components/ui/button';
20
+ import { Card, CardContent } from '@/components/ui/card';
21
+ import {
22
+ Dialog,
23
+ DialogContent,
24
+ DialogDescription,
25
+ DialogFooter,
26
+ DialogHeader,
27
+ DialogTitle,
28
+ } from '@/components/ui/dialog';
29
+ import {
30
+ DropdownMenu,
31
+ DropdownMenuContent,
32
+ DropdownMenuItem,
33
+ DropdownMenuSeparator,
34
+ DropdownMenuTrigger,
35
+ } from '@/components/ui/dropdown-menu';
36
+ import { Field, FieldError, FieldLabel } from '@/components/ui/field';
37
+ import { Input } from '@/components/ui/input';
38
+ import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
39
+ import {
40
+ Select,
41
+ SelectContent,
42
+ SelectItem,
43
+ SelectTrigger,
44
+ SelectValue,
45
+ } from '@/components/ui/select';
46
+ import { Separator } from '@/components/ui/separator';
47
+ import {
48
+ Sheet,
49
+ SheetContent,
50
+ SheetDescription,
51
+ SheetFooter,
52
+ SheetHeader,
53
+ SheetTitle,
54
+ } from '@/components/ui/sheet';
55
+ import { Skeleton } from '@/components/ui/skeleton';
56
+ import { Switch } from '@/components/ui/switch';
57
+ import {
58
+ Table,
59
+ TableBody,
60
+ TableCell,
61
+ TableHead,
62
+ TableHeader,
63
+ TableRow,
64
+ } from '@/components/ui/table';
65
+ import { Textarea } from '@/components/ui/textarea';
66
+ import { usePersistedViewMode } from '@/hooks/use-persisted-view-mode';
67
+ import {
68
+ DndContext,
69
+ DragEndEvent,
70
+ KeyboardSensor,
71
+ PointerSensor,
72
+ closestCenter,
73
+ useSensor,
74
+ useSensors,
75
+ } from '@dnd-kit/core';
76
+ import {
77
+ SortableContext,
78
+ arrayMove,
79
+ sortableKeyboardCoordinates,
80
+ useSortable,
81
+ verticalListSortingStrategy,
82
+ } from '@dnd-kit/sortable';
83
+ import { CSS } from '@dnd-kit/utilities';
84
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
85
+ import { zodResolver } from '@hookform/resolvers/zod';
86
+ import { motion } from 'framer-motion';
87
+ import {
88
+ AlertTriangle,
89
+ Clock,
90
+ GraduationCap,
91
+ GripVertical,
92
+ Layers,
93
+ Loader2,
94
+ MoreHorizontal,
95
+ Pencil,
96
+ Plus,
97
+ Target,
98
+ Trash2,
99
+ Users,
100
+ X,
101
+ } from 'lucide-react';
102
+ import { useTranslations } from 'next-intl';
103
+ import { useRouter } from 'next/navigation';
104
+ import { useEffect, useMemo, useRef, useState } from 'react';
105
+ import { Controller, useForm, useWatch } from 'react-hook-form';
106
+ import { toast } from 'sonner';
107
+ import { z } from 'zod';
108
+
109
+ // ── Types ─────────────────────────────────────────────────────────────────────
110
+
111
+ interface Formacao {
112
+ id: number;
113
+ nome: string;
114
+ descricao: string;
115
+ area: string;
116
+ nivel: string;
117
+ prerequisitos: string;
118
+ cursos: string[];
119
+ exams?: string[];
120
+ courseIds: number[];
121
+ examIds?: number[];
122
+ items?: LearningPathItem[];
123
+ cargaTotal: number;
124
+ alunos: number;
125
+ status: 'ativa' | 'rascunho' | 'encerrada';
126
+ criadoEm: string;
127
+ primaryColor?: string;
128
+ secondaryColor?: string;
129
+ }
130
+
131
+ type TrainingColorPayload = {
132
+ primaryColor?: string | null;
133
+ secondaryColor?: string | null;
134
+ primary_color?: string | null;
135
+ secondary_color?: string | null;
136
+ };
137
+
138
+ interface CursoOption {
139
+ id: number;
140
+ nome: string;
141
+ cargaHoraria: number;
142
+ categories: string[];
143
+ area: string;
144
+ }
145
+
146
+ interface ExameOption {
147
+ id: number;
148
+ titulo: string;
149
+ limiteTempo: number;
150
+ status: 'publicado' | 'rascunho' | 'encerrado';
151
+ }
152
+
153
+ interface LearningPathItem {
154
+ id?: number;
155
+ type: 'course' | 'exam';
156
+ itemId: number;
157
+ order: number;
158
+ isRequired?: boolean;
159
+ }
160
+
161
+ interface TrailRenderableItem {
162
+ uid: string;
163
+ type: 'course' | 'exam';
164
+ itemId: number;
165
+ title: string;
166
+ subtitle: string;
167
+ order: number;
168
+ }
169
+
170
+ type ApiTrainingListResponse = {
171
+ total: number;
172
+ page: number;
173
+ pageSize: number;
174
+ lastPage: number;
175
+ data: Formacao[];
176
+ };
177
+
178
+ type ApiTrainingStatsResponse = {
179
+ totalTraining: number;
180
+ activeTraining: number;
181
+ enrolledStudents: number;
182
+ coveredCourses: number;
183
+ };
184
+
185
+ type ApiCourseListResponse = {
186
+ data: Array<{
187
+ id: number;
188
+ title: string;
189
+ durationHours: number;
190
+ categories?: string[];
191
+ }>;
192
+ };
193
+
194
+ type ApiExamListResponse = {
195
+ data: Array<{
196
+ id: number;
197
+ title: string;
198
+ timeLimit: number;
199
+ status: 'published' | 'draft' | 'closed' | 'archived';
200
+ }>;
201
+ };
202
+
203
+ type ApiCategory = {
204
+ id: number;
205
+ slug: string;
206
+ name: string;
207
+ status?: 'active' | 'inactive';
208
+ };
209
+
210
+ type ApiCategoryList = {
211
+ data: ApiCategory[];
212
+ total: number;
213
+ page: number;
214
+ pageSize: number;
215
+ };
216
+
217
+ type ViewMode = 'cards' | 'list';
218
+
219
+ type Locale = {
220
+ id?: number;
221
+ code: string;
222
+ name: string;
223
+ };
224
+
225
+ const createExamQuickSchema = z.object({
226
+ titulo: z.string().min(3, 'Minimo 3 caracteres'),
227
+ notaMinima: z.coerce.number().min(0).max(10),
228
+ limiteTempo: z.coerce.number().min(1),
229
+ shuffle: z.boolean().default(false),
230
+ status: z.enum(['rascunho', 'publicado', 'encerrado']),
231
+ });
232
+
233
+ type ExamQuickForm = z.infer<typeof createExamQuickSchema>;
234
+
235
+ function normalizeText(value: string) {
236
+ return value
237
+ .trim()
238
+ .toLowerCase()
239
+ .normalize('NFD')
240
+ .replace(/[\u0300-\u036f]/g, '');
241
+ }
242
+
243
+ function categorySlugToArea(slugs: string[] = []) {
244
+ const normalized = slugs.map((slug) => normalizeText(slug));
245
+
246
+ if (
247
+ normalized.some(
248
+ (slug) =>
249
+ slug === 'design' ||
250
+ slug.includes('design') ||
251
+ slug.includes('ux') ||
252
+ slug.includes('ui')
253
+ )
254
+ ) {
255
+ return 'Design';
256
+ }
257
+ if (
258
+ normalized.some(
259
+ (slug) =>
260
+ slug === 'gestao' ||
261
+ slug === 'management' ||
262
+ slug.includes('gestao') ||
263
+ slug.includes('management')
264
+ )
265
+ ) {
266
+ return 'Gestao';
267
+ }
268
+ if (
269
+ normalized.some(
270
+ (slug) => slug === 'marketing' || slug.includes('marketing')
271
+ )
272
+ ) {
273
+ return 'Marketing';
274
+ }
275
+ if (
276
+ normalized.some(
277
+ (slug) =>
278
+ slug === 'financas' ||
279
+ slug === 'finance' ||
280
+ slug.includes('financ') ||
281
+ slug.includes('accounting')
282
+ )
283
+ ) {
284
+ return 'Financas';
285
+ }
286
+
287
+ return 'Tecnologia';
288
+ }
289
+
290
+ function normalizeAreaValue(value: string) {
291
+ const normalized = normalizeText(value);
292
+
293
+ if (
294
+ normalized === 'tecnologia' ||
295
+ normalized === 'technology' ||
296
+ normalized.includes('tecnolog') ||
297
+ normalized.includes('technology')
298
+ ) {
299
+ return 'Tecnologia';
300
+ }
301
+ if (normalized === 'design' || normalized.includes('design')) return 'Design';
302
+ if (
303
+ normalized === 'gestao' ||
304
+ normalized === 'management' ||
305
+ normalized.includes('gestao') ||
306
+ normalized.includes('management')
307
+ ) {
308
+ return 'Gestao';
309
+ }
310
+ if (normalized === 'marketing' || normalized.includes('marketing')) {
311
+ return 'Marketing';
312
+ }
313
+ if (
314
+ normalized === 'financas' ||
315
+ normalized === 'finance' ||
316
+ normalized.includes('financ') ||
317
+ normalized.includes('accounting')
318
+ ) {
319
+ return 'Financas';
320
+ }
321
+
322
+ return 'Tecnologia';
323
+ }
324
+
325
+ function normalizeLevelValue(value: string) {
326
+ const normalized = normalizeText(value);
327
+
328
+ if (['iniciante', 'beginner'].includes(normalized)) return 'Iniciante';
329
+ if (['intermediario', 'intermediate'].includes(normalized)) {
330
+ return 'Intermediario';
331
+ }
332
+ if (['avancado', 'advanced'].includes(normalized)) return 'Avancado';
333
+
334
+ return 'Iniciante';
335
+ }
336
+
337
+ function normalizeStatusValue(value: string) {
338
+ const normalized = normalizeText(value);
339
+
340
+ if (['ativa', 'active'].includes(normalized)) return 'ativa';
341
+ if (['encerrada', 'archived'].includes(normalized)) return 'encerrada';
342
+
343
+ return 'rascunho';
344
+ }
345
+
346
+ function slugifyText(value: string) {
347
+ return normalizeText(value)
348
+ .replace(/[^a-z0-9\s-]/g, '')
349
+ .replace(/\s+/g, '-')
350
+ .replace(/-+/g, '-')
351
+ .replace(/^-|-$/g, '')
352
+ .slice(0, 64);
353
+ }
354
+
355
+ function normalizeHexColor(value?: string | null) {
356
+ if (!value) return null;
357
+
358
+ const raw = value.trim();
359
+ if (!raw) return null;
360
+
361
+ const prefixed = raw.startsWith('#') ? raw : `#${raw}`;
362
+
363
+ if (/^#([0-9A-Fa-f]{6})$/.test(prefixed)) return prefixed;
364
+ if (/^#([0-9A-Fa-f]{3})$/.test(prefixed)) {
365
+ const hex = prefixed.slice(1);
366
+
367
+ return `#${hex
368
+ .split('')
369
+ .map((char) => `${char}${char}`)
370
+ .join('')}`;
371
+ }
372
+
373
+ return null;
374
+ }
375
+
376
+ function normalizeTrainingColorPayload<T extends TrainingColorPayload>(
377
+ payload: T
378
+ ) {
379
+ const primaryColor = normalizeHexColor(
380
+ payload.primaryColor ?? payload.primary_color
381
+ );
382
+ const secondaryColor = normalizeHexColor(
383
+ payload.secondaryColor ?? payload.secondary_color
384
+ );
385
+
386
+ return {
387
+ ...payload,
388
+ primaryColor: primaryColor ?? undefined,
389
+ secondaryColor: secondaryColor ?? undefined,
390
+ };
391
+ }
392
+
393
+ function normalizeTrainingListPayload(
394
+ payload: ApiTrainingListResponse
395
+ ): ApiTrainingListResponse {
396
+ return {
397
+ ...payload,
398
+ data: (payload.data ?? []).map((item) =>
399
+ normalizeTrainingColorPayload(item as Formacao & TrainingColorPayload)
400
+ ),
401
+ };
402
+ }
403
+
404
+ function buildCourseCodeFromTitle(title: string) {
405
+ const base = slugifyText(title)
406
+ .toUpperCase()
407
+ .replace(/[^A-Z0-9]/g, '-');
408
+ const compact = base.replace(/-+/g, '-').replace(/^-|-$/g, '');
409
+
410
+ if (compact.length >= 2) return compact.slice(0, 32);
411
+
412
+ return `COURSE-${Date.now().toString().slice(-6)}`;
413
+ }
414
+
415
+ // ── Schema ────────────────────────────────────────────────────────────────────
416
+
417
+ const formacaoSchema = z.object({
418
+ nome: z.string().min(3, 'Minimo 3 caracteres'),
419
+ descricao: z.string().min(10, 'Minimo 10 caracteres'),
420
+ area: z.enum(['Tecnologia', 'Design', 'Gestao', 'Marketing', 'Financas']),
421
+ nivel: z.enum(['Iniciante', 'Intermediario', 'Avancado']),
422
+ prerequisitos: z.string().optional(),
423
+ status: z.enum(['rascunho', 'ativa', 'encerrada']),
424
+ primaryColor: z
425
+ .string()
426
+ .regex(/^#([0-9A-Fa-f]{6})$/, 'Cor primária inválida')
427
+ .default('#1D4ED8'),
428
+ secondaryColor: z
429
+ .string()
430
+ .regex(/^#([0-9A-Fa-f]{6})$/, 'Cor secundária inválida')
431
+ .default('#111827'),
432
+ });
433
+
434
+ type FormacaoForm = z.infer<typeof formacaoSchema>;
435
+
436
+ // ── Constants ─────────────────────────────────────────────────────────────────
437
+
438
+ const STATUS_MAP: Record<
439
+ string,
440
+ { label: string; variant: 'default' | 'secondary' | 'outline' }
441
+ > = {
442
+ ativa: { label: 'Ativa', variant: 'default' },
443
+ rascunho: { label: 'Rascunho', variant: 'secondary' },
444
+ encerrada: { label: 'Encerrada', variant: 'outline' },
445
+ };
446
+
447
+ const PAGE_SIZES = [6, 12, 24];
448
+ const API_TRAINING_CACHE_KEY = 'lms:training:api-cache';
449
+
450
+ // ── Animations ────────────────────────────────────────────────────────────────
451
+
452
+ const fadeUp = {
453
+ hidden: { opacity: 0, y: 16 },
454
+ show: {
455
+ opacity: 1,
456
+ y: 0,
457
+ transition: { duration: 0.3 },
458
+ },
459
+ };
460
+ const stagger = {
461
+ hidden: {},
462
+ show: { transition: { staggerChildren: 0.05 } },
463
+ };
464
+
465
+ function SortableTrailItem(props: {
466
+ item: TrailRenderableItem;
467
+ onRemove: (uid: string) => void;
468
+ }) {
469
+ const { item, onRemove } = props;
470
+ const { attributes, listeners, setNodeRef, transform, transition } =
471
+ useSortable({ id: item.uid });
472
+
473
+ const style = {
474
+ transform: CSS.Transform.toString(transform),
475
+ transition,
476
+ };
477
+
478
+ return (
479
+ <div
480
+ ref={setNodeRef}
481
+ style={style}
482
+ className="flex items-center gap-2 border-b p-2.5 last:border-0"
483
+ >
484
+ <Button
485
+ type="button"
486
+ variant="ghost"
487
+ size="icon"
488
+ className="size-7 text-muted-foreground"
489
+ aria-label="Arrastar item"
490
+ {...attributes}
491
+ {...listeners}
492
+ >
493
+ <GripVertical className="size-4" />
494
+ </Button>
495
+
496
+ <div className="min-w-0 flex-1">
497
+ <p className="truncate text-sm font-medium">{item.title}</p>
498
+ <p className="truncate text-xs text-muted-foreground">
499
+ {item.subtitle}
500
+ </p>
501
+ </div>
502
+
503
+ <Badge variant="outline" className="text-[10px] uppercase tracking-wide">
504
+ {item.type === 'course' ? 'Curso' : 'Exame'}
505
+ </Badge>
506
+
507
+ <Button
508
+ type="button"
509
+ variant="ghost"
510
+ size="sm"
511
+ onClick={() => onRemove(item.uid)}
512
+ className="h-7 px-2 text-muted-foreground"
513
+ >
514
+ <X className="size-3.5" />
515
+ </Button>
516
+ </div>
517
+ );
518
+ }
519
+
520
+ // ── Page ──────────────────────────────────────────────────────────────────────
521
+
522
+ export default function TrainingPage() {
523
+ const t = useTranslations('lms.TrainingPage');
524
+ const tCourse = useTranslations('lms.CoursesPage');
525
+ const tExam = useTranslations('lms.ExamsPage');
526
+ const router = useRouter();
527
+ const { request } = useApp();
528
+
529
+ const [sheetOpen, setSheetOpen] = useState(false);
530
+ const [editingFormacao, setEditingFormacao] = useState<Formacao | null>(null);
531
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
532
+ const [formacaoToDelete, setFormacaoToDelete] = useState<Formacao | null>(
533
+ null
534
+ );
535
+ const [learningPathItems, setLearningPathItems] = useState<
536
+ LearningPathItem[]
537
+ >([]);
538
+ const [selectedCourseToAdd, setSelectedCourseToAdd] = useState('');
539
+ const [selectedExamToAdd, setSelectedExamToAdd] = useState('');
540
+ const [saving, setSaving] = useState(false);
541
+ const [loadingEditSheet, setLoadingEditSheet] = useState(false);
542
+ const [courseSheetOpen, setCourseSheetOpen] = useState(false);
543
+ const [examSheetOpen, setExamSheetOpen] = useState(false);
544
+ const [creatingCourse, setCreatingCourse] = useState(false);
545
+ const [creatingExam, setCreatingExam] = useState(false);
546
+ const [cachedListData, setCachedListData] =
547
+ useState<ApiTrainingListResponse | null>(null);
548
+ const initialLearningPathRef = useRef<LearningPathItem[]>([]);
549
+
550
+ // Search/filter inputs
551
+ const [buscaInput, setBuscaInput] = useState('');
552
+ const [buscaDebounced, setBuscaDebounced] = useState('');
553
+ const [filtroStatusInput, setFiltroStatusInput] = useState('todos');
554
+ const [filtroNivelInput, setFiltroNivelInput] = useState('todos');
555
+ const [viewMode, setViewMode] = usePersistedViewMode<ViewMode>({
556
+ storageKey: 'lms:training:view-mode',
557
+ defaultValue: 'cards',
558
+ allowedValues: ['cards', 'list'],
559
+ });
560
+
561
+ // Pagination
562
+ const [currentPage, setCurrentPage] = useState(1);
563
+ const [pageSize, setPageSize] = useState(12);
564
+
565
+ // Double-click tracking
566
+ const clickTimers = useRef<Map<number, ReturnType<typeof setTimeout>>>(
567
+ new Map()
568
+ );
569
+
570
+ const sensors = useSensors(
571
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
572
+ useSensor(KeyboardSensor, {
573
+ coordinateGetter: sortableKeyboardCoordinates,
574
+ })
575
+ );
576
+
577
+ const {
578
+ data: listData,
579
+ isLoading: isLoadingList,
580
+ isFetching: isFetchingList,
581
+ refetch: refetchTraining,
582
+ } = useQuery<ApiTrainingListResponse>({
583
+ queryKey: [
584
+ 'lms-training-list',
585
+ currentPage,
586
+ pageSize,
587
+ buscaDebounced,
588
+ filtroStatusInput,
589
+ filtroNivelInput,
590
+ ],
591
+ queryFn: async () => {
592
+ const response = await request<ApiTrainingListResponse>({
593
+ url: '/lms/training',
594
+ method: 'GET',
595
+ params: {
596
+ page: currentPage,
597
+ pageSize,
598
+ search: buscaDebounced || undefined,
599
+ status:
600
+ filtroStatusInput !== 'todos'
601
+ ? ptStatusToApi(filtroStatusInput as Formacao['status'])
602
+ : undefined,
603
+ level: filtroNivelInput !== 'todos' ? filtroNivelInput : undefined,
604
+ },
605
+ });
606
+
607
+ return normalizeTrainingListPayload(response.data);
608
+ },
609
+ });
610
+
611
+ const { data: statsData, refetch: refetchStats } =
612
+ useQuery<ApiTrainingStatsResponse>({
613
+ queryKey: ['lms-training-stats'],
614
+ queryFn: async () => {
615
+ const response = await request<ApiTrainingStatsResponse>({
616
+ url: '/lms/training/stats',
617
+ method: 'GET',
618
+ });
619
+
620
+ return response.data;
621
+ },
622
+ });
623
+
624
+ const {
625
+ data: coursesData,
626
+ refetch: refetchCourses,
627
+ isFetching: isFetchingCourses,
628
+ } = useQuery<ApiCourseListResponse>({
629
+ queryKey: ['lms-training-course-options'],
630
+ queryFn: async () => {
631
+ const response = await request<ApiCourseListResponse>({
632
+ url: '/lms/courses',
633
+ method: 'GET',
634
+ params: {
635
+ page: 1,
636
+ pageSize: 1000,
637
+ },
638
+ });
639
+
640
+ return response.data;
641
+ },
642
+ });
643
+
644
+ const {
645
+ data: examsData,
646
+ refetch: refetchExams,
647
+ isFetching: isFetchingExams,
648
+ } = useQuery<ApiExamListResponse>({
649
+ queryKey: ['lms-training-exam-options'],
650
+ queryFn: async () => {
651
+ const response = await request<ApiExamListResponse>({
652
+ url: '/lms/exams',
653
+ method: 'GET',
654
+ params: {
655
+ page: 1,
656
+ pageSize: 1000,
657
+ },
658
+ });
659
+
660
+ return response.data;
661
+ },
662
+ });
663
+
664
+ const { data: categoryListData, refetch: refetchCategories } =
665
+ useQuery<ApiCategoryList>({
666
+ queryKey: ['lms-training-category-options'],
667
+ queryFn: async () => {
668
+ const response = await request<ApiCategoryList>({
669
+ url: '/category',
670
+ method: 'GET',
671
+ params: {
672
+ page: 1,
673
+ pageSize: 500,
674
+ status: 'all',
675
+ },
676
+ });
677
+
678
+ const payload = response.data as ApiCategoryList | ApiCategory[];
679
+ if (Array.isArray(payload)) {
680
+ return {
681
+ data: payload,
682
+ total: payload.length,
683
+ page: 1,
684
+ pageSize: payload.length,
685
+ };
686
+ }
687
+
688
+ return payload;
689
+ },
690
+ initialData: {
691
+ data: [],
692
+ total: 0,
693
+ page: 1,
694
+ pageSize: 500,
695
+ },
696
+ });
697
+
698
+ useEffect(() => {
699
+ if (courseSheetOpen) {
700
+ void refetchCategories();
701
+ }
702
+ }, [courseSheetOpen, refetchCategories]);
703
+
704
+ const availableCursos: CursoOption[] = (coursesData?.data ?? []).map(
705
+ (course) => ({
706
+ id: course.id,
707
+ nome: course.title,
708
+ cargaHoraria: course.durationHours ?? 0,
709
+ categories: course.categories ?? [],
710
+ area: categorySlugToArea(course.categories ?? []),
711
+ })
712
+ );
713
+
714
+ const form = useForm<FormacaoForm>({
715
+ resolver: zodResolver(formacaoSchema),
716
+ defaultValues: {
717
+ nome: '',
718
+ descricao: '',
719
+ area: 'Tecnologia',
720
+ nivel: 'Iniciante',
721
+ prerequisitos: '',
722
+ status: 'rascunho',
723
+ primaryColor: '#1D4ED8',
724
+ secondaryColor: '#111827',
725
+ },
726
+ });
727
+
728
+ const courseForm = useForm<CourseSheetFormValues>({
729
+ resolver: zodResolver(getCourseSheetSchema(tCourse)),
730
+ defaultValues: DEFAULT_COURSE_FORM_VALUES,
731
+ });
732
+ const examForm = useForm<ExamQuickForm>({
733
+ resolver: zodResolver(createExamQuickSchema),
734
+ defaultValues: {
735
+ titulo: '',
736
+ notaMinima: 7,
737
+ limiteTempo: 60,
738
+ shuffle: false,
739
+ status: 'rascunho',
740
+ },
741
+ });
742
+ const watchedFormValues = useWatch({ control: form.control });
743
+
744
+ const availableExams: ExameOption[] = (examsData?.data ?? []).map((exam) => ({
745
+ id: exam.id,
746
+ titulo: exam.title,
747
+ limiteTempo: exam.timeLimit ?? 0,
748
+ status:
749
+ exam.status === 'published'
750
+ ? 'publicado'
751
+ : exam.status === 'draft'
752
+ ? 'rascunho'
753
+ : 'encerrado',
754
+ }));
755
+
756
+ const selectedCursos = useMemo(
757
+ () =>
758
+ learningPathItems
759
+ .filter((item) => item.type === 'course')
760
+ .map((item) => item.itemId),
761
+ [learningPathItems]
762
+ );
763
+
764
+ const trailItems = useMemo<TrailRenderableItem[]>(() => {
765
+ const sorted = [...learningPathItems].sort((a, b) => a.order - b.order);
766
+
767
+ return sorted
768
+ .map((item, index) => {
769
+ if (item.type === 'course') {
770
+ const course = availableCursos.find((c) => c.id === item.itemId);
771
+ if (!course) return null;
772
+
773
+ return {
774
+ uid: `course-${course.id}`,
775
+ type: 'course' as const,
776
+ itemId: course.id,
777
+ title: course.nome,
778
+ subtitle: `${course.cargaHoraria}h`,
779
+ order: item.order ?? index,
780
+ };
781
+ }
782
+
783
+ const exam = availableExams.find((e) => e.id === item.itemId);
784
+ if (!exam) return null;
785
+
786
+ return {
787
+ uid: `exam-${exam.id}`,
788
+ type: 'exam' as const,
789
+ itemId: exam.id,
790
+ title: exam.titulo,
791
+ subtitle: `${exam.limiteTempo}min`,
792
+ order: item.order ?? index,
793
+ };
794
+ })
795
+ .filter(Boolean) as TrailRenderableItem[];
796
+ }, [availableCursos, availableExams, learningPathItems]);
797
+
798
+ const selectableCursos = useMemo(
799
+ () => [...availableCursos].sort((a, b) => a.nome.localeCompare(b.nome)),
800
+ [availableCursos]
801
+ );
802
+
803
+ const selectableExams = useMemo(
804
+ () => [...availableExams].sort((a, b) => a.titulo.localeCompare(b.titulo)),
805
+ [availableExams]
806
+ );
807
+
808
+ const categoryOptions = useMemo<CourseCategoryOption[]>(
809
+ () =>
810
+ (categoryListData?.data ?? [])
811
+ .filter((category) => !!category.slug)
812
+ .map((category) => ({
813
+ value: category.slug,
814
+ label: category.name || category.slug,
815
+ }))
816
+ .sort((a, b) => a.label.localeCompare(b.label)),
817
+ [categoryListData]
818
+ );
819
+
820
+ useEffect(() => {
821
+ if (typeof window === 'undefined') return;
822
+ try {
823
+ const raw = window.localStorage.getItem(API_TRAINING_CACHE_KEY);
824
+ if (!raw) return;
825
+ const parsed = JSON.parse(raw) as ApiTrainingListResponse;
826
+ if (parsed && Array.isArray(parsed.data)) {
827
+ setCachedListData(normalizeTrainingListPayload(parsed));
828
+ }
829
+ } catch {
830
+ setCachedListData(null);
831
+ }
832
+ }, []);
833
+
834
+ useEffect(() => {
835
+ if (typeof window === 'undefined') return;
836
+ if (!listData) return;
837
+ window.localStorage.setItem(
838
+ API_TRAINING_CACHE_KEY,
839
+ JSON.stringify(listData)
840
+ );
841
+ setCachedListData(listData);
842
+ }, [listData]);
843
+
844
+ const effectiveListData = listData ?? cachedListData;
845
+ const initialLoading = isLoadingList && !effectiveListData;
846
+ const cardsRefreshing = isFetchingList && !!effectiveListData;
847
+
848
+ const formacoesToRender = useMemo(() => {
849
+ const formacoes = effectiveListData?.data ?? [];
850
+
851
+ if (!sheetOpen || !editingFormacao) return formacoes;
852
+
853
+ const selectedCourses = availableCursos.filter((course) =>
854
+ selectedCursos.includes(course.id)
855
+ );
856
+
857
+ const selectedCourseNames = selectedCourses.map((course) => course.nome);
858
+ const selectedCourseHours = selectedCourses.reduce(
859
+ (sum, course) => sum + course.cargaHoraria,
860
+ 0
861
+ );
862
+
863
+ return formacoes.map((formacao) => {
864
+ if (formacao.id !== editingFormacao.id) return formacao;
865
+
866
+ return {
867
+ ...formacao,
868
+ nome: watchedFormValues.nome ?? formacao.nome,
869
+ descricao: watchedFormValues.descricao ?? formacao.descricao,
870
+ nivel: watchedFormValues.nivel ?? formacao.nivel,
871
+ status: watchedFormValues.status ?? formacao.status,
872
+ prerequisitos:
873
+ watchedFormValues.prerequisitos ?? formacao.prerequisitos,
874
+ primaryColor: watchedFormValues.primaryColor ?? formacao.primaryColor,
875
+ secondaryColor:
876
+ watchedFormValues.secondaryColor ?? formacao.secondaryColor,
877
+ cursos: selectedCourseNames,
878
+ courseIds: selectedCursos,
879
+ cargaTotal: selectedCourseHours,
880
+ };
881
+ });
882
+ }, [
883
+ availableCursos,
884
+ editingFormacao,
885
+ effectiveListData,
886
+ selectedCursos,
887
+ sheetOpen,
888
+ watchedFormValues,
889
+ ]);
890
+
891
+ const totalItems = effectiveListData?.total ?? 0;
892
+ const totalPages = Math.max(effectiveListData?.lastPage ?? 1, 1);
893
+ const safePage = Math.min(currentPage, totalPages);
894
+
895
+ useEffect(() => {
896
+ if (currentPage > totalPages) {
897
+ setCurrentPage(totalPages);
898
+ }
899
+ }, [currentPage, totalPages]);
900
+
901
+ useEffect(() => {
902
+ const timeout = setTimeout(() => {
903
+ setBuscaDebounced(buscaInput.trim());
904
+ }, 350);
905
+
906
+ return () => clearTimeout(timeout);
907
+ }, [buscaInput]);
908
+
909
+ useEffect(() => {
910
+ setCurrentPage(1);
911
+ }, [buscaDebounced, filtroStatusInput, filtroNivelInput]);
912
+
913
+ function clearFilters() {
914
+ setBuscaInput('');
915
+ setBuscaDebounced('');
916
+ setFiltroStatusInput('todos');
917
+ setFiltroNivelInput('todos');
918
+ setCurrentPage(1);
919
+ }
920
+
921
+ const hasActiveFilters =
922
+ buscaInput.trim().length > 0 ||
923
+ filtroStatusInput !== 'todos' ||
924
+ filtroNivelInput !== 'todos';
925
+
926
+ function openDeleteDialog(formacao: Formacao, e: React.MouseEvent) {
927
+ e.stopPropagation();
928
+ setFormacaoToDelete(formacao);
929
+ setDeleteDialogOpen(true);
930
+ }
931
+
932
+ // ── Double-click ──────────────────────────────────────────────────────────
933
+
934
+ function handleCardClick(formacao: Formacao) {
935
+ const existing = clickTimers.current.get(formacao.id);
936
+ if (existing) {
937
+ clearTimeout(existing);
938
+ clickTimers.current.delete(formacao.id);
939
+ // Toast message for opening would go here if integrated
940
+ } else {
941
+ const timer = setTimeout(
942
+ () => clickTimers.current.delete(formacao.id),
943
+ 300
944
+ );
945
+ clickTimers.current.set(formacao.id, timer);
946
+ }
947
+ }
948
+
949
+ // ── CRUD ──────────────────────────────────────────────────────────────────
950
+
951
+ function openCreateSheet() {
952
+ setEditingFormacao(null);
953
+ initialLearningPathRef.current = [];
954
+ setLearningPathItems([]);
955
+ form.reset({
956
+ nome: '',
957
+ descricao: '',
958
+ area: 'Tecnologia',
959
+ nivel: 'Iniciante',
960
+ prerequisitos: '',
961
+ status: 'rascunho',
962
+ primaryColor: '#1D4ED8',
963
+ secondaryColor: '#111827',
964
+ });
965
+ examForm.reset();
966
+ setSelectedCourseToAdd('');
967
+ setSelectedExamToAdd('');
968
+ void Promise.all([refetchCourses(), refetchExams(), refetchCategories()]);
969
+ setSheetOpen(true);
970
+ }
971
+
972
+ async function openEditSheet(formacao: Formacao, e?: React.MouseEvent) {
973
+ e?.stopPropagation();
974
+ setLoadingEditSheet(true);
975
+ try {
976
+ const [response] = await Promise.all([
977
+ request<Formacao>({
978
+ url: `/lms/training/${formacao.id}`,
979
+ method: 'GET',
980
+ }),
981
+ refetchCourses(),
982
+ refetchExams(),
983
+ refetchCategories(),
984
+ ]);
985
+
986
+ const fullFormacao = normalizeTrainingColorPayload(
987
+ (response?.data ?? formacao) as Formacao & TrainingColorPayload
988
+ ) as Formacao;
989
+ const normalizedItems: LearningPathItem[] =
990
+ fullFormacao.items && fullFormacao.items.length > 0
991
+ ? fullFormacao.items.map((item, index) => ({
992
+ id: item.id,
993
+ type: item.type,
994
+ itemId: item.itemId,
995
+ order: item.order ?? index,
996
+ isRequired: item.isRequired !== false,
997
+ }))
998
+ : (fullFormacao.courseIds ?? []).map((courseId, index) => ({
999
+ type: 'course',
1000
+ itemId: courseId,
1001
+ order: index,
1002
+ isRequired: true,
1003
+ }));
1004
+
1005
+ setEditingFormacao(fullFormacao);
1006
+ initialLearningPathRef.current = [...normalizedItems];
1007
+ setLearningPathItems(normalizedItems);
1008
+ setSelectedCourseToAdd('');
1009
+ setSelectedExamToAdd('');
1010
+ form.reset({
1011
+ nome: fullFormacao.nome,
1012
+ descricao: fullFormacao.descricao,
1013
+ area: normalizeAreaValue(fullFormacao.area),
1014
+ nivel: normalizeLevelValue(fullFormacao.nivel),
1015
+ prerequisitos: fullFormacao.prerequisitos,
1016
+ status: normalizeStatusValue(fullFormacao.status),
1017
+ primaryColor: fullFormacao.primaryColor ?? '#1D4ED8',
1018
+ secondaryColor: fullFormacao.secondaryColor ?? '#111827',
1019
+ });
1020
+ setSheetOpen(true);
1021
+ } finally {
1022
+ setLoadingEditSheet(false);
1023
+ }
1024
+ }
1025
+
1026
+ function normalizeTrailOrder(items: LearningPathItem[]) {
1027
+ return items.map((item, index) => ({ ...item, order: index }));
1028
+ }
1029
+
1030
+ function addTrailItem(type: 'course' | 'exam', itemId: number) {
1031
+ setLearningPathItems((prev) => {
1032
+ if (prev.some((item) => item.type === type && item.itemId === itemId)) {
1033
+ return prev;
1034
+ }
1035
+
1036
+ return normalizeTrailOrder([
1037
+ ...prev,
1038
+ {
1039
+ type,
1040
+ itemId,
1041
+ order: prev.length,
1042
+ isRequired: true,
1043
+ },
1044
+ ]);
1045
+ });
1046
+ }
1047
+
1048
+ function handleCourseSelection(value: string) {
1049
+ setSelectedCourseToAdd(value);
1050
+ const parsed = Number(value);
1051
+
1052
+ if (Number.isFinite(parsed) && parsed > 0) {
1053
+ addTrailItem('course', parsed);
1054
+ setSelectedCourseToAdd('');
1055
+ }
1056
+ }
1057
+
1058
+ function handleExamSelection(value: string) {
1059
+ setSelectedExamToAdd(value);
1060
+ const parsed = Number(value);
1061
+
1062
+ if (Number.isFinite(parsed) && parsed > 0) {
1063
+ addTrailItem('exam', parsed);
1064
+ setSelectedExamToAdd('');
1065
+ }
1066
+ }
1067
+
1068
+ function removeTrailItem(uid: string) {
1069
+ const [type, idText] = uid.split('-');
1070
+ const itemId = Number(idText);
1071
+
1072
+ if (!itemId || (type !== 'course' && type !== 'exam')) return;
1073
+
1074
+ setLearningPathItems((prev) =>
1075
+ normalizeTrailOrder(
1076
+ prev.filter((item) => !(item.type === type && item.itemId === itemId))
1077
+ )
1078
+ );
1079
+ }
1080
+
1081
+ function handleTrailDragEnd(event: DragEndEvent) {
1082
+ const { active, over } = event;
1083
+ if (!over || active.id === over.id) return;
1084
+
1085
+ setLearningPathItems((prev) => {
1086
+ const sorted = [...prev].sort((a, b) => a.order - b.order);
1087
+ const oldIndex = sorted.findIndex(
1088
+ (item) => `${item.type}-${item.itemId}` === String(active.id)
1089
+ );
1090
+ const newIndex = sorted.findIndex(
1091
+ (item) => `${item.type}-${item.itemId}` === String(over.id)
1092
+ );
1093
+
1094
+ if (oldIndex < 0 || newIndex < 0) return prev;
1095
+
1096
+ return normalizeTrailOrder(arrayMove(sorted, oldIndex, newIndex));
1097
+ });
1098
+ }
1099
+
1100
+ function openCreateCourseSheet() {
1101
+ courseForm.reset(DEFAULT_COURSE_FORM_VALUES);
1102
+ setCourseSheetOpen(true);
1103
+ }
1104
+
1105
+ function openCreateExamSheet() {
1106
+ examForm.reset({
1107
+ titulo: '',
1108
+ notaMinima: 7,
1109
+ limiteTempo: 60,
1110
+ shuffle: false,
1111
+ status: 'rascunho',
1112
+ });
1113
+ setExamSheetOpen(true);
1114
+ }
1115
+
1116
+ function ptStatusToApi(value: Formacao['status']) {
1117
+ if (value === 'ativa') return 'active';
1118
+ if (value === 'encerrada') return 'archived';
1119
+ return 'draft';
1120
+ }
1121
+
1122
+ function ptLevelToApi(value: string) {
1123
+ const normalized = normalizeText(value);
1124
+
1125
+ if (['iniciante', 'beginner'].includes(normalized)) return 'beginner';
1126
+ if (['intermediario', 'intermediate'].includes(normalized)) {
1127
+ return 'intermediate';
1128
+ }
1129
+ if (['avancado', 'advanced'].includes(normalized)) return 'advanced';
1130
+
1131
+ return 'beginner';
1132
+ }
1133
+
1134
+ function courseStatusToApi(value: CourseSheetFormValues['status']) {
1135
+ if (value === 'ativo') return 'published';
1136
+ if (value === 'arquivado') return 'archived';
1137
+ return 'draft';
1138
+ }
1139
+
1140
+ function examStatusToApi(value: ExamQuickForm['status']) {
1141
+ if (value === 'publicado') return 'published';
1142
+ if (value === 'encerrado') return 'closed';
1143
+ return 'draft';
1144
+ }
1145
+
1146
+ function pathsAreEqual(a: LearningPathItem[], b: LearningPathItem[]) {
1147
+ if (a.length !== b.length) return false;
1148
+
1149
+ return a.every((item, index) => {
1150
+ const other = b[index];
1151
+ if (!other) return false;
1152
+
1153
+ return (
1154
+ item.type === other.type &&
1155
+ item.itemId === other.itemId &&
1156
+ item.order === other.order
1157
+ );
1158
+ });
1159
+ }
1160
+
1161
+ async function onSubmit(data: FormacaoForm) {
1162
+ try {
1163
+ const orderedItems = normalizeTrailOrder(
1164
+ [...learningPathItems].sort((a, b) => a.order - b.order)
1165
+ );
1166
+
1167
+ if (orderedItems.length === 0) {
1168
+ toast.error(t('toasts.selectAtLeastOneItem'));
1169
+ return;
1170
+ }
1171
+
1172
+ setSaving(true);
1173
+
1174
+ if (editingFormacao) {
1175
+ const dirty = form.formState.dirtyFields;
1176
+ const itemsChanged = !pathsAreEqual(
1177
+ initialLearningPathRef.current,
1178
+ orderedItems
1179
+ );
1180
+
1181
+ const payload: {
1182
+ title?: string;
1183
+ description?: string;
1184
+ shortDescription?: string;
1185
+ level?: 'beginner' | 'intermediate' | 'advanced';
1186
+ status?: 'draft' | 'active' | 'archived';
1187
+ primaryColor?: string;
1188
+ secondaryColor?: string;
1189
+ items?: Array<{
1190
+ type: 'course' | 'exam';
1191
+ itemId: number;
1192
+ order: number;
1193
+ isRequired: boolean;
1194
+ }>;
1195
+ } = {};
1196
+
1197
+ if (dirty.nome) payload.title = data.nome;
1198
+ if (dirty.descricao) payload.description = data.descricao;
1199
+ if (dirty.prerequisitos) {
1200
+ payload.shortDescription = data.prerequisitos?.trim() || undefined;
1201
+ }
1202
+ if (dirty.nivel) payload.level = ptLevelToApi(data.nivel);
1203
+ if (dirty.status) {
1204
+ payload.status = ptStatusToApi(data.status as Formacao['status']);
1205
+ }
1206
+ if (dirty.primaryColor) payload.primaryColor = data.primaryColor;
1207
+ if (dirty.secondaryColor) payload.secondaryColor = data.secondaryColor;
1208
+ if (itemsChanged) {
1209
+ payload.items = orderedItems.map((item, index) => ({
1210
+ type: item.type,
1211
+ itemId: item.itemId,
1212
+ order: index,
1213
+ isRequired: item.isRequired !== false,
1214
+ }));
1215
+ }
1216
+
1217
+ if (Object.keys(payload).length === 0) {
1218
+ setSheetOpen(false);
1219
+ return;
1220
+ }
1221
+
1222
+ await request({
1223
+ url: `/lms/training/${editingFormacao.id}`,
1224
+ method: 'PATCH',
1225
+ data: payload,
1226
+ });
1227
+ toast.success(t('toasts.formacaoUpdated'));
1228
+ } else {
1229
+ const payload = {
1230
+ title: data.nome,
1231
+ description: data.descricao,
1232
+ shortDescription: data.prerequisitos?.trim() || undefined,
1233
+ level: ptLevelToApi(data.nivel),
1234
+ status: ptStatusToApi(data.status as Formacao['status']),
1235
+ primaryColor: data.primaryColor,
1236
+ secondaryColor: data.secondaryColor,
1237
+ items: orderedItems.map((item, index) => ({
1238
+ type: item.type,
1239
+ itemId: item.itemId,
1240
+ order: index,
1241
+ isRequired: item.isRequired !== false,
1242
+ })),
1243
+ };
1244
+
1245
+ await request({
1246
+ url: '/lms/training',
1247
+ method: 'POST',
1248
+ data: payload,
1249
+ });
1250
+ toast.success(t('toasts.formacaoCriada'));
1251
+ }
1252
+
1253
+ await Promise.all([refetchTraining(), refetchStats()]);
1254
+ setSheetOpen(false);
1255
+ setLearningPathItems([]);
1256
+ initialLearningPathRef.current = [];
1257
+ } finally {
1258
+ setSaving(false);
1259
+ }
1260
+ }
1261
+
1262
+ async function onSubmitCourse(data: CourseSheetFormValues) {
1263
+ try {
1264
+ setCreatingCourse(true);
1265
+
1266
+ const slug = slugifyText(data.nomeInterno || data.tituloComercial);
1267
+ const categorySlugs = data.categorias.map(slugifyText).filter(Boolean);
1268
+
1269
+ const response = await request<{
1270
+ id?: number;
1271
+ }>({
1272
+ url: '/lms/courses',
1273
+ method: 'POST',
1274
+ data: {
1275
+ code: buildCourseCodeFromTitle(
1276
+ data.tituloComercial || data.nomeInterno
1277
+ ),
1278
+ slug,
1279
+ title: data.tituloComercial,
1280
+ description: data.descricao,
1281
+ level: ptLevelToApi(data.nivel),
1282
+ status: courseStatusToApi(data.status),
1283
+ categorySlugs,
1284
+ },
1285
+ });
1286
+
1287
+ const createdCourseId = Number(response?.data?.id);
1288
+
1289
+ await refetchCourses();
1290
+ if (Number.isFinite(createdCourseId) && createdCourseId > 0) {
1291
+ addTrailItem('course', createdCourseId);
1292
+ setSelectedCourseToAdd('');
1293
+ }
1294
+
1295
+ setCourseSheetOpen(false);
1296
+ toast.success(t('toasts.courseCreated'));
1297
+ } finally {
1298
+ setCreatingCourse(false);
1299
+ }
1300
+ }
1301
+
1302
+ async function onSubmitExam(data: ExamQuickForm) {
1303
+ try {
1304
+ setCreatingExam(true);
1305
+
1306
+ const response = await request<{ id?: number }>({
1307
+ url: '/lms/exams',
1308
+ method: 'POST',
1309
+ data: {
1310
+ title: data.titulo,
1311
+ minScore: data.notaMinima,
1312
+ timeLimit: data.limiteTempo,
1313
+ shuffle: data.shuffle,
1314
+ status: examStatusToApi(data.status),
1315
+ },
1316
+ });
1317
+
1318
+ const createdExamId = Number(response?.data?.id);
1319
+
1320
+ await refetchExams();
1321
+ if (Number.isFinite(createdExamId) && createdExamId > 0) {
1322
+ addTrailItem('exam', createdExamId);
1323
+ setSelectedExamToAdd('');
1324
+ }
1325
+
1326
+ setExamSheetOpen(false);
1327
+ toast.success(t('toasts.examCreated'));
1328
+ } finally {
1329
+ setCreatingExam(false);
1330
+ }
1331
+ }
1332
+
1333
+ async function confirmDelete() {
1334
+ if (!formacaoToDelete) return;
1335
+
1336
+ await request({
1337
+ url: `/lms/training/${formacaoToDelete.id}`,
1338
+ method: 'DELETE',
1339
+ });
1340
+
1341
+ toast.success(t('toasts.formacaoRemovida'));
1342
+ await Promise.all([refetchTraining(), refetchStats()]);
1343
+ setFormacaoToDelete(null);
1344
+ setDeleteDialogOpen(false);
1345
+ }
1346
+
1347
+ // ── KPIs ─────────────────────────────────────────────────────���────────────
1348
+
1349
+ const kpis = [
1350
+ {
1351
+ key: 'total-training',
1352
+ title: t('kpis.totalTraining.label'),
1353
+ value: statsData?.totalTraining ?? 0,
1354
+ description: t('kpis.totalTraining.sub'),
1355
+ icon: GraduationCap,
1356
+ layout: 'compact' as const,
1357
+ accentClassName: 'from-orange-500/25 via-amber-500/15 to-transparent',
1358
+ iconContainerClassName: 'bg-orange-100 text-orange-700',
1359
+ },
1360
+ {
1361
+ key: 'active-training',
1362
+ title: t('kpis.activeTraining.label'),
1363
+ value: statsData?.activeTraining ?? 0,
1364
+ description: t('kpis.activeTraining.sub'),
1365
+ icon: Target,
1366
+ layout: 'compact' as const,
1367
+ accentClassName: 'from-sky-500/25 via-blue-500/15 to-transparent',
1368
+ iconContainerClassName: 'bg-sky-100 text-sky-700',
1369
+ },
1370
+ {
1371
+ key: 'enrolled-students',
1372
+ title: t('kpis.enrolledStudents.label'),
1373
+ value: (statsData?.enrolledStudents ?? 0).toLocaleString('pt-BR'),
1374
+ description: t('kpis.enrolledStudents.sub'),
1375
+ icon: Users,
1376
+ layout: 'compact' as const,
1377
+ accentClassName: 'from-emerald-500/25 via-green-500/15 to-transparent',
1378
+ iconContainerClassName: 'bg-emerald-100 text-emerald-700',
1379
+ },
1380
+ {
1381
+ key: 'covered-courses',
1382
+ title: t('kpis.coveredCourses.label'),
1383
+ value: statsData?.coveredCourses ?? 0,
1384
+ description: t('kpis.coveredCourses.sub'),
1385
+ icon: Layers,
1386
+ layout: 'compact' as const,
1387
+ accentClassName: 'from-pink-500/25 via-rose-500/15 to-transparent',
1388
+ iconContainerClassName: 'bg-pink-100 text-pink-700',
1389
+ },
1390
+ ];
1391
+
1392
+ const { locales } = useApp();
1393
+
1394
+ const handleNewTraining = (): void => {
1395
+ const nextLocaleData: Record<string, { name: string }> = {};
1396
+ locales.forEach((locale: Locale) => {
1397
+ nextLocaleData[locale.code] = {
1398
+ name: '',
1399
+ };
1400
+ });
1401
+ void nextLocaleData;
1402
+ openCreateSheet();
1403
+ };
1404
+
1405
+ // ── Render ────────────────────────────────────────────────────────────────
1406
+
1407
+ return (
1408
+ <Page>
1409
+ <PageHeader
1410
+ title={t('title')}
1411
+ description={t('description')}
1412
+ breadcrumbs={[
1413
+ {
1414
+ label: t('breadcrumbs.home'),
1415
+ href: '/',
1416
+ },
1417
+ {
1418
+ label: t('title'),
1419
+ },
1420
+ ]}
1421
+ actions={[
1422
+ {
1423
+ label: t('actions.createTraining'),
1424
+ onClick: () => handleNewTraining(),
1425
+ variant: 'default',
1426
+ },
1427
+ ]}
1428
+ />
1429
+
1430
+ {/* KPIs */}
1431
+ <div className="mb-1">
1432
+ {initialLoading && !statsData ? (
1433
+ <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
1434
+ {Array.from({ length: 4 }).map((_, i) => (
1435
+ <Card key={i}>
1436
+ <CardContent className="p-4">
1437
+ <Skeleton className="mb-2 h-8 w-16" />
1438
+ <Skeleton className="h-4 w-28" />
1439
+ </CardContent>
1440
+ </Card>
1441
+ ))}
1442
+ </div>
1443
+ ) : (
1444
+ <KpiCardsGrid items={kpis} />
1445
+ )}
1446
+ </div>
1447
+
1448
+ {/* Search bar */}
1449
+ <div className="mb-1 space-y-3">
1450
+ <SearchBar
1451
+ searchQuery={buscaInput}
1452
+ onSearchChange={setBuscaInput}
1453
+ onSearch={() => {
1454
+ setBuscaDebounced(buscaInput.trim());
1455
+ setCurrentPage(1);
1456
+ }}
1457
+ placeholder={t('filters.searchPlaceholder')}
1458
+ controls={[
1459
+ {
1460
+ id: 'status',
1461
+ type: 'select',
1462
+ value: filtroStatusInput,
1463
+ onChange: setFiltroStatusInput,
1464
+ placeholder: t('filters.allStatuses'),
1465
+ options: [
1466
+ { value: 'todos', label: t('filters.allStatuses') },
1467
+ { value: 'ativa', label: t('status.active') },
1468
+ { value: 'rascunho', label: t('status.draft') },
1469
+ { value: 'encerrada', label: t('status.closed') },
1470
+ ],
1471
+ },
1472
+ {
1473
+ id: 'level',
1474
+ type: 'select',
1475
+ value: filtroNivelInput,
1476
+ onChange: setFiltroNivelInput,
1477
+ placeholder: t('filters.allLevels'),
1478
+ options: [
1479
+ { value: 'todos', label: t('filters.allLevels') },
1480
+ { value: 'Iniciante', label: t('levels.beginner') },
1481
+ { value: 'Intermediario', label: t('levels.intermediate') },
1482
+ { value: 'Avancado', label: t('levels.advanced') },
1483
+ ],
1484
+ },
1485
+ ]}
1486
+ afterSearchButton={
1487
+ <ViewModeToggle
1488
+ viewMode={viewMode}
1489
+ onViewModeChange={setViewMode}
1490
+ listLabel={t('viewMode.list')}
1491
+ cardsLabel={t('viewMode.cards')}
1492
+ />
1493
+ }
1494
+ />
1495
+ <div className="flex flex-wrap items-center justify-between gap-3">
1496
+ <p className="text-sm text-muted-foreground">
1497
+ {totalItems}{' '}
1498
+ {totalItems !== 1
1499
+ ? t('pagination.formacoes')
1500
+ : t('pagination.formacao')}
1501
+ </p>
1502
+ <div className="flex items-center gap-2">
1503
+ {hasActiveFilters && (
1504
+ <Button
1505
+ type="button"
1506
+ variant="ghost"
1507
+ size="sm"
1508
+ onClick={clearFilters}
1509
+ >
1510
+ {t('filters.clear')}
1511
+ </Button>
1512
+ )}
1513
+ {cardsRefreshing && (
1514
+ <Loader2 className="size-4 animate-spin text-muted-foreground" />
1515
+ )}
1516
+ </div>
1517
+ </div>
1518
+ </div>
1519
+
1520
+ {/* Training list */}
1521
+ {initialLoading ? (
1522
+ viewMode === 'cards' ? (
1523
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
1524
+ {Array.from({ length: 6 }).map((_, i) => (
1525
+ <Card key={i} className="overflow-hidden">
1526
+ <CardContent className="p-5">
1527
+ <div className="flex min-h-52.5 flex-col justify-between">
1528
+ <div>
1529
+ <Skeleton className="mb-3 h-5 w-20 rounded-full" />
1530
+ <Skeleton className="mb-1.5 h-5 w-3/4" />
1531
+ <Skeleton className="mb-4 h-4 w-full" />
1532
+ </div>
1533
+ <div className="mt-auto flex gap-2">
1534
+ <Skeleton className="h-6 w-16 rounded-full" />
1535
+ <Skeleton className="h-6 w-20 rounded-full" />
1536
+ </div>
1537
+ </div>
1538
+ </CardContent>
1539
+ </Card>
1540
+ ))}
1541
+ </div>
1542
+ ) : (
1543
+ <div className="overflow-hidden rounded-xl border border-border/70">
1544
+ <Table>
1545
+ <TableHeader>
1546
+ <TableRow>
1547
+ <TableHead>{t('form.fields.nome.label')}</TableHead>
1548
+ <TableHead>{t('form.fields.level.label')}</TableHead>
1549
+ <TableHead>{t('form.fields.status.label')}</TableHead>
1550
+ <TableHead>{t('cards.coursesLabel')}</TableHead>
1551
+ <TableHead>{t('cards.hoursLabel')}</TableHead>
1552
+ <TableHead className="text-right">
1553
+ {t('cards.studentsLabel')}
1554
+ </TableHead>
1555
+ <TableHead className="w-12" />
1556
+ </TableRow>
1557
+ </TableHeader>
1558
+ <TableBody>
1559
+ {Array.from({ length: 6 }).map((_, i) => (
1560
+ <TableRow key={i}>
1561
+ <TableCell>
1562
+ <div className="space-y-1.5">
1563
+ <Skeleton className="h-4 w-44" />
1564
+ <Skeleton className="h-3 w-56" />
1565
+ </div>
1566
+ </TableCell>
1567
+ <TableCell>
1568
+ <Skeleton className="h-5 w-20 rounded-full" />
1569
+ </TableCell>
1570
+ <TableCell>
1571
+ <Skeleton className="h-5 w-20 rounded-full" />
1572
+ </TableCell>
1573
+ <TableCell>
1574
+ <Skeleton className="h-4 w-12" />
1575
+ </TableCell>
1576
+ <TableCell>
1577
+ <Skeleton className="h-4 w-10" />
1578
+ </TableCell>
1579
+ <TableCell className="text-right">
1580
+ <Skeleton className="ml-auto h-4 w-12" />
1581
+ </TableCell>
1582
+ <TableCell>
1583
+ <Skeleton className="ml-auto size-8 rounded-md" />
1584
+ </TableCell>
1585
+ </TableRow>
1586
+ ))}
1587
+ </TableBody>
1588
+ </Table>
1589
+ </div>
1590
+ )
1591
+ ) : totalItems === 0 ? (
1592
+ <EmptyState
1593
+ icon={<GraduationCap className="size-12 text-muted-foreground/40" />}
1594
+ title={t('empty.title')}
1595
+ description={t('empty.description')}
1596
+ actionLabel={t('empty.action')}
1597
+ actionIcon={<Plus className="mr-2 size-4" />}
1598
+ onAction={openCreateSheet}
1599
+ className="py-20"
1600
+ />
1601
+ ) : (
1602
+ <div className="relative">
1603
+ {cardsRefreshing && (
1604
+ <div className="absolute inset-0 z-10 rounded-2xl bg-background/55 backdrop-blur-[1px]" />
1605
+ )}
1606
+ {viewMode === 'cards' ? (
1607
+ <motion.div
1608
+ className={`${'grid gap-4 sm:grid-cols-2 lg:grid-cols-3'} ${cardsRefreshing ? 'pointer-events-none' : ''}`}
1609
+ variants={stagger}
1610
+ initial="hidden"
1611
+ animate="show"
1612
+ >
1613
+ {formacoesToRender.map((formacao) => {
1614
+ const statusCfg = STATUS_MAP[formacao.status] ?? {
1615
+ label: formacao.status,
1616
+ variant: 'default' as const,
1617
+ };
1618
+ const cardTopColor =
1619
+ normalizeHexColor(formacao.primaryColor) ?? '#1D4ED8';
1620
+
1621
+ return (
1622
+ <motion.div key={formacao.id} variants={fadeUp}>
1623
+ <Card
1624
+ className="group relative flex min-h-60 max-h-67.5 cursor-pointer flex-col overflow-hidden border-border/70 py-0 shadow-sm transition-all duration-200 hover:border-border hover:shadow-md"
1625
+ onClick={() => handleCardClick(formacao)}
1626
+ title={t('cards.tooltip')}
1627
+ >
1628
+ <div
1629
+ className="absolute inset-x-0 top-0 h-1"
1630
+ style={{
1631
+ backgroundColor: cardTopColor,
1632
+ }}
1633
+ />
1634
+ <CardContent className="flex h-full flex-col p-4 pt-5">
1635
+ <div className="mb-3 flex items-center justify-between gap-2">
1636
+ <div className="flex flex-wrap items-center gap-1.5">
1637
+ <Badge variant="outline" className="text-xs">
1638
+ {normalizeLevelValue(formacao.nivel)}
1639
+ </Badge>
1640
+ <Badge
1641
+ variant={statusCfg.variant}
1642
+ className="text-xs"
1643
+ >
1644
+ {statusCfg.label}
1645
+ </Badge>
1646
+ </div>
1647
+ <DropdownMenu>
1648
+ <DropdownMenuTrigger asChild>
1649
+ <Button
1650
+ variant="ghost"
1651
+ size="icon"
1652
+ className="size-8 shrink-0 -mr-2 -mt-1"
1653
+ onClick={(e) => e.stopPropagation()}
1654
+ aria-label={t('cards.actions.label')}
1655
+ >
1656
+ <MoreHorizontal className="size-4" />
1657
+ </Button>
1658
+ </DropdownMenuTrigger>
1659
+ <DropdownMenuContent align="end" className="w-48">
1660
+ <DropdownMenuItem
1661
+ onClick={(e) => openEditSheet(formacao, e)}
1662
+ >
1663
+ <Pencil className="mr-2 size-4" />{' '}
1664
+ {t('cards.actions.edit')}
1665
+ </DropdownMenuItem>
1666
+ <DropdownMenuSeparator />
1667
+ <DropdownMenuItem
1668
+ className="text-destructive focus:text-destructive"
1669
+ onClick={(e) => openDeleteDialog(formacao, e)}
1670
+ >
1671
+ <Trash2 className="mr-2 size-4" />{' '}
1672
+ {t('cards.actions.delete')}
1673
+ </DropdownMenuItem>
1674
+ </DropdownMenuContent>
1675
+ </DropdownMenu>
1676
+ </div>
1677
+
1678
+ <h3 className="mb-0.5 font-semibold leading-tight">
1679
+ {formacao.nome}
1680
+ </h3>
1681
+ <p className="mb-4 line-clamp-2 text-sm text-muted-foreground leading-relaxed">
1682
+ {formacao.descricao}
1683
+ </p>
1684
+
1685
+ <p className="mb-3 line-clamp-1 text-xs text-muted-foreground">
1686
+ {t('cards.prerequisiteLabel')}{' '}
1687
+ {formacao.prerequisitos?.trim()
1688
+ ? formacao.prerequisitos
1689
+ : t('cards.noPrerequisite')}
1690
+ </p>
1691
+
1692
+ <div className="mb-4 flex flex-wrap gap-1">
1693
+ {formacao.cursos.slice(0, 3).map((c) => (
1694
+ <Badge
1695
+ key={c}
1696
+ variant="outline"
1697
+ className="text-xs px-1.5 py-0"
1698
+ >
1699
+ {c}
1700
+ </Badge>
1701
+ ))}
1702
+ {formacao.cursos.length > 3 && (
1703
+ <Badge
1704
+ variant="outline"
1705
+ className="text-xs px-1.5 py-0"
1706
+ >
1707
+ +{formacao.cursos.length - 3}
1708
+ </Badge>
1709
+ )}
1710
+ </div>
1711
+
1712
+ <Separator className="mb-3" />
1713
+
1714
+ <div className="flex items-center justify-between text-xs text-muted-foreground">
1715
+ <div className="flex items-center gap-3">
1716
+ <span className="flex items-center gap-1">
1717
+ <Layers className="size-3.5" />
1718
+ {formacao.cursos.length} {t('cards.coursesLabel')}
1719
+ </span>
1720
+ <span className="flex items-center gap-1">
1721
+ <Clock className="size-3.5" />
1722
+ {formacao.cargaTotal}
1723
+ {t('cards.hoursLabel')}
1724
+ </span>
1725
+ </div>
1726
+ <span className="flex items-center gap-1">
1727
+ <Users className="size-3.5" />
1728
+ {formacao.alunos.toLocaleString('pt-BR')}{' '}
1729
+ {t('cards.studentsLabel')}
1730
+ </span>
1731
+ </div>
1732
+ </CardContent>
1733
+ </Card>
1734
+ </motion.div>
1735
+ );
1736
+ })}
1737
+ </motion.div>
1738
+ ) : (
1739
+ <div
1740
+ className={`overflow-hidden rounded-xl border border-border/70 ${cardsRefreshing ? 'pointer-events-none' : ''}`}
1741
+ >
1742
+ <Table>
1743
+ <TableHeader>
1744
+ <TableRow>
1745
+ <TableHead>{t('form.fields.nome.label')}</TableHead>
1746
+ <TableHead>{t('form.fields.level.label')}</TableHead>
1747
+ <TableHead>{t('form.fields.status.label')}</TableHead>
1748
+ <TableHead>{t('cards.coursesLabel')}</TableHead>
1749
+ <TableHead>{t('cards.hoursLabel')}</TableHead>
1750
+ <TableHead className="text-right">
1751
+ {t('cards.studentsLabel')}
1752
+ </TableHead>
1753
+ <TableHead className="w-12" />
1754
+ </TableRow>
1755
+ </TableHeader>
1756
+ <TableBody>
1757
+ {formacoesToRender.map((formacao) => {
1758
+ const statusCfg = STATUS_MAP[formacao.status] ?? {
1759
+ label: formacao.status,
1760
+ variant: 'default' as const,
1761
+ };
1762
+
1763
+ return (
1764
+ <TableRow
1765
+ key={formacao.id}
1766
+ className="cursor-pointer"
1767
+ onClick={() => handleCardClick(formacao)}
1768
+ title={t('cards.tooltip')}
1769
+ >
1770
+ <TableCell>
1771
+ <div className="min-w-0">
1772
+ <p className="truncate font-semibold text-foreground">
1773
+ {formacao.nome}
1774
+ </p>
1775
+ <p className="mt-1 line-clamp-1 text-xs text-muted-foreground">
1776
+ {formacao.prerequisitos?.trim()
1777
+ ? formacao.prerequisitos
1778
+ : t('cards.noPrerequisite')}
1779
+ </p>
1780
+ </div>
1781
+ </TableCell>
1782
+ <TableCell>
1783
+ <Badge variant="outline" className="text-xs">
1784
+ {normalizeLevelValue(formacao.nivel)}
1785
+ </Badge>
1786
+ </TableCell>
1787
+ <TableCell>
1788
+ <Badge
1789
+ variant={statusCfg.variant}
1790
+ className="text-xs"
1791
+ >
1792
+ {statusCfg.label}
1793
+ </Badge>
1794
+ </TableCell>
1795
+ <TableCell>{formacao.cursos.length}</TableCell>
1796
+ <TableCell>
1797
+ {formacao.cargaTotal}
1798
+ {t('cards.hoursLabel')}
1799
+ </TableCell>
1800
+ <TableCell className="text-right font-medium">
1801
+ {formacao.alunos.toLocaleString('pt-BR')}
1802
+ </TableCell>
1803
+ <TableCell onClick={(e) => e.stopPropagation()}>
1804
+ <DropdownMenu>
1805
+ <DropdownMenuTrigger asChild>
1806
+ <Button
1807
+ variant="ghost"
1808
+ size="icon"
1809
+ className="ml-auto size-8"
1810
+ aria-label={t('cards.actions.label')}
1811
+ >
1812
+ <MoreHorizontal className="size-4" />
1813
+ </Button>
1814
+ </DropdownMenuTrigger>
1815
+ <DropdownMenuContent align="end" className="w-48">
1816
+ <DropdownMenuItem
1817
+ onClick={() => openEditSheet(formacao)}
1818
+ >
1819
+ <Pencil className="mr-2 size-4" />{' '}
1820
+ {t('cards.actions.edit')}
1821
+ </DropdownMenuItem>
1822
+ <DropdownMenuSeparator />
1823
+ <DropdownMenuItem
1824
+ className="text-destructive focus:text-destructive"
1825
+ onClick={(e) => openDeleteDialog(formacao, e)}
1826
+ >
1827
+ <Trash2 className="mr-2 size-4" />{' '}
1828
+ {t('cards.actions.delete')}
1829
+ </DropdownMenuItem>
1830
+ </DropdownMenuContent>
1831
+ </DropdownMenu>
1832
+ </TableCell>
1833
+ </TableRow>
1834
+ );
1835
+ })}
1836
+ </TableBody>
1837
+ </Table>
1838
+ </div>
1839
+ )}
1840
+ </div>
1841
+ )}
1842
+
1843
+ {/* Pagination footer */}
1844
+ {!initialLoading && totalItems > 0 && (
1845
+ <div className="mt-6">
1846
+ <PaginationFooter
1847
+ currentPage={safePage}
1848
+ pageSize={pageSize}
1849
+ totalItems={totalItems}
1850
+ onPageChange={setCurrentPage}
1851
+ onPageSizeChange={(nextPageSize) => {
1852
+ setPageSize(nextPageSize);
1853
+ setCurrentPage(1);
1854
+ }}
1855
+ pageSizeOptions={PAGE_SIZES}
1856
+ />
1857
+ </div>
1858
+ )}
1859
+
1860
+ {/* Sheet */}
1861
+ <Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
1862
+ <SheetContent
1863
+ side="right"
1864
+ className="flex w-full flex-col sm:max-w-lg overflow-y-auto"
1865
+ >
1866
+ <SheetHeader className="shrink-0">
1867
+ <SheetTitle>
1868
+ {editingFormacao ? t('form.title.edit') : t('form.title.create')}
1869
+ </SheetTitle>
1870
+ <SheetDescription>{t('form.description.create')}</SheetDescription>
1871
+ </SheetHeader>
1872
+ <form
1873
+ onSubmit={form.handleSubmit(onSubmit)}
1874
+ className="flex flex-1 flex-col gap-4 px-4 py-6"
1875
+ >
1876
+ <Field>
1877
+ <FieldLabel htmlFor="nome">
1878
+ {t('form.fields.nome.label')}{' '}
1879
+ <span className="text-destructive">*</span>
1880
+ </FieldLabel>
1881
+ <Input
1882
+ id="nome"
1883
+ placeholder={t('form.fields.nome.placeholder')}
1884
+ {...form.register('nome')}
1885
+ />
1886
+ <FieldError>{form.formState.errors.nome?.message}</FieldError>
1887
+ </Field>
1888
+ <Field>
1889
+ <FieldLabel htmlFor="descricao">
1890
+ {t('form.fields.descricao.label')}{' '}
1891
+ <span className="text-destructive">*</span>
1892
+ </FieldLabel>
1893
+ <Textarea
1894
+ id="descricao"
1895
+ rows={3}
1896
+ placeholder={t('form.fields.descricao.placeholder')}
1897
+ {...form.register('descricao')}
1898
+ />
1899
+ <FieldError>
1900
+ {form.formState.errors.descricao?.message}
1901
+ </FieldError>
1902
+ </Field>
1903
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
1904
+ <Field>
1905
+ <FieldLabel>
1906
+ {t('form.fields.nivel.label')}{' '}
1907
+ <span className="text-destructive">*</span>
1908
+ </FieldLabel>
1909
+ <Controller
1910
+ name="nivel"
1911
+ control={form.control}
1912
+ render={({ field }) => (
1913
+ <Select onValueChange={field.onChange} value={field.value}>
1914
+ <SelectTrigger>
1915
+ <SelectValue
1916
+ placeholder={t('form.fields.nivel.placeholder')}
1917
+ />
1918
+ </SelectTrigger>
1919
+ <SelectContent>
1920
+ <SelectItem value="Iniciante">
1921
+ {t('levels.beginner')}
1922
+ </SelectItem>
1923
+ <SelectItem value="Intermediario">
1924
+ {t('levels.intermediate')}
1925
+ </SelectItem>
1926
+ <SelectItem value="Avancado">
1927
+ {t('levels.advanced')}
1928
+ </SelectItem>
1929
+ </SelectContent>
1930
+ </Select>
1931
+ )}
1932
+ />
1933
+ <FieldError>{form.formState.errors.nivel?.message}</FieldError>
1934
+ </Field>
1935
+
1936
+ <Field>
1937
+ <FieldLabel>
1938
+ {t('form.fields.status.label')}{' '}
1939
+ <span className="text-destructive">*</span>
1940
+ </FieldLabel>
1941
+ <Controller
1942
+ name="status"
1943
+ control={form.control}
1944
+ render={({ field }) => (
1945
+ <Select onValueChange={field.onChange} value={field.value}>
1946
+ <SelectTrigger>
1947
+ <SelectValue />
1948
+ </SelectTrigger>
1949
+ <SelectContent>
1950
+ <SelectItem value="rascunho">
1951
+ {t('status.draft')}
1952
+ </SelectItem>
1953
+ <SelectItem value="ativa">
1954
+ {t('status.active')}
1955
+ </SelectItem>
1956
+ <SelectItem value="encerrada">
1957
+ {t('status.closed')}
1958
+ </SelectItem>
1959
+ </SelectContent>
1960
+ </Select>
1961
+ )}
1962
+ />
1963
+ <FieldError>{form.formState.errors.status?.message}</FieldError>
1964
+ </Field>
1965
+ </div>
1966
+
1967
+ <Field>
1968
+ <FieldLabel htmlFor="prerequisitos">
1969
+ {t('form.fields.prerequisitos.label')}
1970
+ </FieldLabel>
1971
+ <Input
1972
+ id="prerequisitos"
1973
+ placeholder={t('form.fields.prerequisitos.placeholder')}
1974
+ {...form.register('prerequisitos')}
1975
+ />
1976
+ </Field>
1977
+
1978
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
1979
+ <Field>
1980
+ <FieldLabel htmlFor="primaryColor">Cor Primária</FieldLabel>
1981
+ <Controller
1982
+ name="primaryColor"
1983
+ control={form.control}
1984
+ render={({ field }) => (
1985
+ <div className="flex items-center gap-2">
1986
+ <Input
1987
+ id="primaryColor"
1988
+ type="color"
1989
+ className="h-10 w-16 p-1"
1990
+ value={field.value}
1991
+ onChange={field.onChange}
1992
+ />
1993
+ <Input
1994
+ value={field.value}
1995
+ onChange={field.onChange}
1996
+ placeholder="#1D4ED8"
1997
+ />
1998
+ </div>
1999
+ )}
2000
+ />
2001
+ <FieldError>
2002
+ {form.formState.errors.primaryColor?.message}
2003
+ </FieldError>
2004
+ </Field>
2005
+
2006
+ <Field>
2007
+ <FieldLabel htmlFor="secondaryColor">Cor Secundária</FieldLabel>
2008
+ <Controller
2009
+ name="secondaryColor"
2010
+ control={form.control}
2011
+ render={({ field }) => (
2012
+ <div className="flex items-center gap-2">
2013
+ <Input
2014
+ id="secondaryColor"
2015
+ type="color"
2016
+ className="h-10 w-16 p-1"
2017
+ value={field.value}
2018
+ onChange={field.onChange}
2019
+ />
2020
+ <Input
2021
+ value={field.value}
2022
+ onChange={field.onChange}
2023
+ placeholder="#111827"
2024
+ />
2025
+ </div>
2026
+ )}
2027
+ />
2028
+ <FieldError>
2029
+ {form.formState.errors.secondaryColor?.message}
2030
+ </FieldError>
2031
+ </Field>
2032
+ </div>
2033
+
2034
+ {/* Trilha */}
2035
+ <Field>
2036
+ <FieldLabel>{t('form.fields.trilha.label')}</FieldLabel>
2037
+ <div className="space-y-2">
2038
+ <div className="rounded-md border bg-muted/20 p-3">
2039
+ <div className="space-y-2">
2040
+ <div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_auto]">
2041
+ <Select
2042
+ value={selectedCourseToAdd}
2043
+ onValueChange={handleCourseSelection}
2044
+ disabled={selectableCursos.length === 0}
2045
+ >
2046
+ <SelectTrigger className="w-full">
2047
+ <SelectValue
2048
+ placeholder={t('form.fields.cursos.placeholder')}
2049
+ />
2050
+ </SelectTrigger>
2051
+ <SelectContent>
2052
+ {selectableCursos.map((course) => (
2053
+ <SelectItem
2054
+ key={course.id}
2055
+ value={String(course.id)}
2056
+ >
2057
+ {course.nome} ({course.cargaHoraria}h)
2058
+ </SelectItem>
2059
+ ))}
2060
+ </SelectContent>
2061
+ </Select>
2062
+ <Button
2063
+ type="button"
2064
+ variant="outline"
2065
+ className="w-full shrink-0 whitespace-nowrap sm:w-auto"
2066
+ onClick={openCreateCourseSheet}
2067
+ >
2068
+ <Plus className="size-4" />
2069
+ </Button>
2070
+ </div>
2071
+
2072
+ <div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_auto]">
2073
+ <Select
2074
+ value={selectedExamToAdd}
2075
+ onValueChange={handleExamSelection}
2076
+ disabled={selectableExams.length === 0}
2077
+ >
2078
+ <SelectTrigger className="w-full">
2079
+ <SelectValue
2080
+ placeholder={t('form.fields.exames.placeholder')}
2081
+ />
2082
+ </SelectTrigger>
2083
+ <SelectContent>
2084
+ {selectableExams.map((exam) => (
2085
+ <SelectItem key={exam.id} value={String(exam.id)}>
2086
+ {exam.titulo} ({exam.limiteTempo}min)
2087
+ </SelectItem>
2088
+ ))}
2089
+ </SelectContent>
2090
+ </Select>
2091
+ <Button
2092
+ type="button"
2093
+ variant="outline"
2094
+ className="w-full shrink-0 whitespace-nowrap sm:w-auto"
2095
+ onClick={openCreateExamSheet}
2096
+ >
2097
+ <Plus className="size-4" />
2098
+ </Button>
2099
+ </div>
2100
+ </div>
2101
+ </div>
2102
+
2103
+ {trailItems.length > 0 ? (
2104
+ <>
2105
+ <div className="rounded-md border bg-background">
2106
+ <DndContext
2107
+ sensors={sensors}
2108
+ collisionDetection={closestCenter}
2109
+ onDragEnd={handleTrailDragEnd}
2110
+ >
2111
+ <SortableContext
2112
+ items={trailItems.map((item) => item.uid)}
2113
+ strategy={verticalListSortingStrategy}
2114
+ >
2115
+ {trailItems.map((item) => (
2116
+ <SortableTrailItem
2117
+ key={item.uid}
2118
+ item={item}
2119
+ onRemove={removeTrailItem}
2120
+ />
2121
+ ))}
2122
+ </SortableContext>
2123
+ </DndContext>
2124
+ </div>
2125
+ </>
2126
+ ) : (
2127
+ <div className="rounded-md border p-3">
2128
+ <p className="text-sm text-muted-foreground">
2129
+ {t('form.fields.trilha.empty')}
2130
+ </p>
2131
+ </div>
2132
+ )}
2133
+ </div>
2134
+
2135
+ {(isFetchingCourses || isFetchingExams) && (
2136
+ <p className="text-xs text-muted-foreground">
2137
+ {t('form.fields.trilha.loading')}
2138
+ </p>
2139
+ )}
2140
+
2141
+ {learningPathItems.length > 0 && (
2142
+ <p className="text-xs text-muted-foreground mt-1">
2143
+ {learningPathItems.length} {t('coursesSummary.items')}{' '}
2144
+ {t('coursesSummary.dot')}{' '}
2145
+ {availableCursos
2146
+ .filter((c) => selectedCursos.includes(c.id))
2147
+ .reduce((a, c) => a + c.cargaHoraria, 0)}
2148
+ {t('coursesSummary.hours')}
2149
+ </p>
2150
+ )}
2151
+ </Field>
2152
+
2153
+ <SheetFooter className="mt-auto shrink-0 gap-2 pt-4 px-0">
2154
+ <Button
2155
+ type="submit"
2156
+ disabled={saving || loadingEditSheet}
2157
+ className="gap-2"
2158
+ >
2159
+ {(saving || loadingEditSheet) && (
2160
+ <Loader2 className="size-4 animate-spin" />
2161
+ )}
2162
+ {editingFormacao
2163
+ ? t('form.actions.save')
2164
+ : t('form.actions.create')}
2165
+ </Button>
2166
+ </SheetFooter>
2167
+ </form>
2168
+ </SheetContent>
2169
+ </Sheet>
2170
+
2171
+ <CourseFormSheet
2172
+ open={courseSheetOpen}
2173
+ onOpenChange={setCourseSheetOpen}
2174
+ editing={false}
2175
+ saving={creatingCourse}
2176
+ form={courseForm}
2177
+ onSubmit={onSubmitCourse}
2178
+ categories={categoryOptions}
2179
+ onCreateCategory={() => router.push('/category?new=1')}
2180
+ t={tCourse}
2181
+ />
2182
+
2183
+ <Sheet open={examSheetOpen} onOpenChange={setExamSheetOpen}>
2184
+ <SheetContent
2185
+ side="right"
2186
+ className="flex w-full flex-col sm:max-w-lg overflow-y-auto"
2187
+ >
2188
+ <SheetHeader>
2189
+ <SheetTitle>{t('examForm.title')}</SheetTitle>
2190
+ <SheetDescription>{t('examForm.description')}</SheetDescription>
2191
+ </SheetHeader>
2192
+
2193
+ <form
2194
+ onSubmit={examForm.handleSubmit(onSubmitExam)}
2195
+ className="flex flex-1 flex-col gap-4 px-4 py-6"
2196
+ >
2197
+ <Field>
2198
+ <FieldLabel htmlFor="exam-titulo">
2199
+ {tExam('form.fields.title.label')}{' '}
2200
+ <span className="text-destructive">*</span>
2201
+ </FieldLabel>
2202
+ <Input
2203
+ id="exam-titulo"
2204
+ placeholder={tExam('form.fields.title.placeholder')}
2205
+ {...examForm.register('titulo')}
2206
+ />
2207
+ <FieldError>
2208
+ {examForm.formState.errors.titulo?.message}
2209
+ </FieldError>
2210
+ </Field>
2211
+
2212
+ <div className="grid grid-cols-2 gap-4">
2213
+ <Field>
2214
+ <FieldLabel htmlFor="exam-min-score">
2215
+ {tExam('form.fields.minScore.label')}{' '}
2216
+ <span className="text-destructive">*</span>
2217
+ </FieldLabel>
2218
+ <Input
2219
+ id="exam-min-score"
2220
+ type="number"
2221
+ step="0.5"
2222
+ {...examForm.register('notaMinima')}
2223
+ />
2224
+ </Field>
2225
+
2226
+ <Field>
2227
+ <FieldLabel htmlFor="exam-time-limit">
2228
+ {tExam('form.fields.timeLimit.label')}{' '}
2229
+ <span className="text-destructive">*</span>
2230
+ </FieldLabel>
2231
+ <Input
2232
+ id="exam-time-limit"
2233
+ type="number"
2234
+ {...examForm.register('limiteTempo')}
2235
+ />
2236
+ </Field>
2237
+ </div>
2238
+
2239
+ <Field>
2240
+ <FieldLabel>{tExam('form.fields.status.label')}</FieldLabel>
2241
+ <Controller
2242
+ name="status"
2243
+ control={examForm.control}
2244
+ render={({ field }) => (
2245
+ <Select onValueChange={field.onChange} value={field.value}>
2246
+ <SelectTrigger>
2247
+ <SelectValue
2248
+ placeholder={tExam('form.fields.status.placeholder')}
2249
+ />
2250
+ </SelectTrigger>
2251
+ <SelectContent>
2252
+ <SelectItem value="rascunho">
2253
+ {tExam('status.draft')}
2254
+ </SelectItem>
2255
+ <SelectItem value="publicado">
2256
+ {tExam('status.published')}
2257
+ </SelectItem>
2258
+ <SelectItem value="encerrado">
2259
+ {tExam('status.closed')}
2260
+ </SelectItem>
2261
+ </SelectContent>
2262
+ </Select>
2263
+ )}
2264
+ />
2265
+ </Field>
2266
+
2267
+ <Field>
2268
+ <div className="flex items-center justify-between rounded-lg border p-3">
2269
+ <div>
2270
+ <p className="text-sm font-medium">
2271
+ {tExam('form.fields.shuffle.label')}
2272
+ </p>
2273
+ <p className="text-xs text-muted-foreground">
2274
+ {tExam('form.fields.shuffle.description')}
2275
+ </p>
2276
+ </div>
2277
+ <Controller
2278
+ name="shuffle"
2279
+ control={examForm.control}
2280
+ render={({ field }) => (
2281
+ <Switch
2282
+ checked={field.value}
2283
+ onCheckedChange={field.onChange}
2284
+ />
2285
+ )}
2286
+ />
2287
+ </div>
2288
+ </Field>
2289
+
2290
+ <SheetFooter className="mt-auto px-0">
2291
+ <Button type="submit" disabled={creatingExam} className="gap-2">
2292
+ {creatingExam && <Loader2 className="size-4 animate-spin" />}
2293
+ {t('examForm.actions.create')}
2294
+ </Button>
2295
+ </SheetFooter>
2296
+ </form>
2297
+ </SheetContent>
2298
+ </Sheet>
2299
+
2300
+ {/* Delete Dialog */}
2301
+ <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
2302
+ <DialogContent className="max-w-3xl">
2303
+ <DialogHeader>
2304
+ <DialogTitle className="flex items-center gap-2">
2305
+ <AlertTriangle className="size-5 text-destructive" />{' '}
2306
+ {t('deleteDialog.title')}
2307
+ </DialogTitle>
2308
+ <DialogDescription>
2309
+ {t('deleteDialog.description')}{' '}
2310
+ <strong>{formacaoToDelete?.nome}</strong>?
2311
+ {(formacaoToDelete?.alunos ?? 0) > 0 && (
2312
+ <span className="mt-2 block rounded-md bg-destructive/10 p-2 text-sm text-destructive">
2313
+ {t('deleteDialog.warning', {
2314
+ count: formacaoToDelete?.alunos ?? 0,
2315
+ })}
2316
+ </span>
2317
+ )}
2318
+ </DialogDescription>
2319
+ </DialogHeader>
2320
+ <DialogFooter className="gap-2">
2321
+ <Button
2322
+ variant="outline"
2323
+ onClick={() => setDeleteDialogOpen(false)}
2324
+ >
2325
+ {t('deleteDialog.actions.cancel')}
2326
+ </Button>
2327
+ <Button
2328
+ variant="destructive"
2329
+ onClick={confirmDelete}
2330
+ className="gap-2"
2331
+ >
2332
+ <Trash2 className="size-4" /> {t('deleteDialog.actions.delete')}
2333
+ </Button>
2334
+ </DialogFooter>
2335
+ </DialogContent>
2336
+ </Dialog>
2337
+ </Page>
2338
+ );
2339
+ }