@hed-hog/lms 0.0.268 → 0.0.270

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 (52) hide show
  1. package/hedhog/data/menu.yaml +8 -1
  2. package/hedhog/frontend/app/classes/[id]/page.tsx.ejs +1387 -0
  3. package/hedhog/frontend/app/classes/page.tsx.ejs +4 -4
  4. package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +1237 -0
  5. package/hedhog/frontend/app/courses/[id]/structure/page.tsx.ejs +2642 -0
  6. package/hedhog/frontend/app/courses/page.tsx.ejs +825 -727
  7. package/hedhog/frontend/app/exams/[id]/attempt/page.tsx.ejs +976 -0
  8. package/hedhog/frontend/app/exams/[id]/questions/page.tsx.ejs +931 -0
  9. package/hedhog/frontend/app/exams/page.tsx.ejs +9 -7
  10. package/hedhog/frontend/app/training/page.tsx.ejs +3 -3
  11. package/hedhog/frontend/messages/en.json +703 -14
  12. package/hedhog/frontend/messages/pt.json +863 -174
  13. package/hedhog/query/triggers.sql +0 -0
  14. package/hedhog/table/certificate.yaml +89 -0
  15. package/hedhog/table/certificate_template.yaml +24 -0
  16. package/hedhog/table/course.yaml +67 -1
  17. package/hedhog/table/course_category.yaml +22 -0
  18. package/hedhog/table/course_class_attendance.yaml +34 -0
  19. package/hedhog/table/course_class_group.yaml +58 -0
  20. package/hedhog/table/course_class_session.yaml +38 -0
  21. package/hedhog/table/course_class_session_instructor.yaml +27 -0
  22. package/hedhog/table/course_enrollment.yaml +45 -0
  23. package/hedhog/table/course_image.yaml +33 -0
  24. package/hedhog/table/course_lesson.yaml +35 -0
  25. package/hedhog/table/course_lesson_file.yaml +23 -0
  26. package/hedhog/table/course_lesson_instructor.yaml +27 -0
  27. package/hedhog/table/course_lesson_progress.yaml +40 -0
  28. package/hedhog/table/course_lesson_question.yaml +24 -0
  29. package/hedhog/table/course_module.yaml +25 -0
  30. package/hedhog/table/course_prerequisite.yaml +48 -0
  31. package/hedhog/table/evaluation_rating.yaml +30 -0
  32. package/hedhog/table/evaluation_topic.yaml +68 -0
  33. package/hedhog/table/exam.yaml +91 -0
  34. package/hedhog/table/exam_answer.yaml +40 -0
  35. package/hedhog/table/exam_attempt.yaml +51 -0
  36. package/hedhog/table/exam_image.yaml +33 -0
  37. package/hedhog/table/exam_option.yaml +25 -0
  38. package/hedhog/table/exam_question.yaml +24 -0
  39. package/hedhog/table/image_type.yaml +28 -0
  40. package/hedhog/table/instructor.yaml +23 -0
  41. package/hedhog/table/learning_path.yaml +49 -0
  42. package/hedhog/table/learning_path_enrollment.yaml +33 -0
  43. package/hedhog/table/learning_path_image.yaml +33 -0
  44. package/hedhog/table/learning_path_step.yaml +43 -0
  45. package/hedhog/table/question.yaml +15 -0
  46. package/package.json +9 -6
  47. package/src/index.ts +1 -1
  48. package/src/lms.module.ts +15 -15
  49. package/hedhog/table/classes.yaml +0 -3
  50. package/hedhog/table/exams.yaml +0 -3
  51. package/hedhog/table/reports.yaml +0 -3
  52. package/hedhog/table/training.yaml +0 -3
@@ -45,49 +45,42 @@ import {
45
45
  } from '@/components/ui/sheet';
46
46
  import { Skeleton } from '@/components/ui/skeleton';
47
47
  import { Switch } from '@/components/ui/switch';
48
- import {
49
- Table,
50
- TableBody,
51
- TableCell,
52
- TableHead,
53
- TableHeader,
54
- TableRow,
55
- } from '@/components/ui/table';
56
48
  import { Textarea } from '@/components/ui/textarea';
57
49
  import { zodResolver } from '@hookform/resolvers/zod';
58
50
  import { AnimatePresence, motion } from 'framer-motion';
59
51
  import {
60
52
  AlertTriangle,
61
53
  Archive,
54
+ Award,
62
55
  BarChart3,
63
56
  BookOpen,
64
- CheckSquare,
65
57
  ChevronLeft,
66
58
  ChevronRight,
67
- Copy,
59
+ ChevronsLeft,
60
+ ChevronsRight,
68
61
  Eye,
69
62
  FileCheck,
70
- Filter,
71
63
  GraduationCap,
72
- Hash,
73
64
  LayoutDashboard,
74
65
  Loader2,
75
66
  MoreHorizontal,
76
67
  Pencil,
77
68
  Plus,
78
69
  Search,
70
+ Star,
79
71
  Trash2,
72
+ TrendingUp,
80
73
  Users,
81
74
  X,
82
75
  } from 'lucide-react';
83
76
  import { useTranslations } from 'next-intl';
84
77
  import { usePathname, useRouter } from 'next/navigation';
85
- import { useEffect, useMemo, useState } from 'react';
78
+ import { useEffect, useMemo, useRef, useState } from 'react';
86
79
  import { Controller, useForm } from 'react-hook-form';
87
80
  import { toast } from 'sonner';
88
81
  import { z } from 'zod';
89
82
 
90
- // ── Navigation ───────────────────────────────────────────────────────────────
83
+ // ── Navigation ────────────────────────────────────────────────────────────────
91
84
 
92
85
  const NAV_ITEMS = [
93
86
  { label: 'Dashboard', href: '/', icon: LayoutDashboard },
@@ -98,7 +91,7 @@ const NAV_ITEMS = [
98
91
  { label: 'Relatorios', href: '/relatorios', icon: BarChart3 },
99
92
  ];
100
93
 
101
- // ── Types ────────────────────────────────────────────────────────────────────
94
+ // ── Types ─────────────────────────────────────────────────────────────────────
102
95
 
103
96
  interface Curso {
104
97
  id: number;
@@ -116,10 +109,10 @@ interface Curso {
116
109
  criadoEm: string;
117
110
  }
118
111
 
119
- // ── Schema ───────────────────────────────────────────────────────────────────
112
+ // ── Zod Schema ────────────────────────────────────────────────────────────────
120
113
 
121
- const createCursoSchema = (t: (key: string) => string) =>
122
- z.object({
114
+ function getCursoSchema(t: (key: string) => string) {
115
+ return z.object({
123
116
  codigo: z
124
117
  .string()
125
118
  .min(2, t('form.validation.codeMinLength'))
@@ -143,32 +136,47 @@ const createCursoSchema = (t: (key: string) => string) =>
143
136
  certificado: z.boolean(),
144
137
  listado: z.boolean(),
145
138
  });
139
+ }
146
140
 
147
- type CursoForm = z.infer<ReturnType<typeof createCursoSchema>>;
141
+ type CursoForm = {
142
+ codigo: string;
143
+ nomeInterno: string;
144
+ tituloComercial: string;
145
+ descricao: string;
146
+ nivel: 'iniciante' | 'intermediario' | 'avancado';
147
+ status: 'ativo' | 'rascunho' | 'arquivado';
148
+ categorias: string[];
149
+ destaque: boolean;
150
+ certificado: boolean;
151
+ listado: boolean;
152
+ };
148
153
 
149
- // ── Constants ────────────────────────────────────────────────────────────────
154
+ // ── Constants ─────────────────────────────────────────────────────────────────
150
155
 
151
156
  const CATEGORIAS = [
152
- 'technology',
153
- 'design',
154
- 'management',
155
- 'marketing',
156
- 'finance',
157
- 'health',
158
- 'languages',
159
- 'law',
160
- ] as const;
161
-
162
- const STATUS_MAP: Record<
163
- string,
164
- { variant: 'default' | 'secondary' | 'outline' }
165
- > = {
166
- ativo: { variant: 'default' },
167
- rascunho: { variant: 'secondary' },
168
- arquivado: { variant: 'outline' },
157
+ 'Tecnologia',
158
+ 'Design',
159
+ 'Gestao',
160
+ 'Marketing',
161
+ 'Financas',
162
+ 'Saude',
163
+ 'Idiomas',
164
+ 'Direito',
165
+ ];
166
+
167
+ const NIVEL_COLOR: Record<string, string> = {
168
+ iniciante: 'bg-emerald-50 text-emerald-700 border-emerald-200',
169
+ intermediario: 'bg-amber-50 text-amber-700 border-amber-200',
170
+ avancado: 'bg-red-50 text-red-700 border-red-200',
171
+ };
172
+
173
+ const STATUS_VARIANT: Record<string, 'default' | 'secondary' | 'outline'> = {
174
+ ativo: 'default',
175
+ rascunho: 'secondary',
176
+ arquivado: 'outline',
169
177
  };
170
178
 
171
- // ── Seed Data ────────────────────────────────────────────────────────────────
179
+ // ── Seed Data ─────────────────────────────────────────────────────────────────
172
180
 
173
181
  const initialCursos: Curso[] = [
174
182
  {
@@ -177,10 +185,10 @@ const initialCursos: Curso[] = [
177
185
  nomeInterno: 'react-avancado',
178
186
  tituloComercial: 'React Avancado',
179
187
  descricao:
180
- 'Curso completo de React com hooks, context e patterns avancados para aplicacoes modernas',
188
+ 'Curso completo de React com hooks, context e patterns avancados para aplicacoes modernas.',
181
189
  nivel: 'avancado',
182
190
  status: 'ativo',
183
- categorias: ['technology'],
191
+ categorias: ['Tecnologia'],
184
192
  destaque: true,
185
193
  certificado: true,
186
194
  listado: true,
@@ -193,10 +201,10 @@ const initialCursos: Curso[] = [
193
201
  nomeInterno: 'ux-fundamentals',
194
202
  tituloComercial: 'UX Design Fundamentals',
195
203
  descricao:
196
- 'Fundamentos de design de experiencia do usuario com ferramentas modernas e pesquisa',
204
+ 'Fundamentos de design de experiencia do usuario com ferramentas modernas e pesquisa.',
197
205
  nivel: 'iniciante',
198
206
  status: 'ativo',
199
- categorias: ['design'],
207
+ categorias: ['Design'],
200
208
  destaque: false,
201
209
  certificado: true,
202
210
  listado: true,
@@ -209,10 +217,10 @@ const initialCursos: Curso[] = [
209
217
  nomeInterno: 'gestao-agil',
210
218
  tituloComercial: 'Gestao de Projetos Ageis',
211
219
  descricao:
212
- 'Metodologias ageis para gestao de projetos incluindo Scrum, Kanban e SAFe em equipes',
220
+ 'Metodologias ageis para gestao de projetos incluindo Scrum, Kanban e SAFe.',
213
221
  nivel: 'intermediario',
214
222
  status: 'ativo',
215
- categorias: ['management'],
223
+ categorias: ['Gestao'],
216
224
  destaque: true,
217
225
  certificado: true,
218
226
  listado: true,
@@ -225,10 +233,10 @@ const initialCursos: Curso[] = [
225
233
  nomeInterno: 'marketing-digital',
226
234
  tituloComercial: 'Marketing Digital Completo',
227
235
  descricao:
228
- 'Estrategias de marketing digital para negocios modernos incluindo SEO, SEM e redes sociais',
236
+ 'Estrategias de marketing digital para negocios modernos incluindo SEO, SEM e redes sociais.',
229
237
  nivel: 'intermediario',
230
238
  status: 'rascunho',
231
- categorias: ['marketing'],
239
+ categorias: ['Marketing'],
232
240
  destaque: false,
233
241
  certificado: false,
234
242
  listado: false,
@@ -241,10 +249,10 @@ const initialCursos: Curso[] = [
241
249
  nomeInterno: 'python-data-science',
242
250
  tituloComercial: 'Python para Data Science',
243
251
  descricao:
244
- 'Introducao a Python focado em ciencia de dados, analise estatistica e machine learning basico',
252
+ 'Introducao a Python focado em ciencia de dados, analise estatistica e machine learning.',
245
253
  nivel: 'intermediario',
246
254
  status: 'ativo',
247
- categorias: ['technology'],
255
+ categorias: ['Tecnologia'],
248
256
  destaque: false,
249
257
  certificado: true,
250
258
  listado: true,
@@ -257,10 +265,10 @@ const initialCursos: Curso[] = [
257
265
  nomeInterno: 'node-completo',
258
266
  tituloComercial: 'Node.js Completo',
259
267
  descricao:
260
- 'Backend com Node.js, Express e bancos de dados relacionais e NoSQL para APIs robustas',
268
+ 'Backend com Node.js, Express e bancos de dados relacionais e NoSQL para APIs robustas.',
261
269
  nivel: 'avancado',
262
270
  status: 'ativo',
263
- categorias: ['technology'],
271
+ categorias: ['Tecnologia'],
264
272
  destaque: true,
265
273
  certificado: true,
266
274
  listado: true,
@@ -273,10 +281,10 @@ const initialCursos: Curso[] = [
273
281
  nomeInterno: 'figma-iniciantes',
274
282
  tituloComercial: 'Figma para Iniciantes',
275
283
  descricao:
276
- 'Aprenda a usar o Figma do zero para criar interfaces profissionais e prototipos interativos',
284
+ 'Aprenda a usar o Figma do zero para criar interfaces profissionais e prototipos interativos.',
277
285
  nivel: 'iniciante',
278
286
  status: 'arquivado',
279
- categorias: ['design'],
287
+ categorias: ['Design'],
280
288
  destaque: false,
281
289
  certificado: true,
282
290
  listado: false,
@@ -289,10 +297,10 @@ const initialCursos: Curso[] = [
289
297
  nomeInterno: 'lideranca-comunicacao',
290
298
  tituloComercial: 'Lideranca e Comunicacao',
291
299
  descricao:
292
- 'Desenvolva habilidades de lideranca e comunicacao assertiva para ambientes corporativos',
300
+ 'Desenvolva habilidades de lideranca e comunicacao assertiva para ambientes corporativos.',
293
301
  nivel: 'iniciante',
294
302
  status: 'ativo',
295
- categorias: ['management'],
303
+ categorias: ['Gestao'],
296
304
  destaque: false,
297
305
  certificado: true,
298
306
  listado: true,
@@ -305,10 +313,10 @@ const initialCursos: Curso[] = [
305
313
  nomeInterno: 'seo-avancado',
306
314
  tituloComercial: 'SEO Avancado',
307
315
  descricao:
308
- 'Tecnicas avancadas de otimizacao para motores de busca, conteudo web e performance de sites',
316
+ 'Tecnicas avancadas de otimizacao para motores de busca, conteudo web e performance.',
309
317
  nivel: 'avancado',
310
318
  status: 'rascunho',
311
- categorias: ['marketing', 'technology'],
319
+ categorias: ['Marketing', 'Tecnologia'],
312
320
  destaque: false,
313
321
  certificado: false,
314
322
  listado: false,
@@ -321,10 +329,10 @@ const initialCursos: Curso[] = [
321
329
  nomeInterno: 'typescript-pratica',
322
330
  tituloComercial: 'TypeScript na Pratica',
323
331
  descricao:
324
- 'TypeScript aplicado em projetos reais com boas praticas, design patterns e testes',
332
+ 'TypeScript aplicado em projetos reais com boas praticas, design patterns e testes.',
325
333
  nivel: 'intermediario',
326
334
  status: 'ativo',
327
- categorias: ['technology'],
335
+ categorias: ['Tecnologia'],
328
336
  destaque: true,
329
337
  certificado: true,
330
338
  listado: true,
@@ -337,10 +345,10 @@ const initialCursos: Curso[] = [
337
345
  nomeInterno: 'design-system',
338
346
  tituloComercial: 'Design System Completo',
339
347
  descricao:
340
- 'Como criar e manter um design system escalavel para grandes equipes de produto e engenharia',
348
+ 'Como criar e manter um design system escalavel para grandes equipes de produto.',
341
349
  nivel: 'avancado',
342
350
  status: 'ativo',
343
- categorias: ['design', 'technology'],
351
+ categorias: ['Design', 'Tecnologia'],
344
352
  destaque: false,
345
353
  certificado: true,
346
354
  listado: true,
@@ -353,10 +361,10 @@ const initialCursos: Curso[] = [
353
361
  nomeInterno: 'excel-negocios',
354
362
  tituloComercial: 'Excel para Negocios',
355
363
  descricao:
356
- 'Domine Excel com formulas avancadas, dashboards profissionais e analise de dados empresariais',
364
+ 'Domine Excel com formulas avancadas, dashboards profissionais e analise de dados.',
357
365
  nivel: 'iniciante',
358
366
  status: 'ativo',
359
- categorias: ['management', 'finance'],
367
+ categorias: ['Gestao', 'Financas'],
360
368
  destaque: false,
361
369
  certificado: true,
362
370
  listado: true,
@@ -369,10 +377,10 @@ const initialCursos: Curso[] = [
369
377
  nomeInterno: 'financas-pessoais',
370
378
  tituloComercial: 'Financas Pessoais',
371
379
  descricao:
372
- 'Aprenda a gerenciar suas financas, investir e planejar sua aposentadoria de forma inteligente',
380
+ 'Aprenda a gerenciar suas financas, investir e planejar sua aposentadoria de forma inteligente.',
373
381
  nivel: 'iniciante',
374
382
  status: 'ativo',
375
- categorias: ['finance'],
383
+ categorias: ['Financas'],
376
384
  destaque: false,
377
385
  certificado: true,
378
386
  listado: true,
@@ -385,10 +393,10 @@ const initialCursos: Curso[] = [
385
393
  nomeInterno: 'flutter-mobile',
386
394
  tituloComercial: 'Flutter para Mobile',
387
395
  descricao:
388
- 'Desenvolvimento de aplicativos moveis multiplataforma com Flutter e Dart do zero ao deploy',
396
+ 'Desenvolvimento de aplicativos moveis multiplataforma com Flutter e Dart do zero.',
389
397
  nivel: 'intermediario',
390
398
  status: 'ativo',
391
- categorias: ['technology'],
399
+ categorias: ['Tecnologia'],
392
400
  destaque: true,
393
401
  certificado: true,
394
402
  listado: true,
@@ -401,10 +409,10 @@ const initialCursos: Curso[] = [
401
409
  nomeInterno: 'direito-trabalhista',
402
410
  tituloComercial: 'Direito Trabalhista Essencial',
403
411
  descricao:
404
- 'Conceitos essenciais de direito trabalhista para gestores de RH e empreendedores',
412
+ 'Conceitos essenciais de direito trabalhista para gestores de RH e empreendedores.',
405
413
  nivel: 'iniciante',
406
414
  status: 'rascunho',
407
- categorias: ['law', 'management'],
415
+ categorias: ['Direito', 'Gestao'],
408
416
  destaque: false,
409
417
  certificado: false,
410
418
  listado: false,
@@ -413,28 +421,28 @@ const initialCursos: Curso[] = [
413
421
  },
414
422
  ];
415
423
 
416
- const ITEMS_PER_PAGE = 8;
424
+ const PAGE_SIZES = [6, 12, 24];
417
425
 
418
- // ── Animations ───────────────────────────────────────────────────────────────
426
+ // ── Animations ────────────────────────────────────────────────────────────────
427
+
428
+ const fadeUp = {
429
+ hidden: { opacity: 0, y: 16 },
430
+ show: { opacity: 1, y: 0, transition: { duration: 0.3 } },
431
+ };
419
432
 
420
433
  const stagger = {
421
434
  hidden: {},
422
- show: { transition: { staggerChildren: 0.06 } },
435
+ show: { transition: { staggerChildren: 0.05 } },
423
436
  };
424
437
 
425
- const fadeUp = {
426
- hidden: { opacity: 0, y: 16 },
427
- show: { opacity: 1, y: 0, transition: { duration: 0.35 } },
428
- } as const;
429
-
430
- // ── Component ────────────────────────────────────────────────────────────────
438
+ // ── Component ─────────────────────────────────────────────────────────────────
431
439
 
432
440
  export default function CursosPage() {
433
441
  const t = useTranslations('lms.CoursesPage');
434
442
  const pathname = usePathname();
435
443
  const router = useRouter();
436
444
 
437
- // UI states
445
+ // UI
438
446
  const [loading, setLoading] = useState(true);
439
447
  const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
440
448
  const [sheetOpen, setSheetOpen] = useState(false);
@@ -449,17 +457,29 @@ export default function CursosPage() {
449
457
  // Selection
450
458
  const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
451
459
 
452
- // Filters
453
- const [busca, setBusca] = useState('');
454
- const [filtroStatus, setFiltroStatus] = useState('todos');
455
- const [filtroNivel, setFiltroNivel] = useState('todos');
456
- const [filtroCategoria, setFiltroCategoria] = useState('todos');
460
+ // Search/filter state (controlled by form submit)
461
+ const [buscaInput, setBuscaInput] = useState('');
462
+ const [filtroStatusInput, setFiltroStatusInput] = useState('todos');
463
+ const [filtroNivelInput, setFiltroNivelInput] = useState('todos');
464
+ const [filtroCatInput, setFiltroCatInput] = useState('todos');
465
+
466
+ // Applied filters (only updated on submit)
467
+ const [buscaApplied, setBuscaApplied] = useState('');
468
+ const [filtroStatusApplied, setFiltroStatusApplied] = useState('todos');
469
+ const [filtroNivelApplied, setFiltroNivelApplied] = useState('todos');
470
+ const [filtroCatApplied, setFiltroCatApplied] = useState('todos');
457
471
 
458
472
  // Pagination
459
473
  const [currentPage, setCurrentPage] = useState(1);
474
+ const [pageSize, setPageSize] = useState(12);
475
+
476
+ // Double-click tracking
477
+ const clickTimers = useRef<Map<number, ReturnType<typeof setTimeout>>>(
478
+ new Map()
479
+ );
460
480
 
461
481
  const form = useForm<CursoForm>({
462
- resolver: zodResolver(createCursoSchema(t)),
482
+ resolver: zodResolver(getCursoSchema(t)),
463
483
  defaultValues: {
464
484
  codigo: '',
465
485
  nomeInterno: '',
@@ -475,74 +495,109 @@ export default function CursosPage() {
475
495
  });
476
496
 
477
497
  useEffect(() => {
478
- const t = setTimeout(() => setLoading(false), 900);
498
+ const t = setTimeout(() => setLoading(false), 800);
479
499
  return () => clearTimeout(t);
480
500
  }, []);
481
501
 
482
- // ── Filtering ──────────────────────────────────────────────────────────────
502
+ // ── Filtering (applied on search submit) ─────────────────────────────────
483
503
 
484
504
  const filteredCursos = useMemo(() => {
485
505
  return cursos.filter((c) => {
486
- const q = busca.toLowerCase();
506
+ const q = buscaApplied.toLowerCase();
487
507
  const matchSearch =
488
508
  !q ||
489
509
  c.codigo.toLowerCase().includes(q) ||
490
510
  c.tituloComercial.toLowerCase().includes(q) ||
491
511
  c.nomeInterno.toLowerCase().includes(q);
492
- const matchStatus = filtroStatus === 'todos' || c.status === filtroStatus;
493
- const matchNivel = filtroNivel === 'todos' || c.nivel === filtroNivel;
512
+ const matchStatus =
513
+ filtroStatusApplied === 'todos' || c.status === filtroStatusApplied;
514
+ const matchNivel =
515
+ filtroNivelApplied === 'todos' || c.nivel === filtroNivelApplied;
494
516
  const matchCategoria =
495
- filtroCategoria === 'todos' || c.categorias.includes(filtroCategoria);
517
+ filtroCatApplied === 'todos' || c.categorias.includes(filtroCatApplied);
496
518
  return matchSearch && matchStatus && matchNivel && matchCategoria;
497
519
  });
498
- }, [cursos, busca, filtroStatus, filtroNivel, filtroCategoria]);
520
+ }, [
521
+ cursos,
522
+ buscaApplied,
523
+ filtroStatusApplied,
524
+ filtroNivelApplied,
525
+ filtroCatApplied,
526
+ ]);
499
527
 
500
- const totalPages = Math.max(
501
- 1,
502
- Math.ceil(filteredCursos.length / ITEMS_PER_PAGE)
503
- );
528
+ const totalPages = Math.max(1, Math.ceil(filteredCursos.length / pageSize));
504
529
  const safePage = Math.min(currentPage, totalPages);
505
530
  const paginatedCursos = filteredCursos.slice(
506
- (safePage - 1) * ITEMS_PER_PAGE,
507
- safePage * ITEMS_PER_PAGE
531
+ (safePage - 1) * pageSize,
532
+ safePage * pageSize
508
533
  );
509
534
 
510
- // Reset page on filter change
511
- useEffect(() => {
535
+ function handleSearch(e: React.FormEvent) {
536
+ e.preventDefault();
537
+ setBuscaApplied(buscaInput);
538
+ setFiltroStatusApplied(filtroStatusInput);
539
+ setFiltroNivelApplied(filtroNivelInput);
540
+ setFiltroCatApplied(filtroCatInput);
512
541
  setCurrentPage(1);
513
- }, [busca, filtroStatus, filtroNivel, filtroCategoria]);
542
+ }
514
543
 
515
- // ── Selection ──────────────────────────────────────────────────────────────
544
+ function clearFilters() {
545
+ setBuscaInput('');
546
+ setFiltroStatusInput('todos');
547
+ setFiltroNivelInput('todos');
548
+ setFiltroCatInput('todos');
549
+ setBuscaApplied('');
550
+ setFiltroStatusApplied('todos');
551
+ setFiltroNivelApplied('todos');
552
+ setFiltroCatApplied('todos');
553
+ setCurrentPage(1);
554
+ }
516
555
 
517
- const allPageSelected =
518
- paginatedCursos.length > 0 &&
519
- paginatedCursos.every((c) => selectedIds.has(c.id));
556
+ const hasActiveFilters =
557
+ buscaApplied ||
558
+ filtroStatusApplied !== 'todos' ||
559
+ filtroNivelApplied !== 'todos' ||
560
+ filtroCatApplied !== 'todos';
520
561
 
521
- function toggleSelectAll() {
562
+ // ── Double-click to navigate ──────────────────────────────────────────────
563
+
564
+ function handleCardClick(curso: Curso) {
565
+ const existing = clickTimers.current.get(curso.id);
566
+ if (existing) {
567
+ clearTimeout(existing);
568
+ clickTimers.current.delete(curso.id);
569
+ router.push(`/lms/courses/${curso.id}`);
570
+ } else {
571
+ const t = setTimeout(() => {
572
+ clickTimers.current.delete(curso.id);
573
+ }, 300);
574
+ clickTimers.current.set(curso.id, t);
575
+ }
576
+ }
577
+
578
+ // ── Selection ─────────────────────────────────────────────────────────────
579
+
580
+ function toggleSelect(id: number, e: React.MouseEvent) {
581
+ e.stopPropagation();
522
582
  setSelectedIds((prev) => {
523
583
  const next = new Set(prev);
524
- if (allPageSelected) {
525
- paginatedCursos.forEach((c) => next.delete(c.id));
526
- } else {
527
- paginatedCursos.forEach((c) => next.add(c.id));
528
- }
584
+ next.has(id) ? next.delete(id) : next.add(id);
529
585
  return next;
530
586
  });
531
587
  }
532
588
 
533
- function toggleSelect(id: number) {
589
+ function toggleSelectAll() {
590
+ const allSelected = paginatedCursos.every((c) => selectedIds.has(c.id));
534
591
  setSelectedIds((prev) => {
535
592
  const next = new Set(prev);
536
- if (next.has(id)) {
537
- next.delete(id);
538
- } else {
539
- next.add(id);
540
- }
593
+ allSelected
594
+ ? paginatedCursos.forEach((c) => next.delete(c.id))
595
+ : paginatedCursos.forEach((c) => next.add(c.id));
541
596
  return next;
542
597
  });
543
598
  }
544
599
 
545
- // ── CRUD ───────────────────────────────────────────────────────────────────
600
+ // ── CRUD ──────────────────────────────────────────────────────────────────
546
601
 
547
602
  function openCreateSheet() {
548
603
  setEditingCurso(null);
@@ -561,7 +616,8 @@ export default function CursosPage() {
561
616
  setSheetOpen(true);
562
617
  }
563
618
 
564
- function openEditSheet(curso: Curso) {
619
+ function openEditSheet(curso: Curso, e?: React.MouseEvent) {
620
+ e?.stopPropagation();
565
621
  setEditingCurso(curso);
566
622
  form.reset({
567
623
  codigo: curso.codigo,
@@ -580,9 +636,7 @@ export default function CursosPage() {
580
636
 
581
637
  async function onSubmit(data: CursoForm) {
582
638
  setSaving(true);
583
- // Simula um delay de API
584
- await new Promise((r) => setTimeout(r, 600));
585
-
639
+ await new Promise((r) => setTimeout(r, 500));
586
640
  if (editingCurso) {
587
641
  setCursos((prev) =>
588
642
  prev.map((c) => (c.id === editingCurso.id ? { ...c, ...data } : c))
@@ -592,64 +646,84 @@ export default function CursosPage() {
592
646
  setSheetOpen(false);
593
647
  } else {
594
648
  const newId = Date.now();
595
- const newCurso: Curso = {
596
- id: newId,
597
- ...data,
598
- alunosInscritos: 0,
599
- criadoEm: new Date().toISOString().split('T')[0] || '',
600
- };
601
- setCursos((prev) => [newCurso, ...prev]);
649
+ setCursos((prev) => [
650
+ {
651
+ id: newId,
652
+ ...data,
653
+ alunosInscritos: 0,
654
+ criadoEm: new Date().toISOString().split('T')[0] ?? '',
655
+ },
656
+ ...prev,
657
+ ]);
602
658
  toast.success(t('toasts.courseCreated'));
603
659
  setSaving(false);
604
660
  setSheetOpen(false);
605
- // Redirecionar para pagina dedicada do curso
606
- setTimeout(() => {
607
- router.push(`/cursos/${newId}`);
608
- }, 400);
661
+ setTimeout(() => router.push(`/lms/courses/${newId}`), 400);
609
662
  }
610
663
  }
611
664
 
612
665
  function confirmDelete() {
613
- if (cursoToDelete) {
614
- setCursos((prev) => prev.filter((c) => c.id !== cursoToDelete.id));
615
- setSelectedIds((prev) => {
616
- const next = new Set(prev);
617
- next.delete(cursoToDelete.id);
618
- return next;
619
- });
620
- toast.success(
621
- t('toasts.courseRemoved', { title: cursoToDelete.tituloComercial })
622
- );
623
- setCursoToDelete(null);
624
- setDeleteDialogOpen(false);
625
- }
626
- }
627
-
628
- function bulkDelete() {
629
- if (selectedIds.size === 0) return;
630
- setCursos((prev) => prev.filter((c) => !selectedIds.has(c.id)));
631
- toast.success(t('toasts.coursesRemoved', { count: selectedIds.size }));
632
- setSelectedIds(new Set());
633
- }
634
-
635
- function bulkArchive() {
636
- if (selectedIds.size === 0) return;
637
- setCursos((prev) =>
638
- prev.map((c) =>
639
- selectedIds.has(c.id) ? { ...c, status: 'arquivado' as const } : c
640
- )
666
+ if (!cursoToDelete) return;
667
+ setCursos((prev) => prev.filter((c) => c.id !== cursoToDelete.id));
668
+ setSelectedIds((prev) => {
669
+ const n = new Set(prev);
670
+ n.delete(cursoToDelete.id);
671
+ return n;
672
+ });
673
+ toast.success(
674
+ t('toasts.courseRemoved', { title: cursoToDelete.tituloComercial })
641
675
  );
642
- toast.success(t('toasts.coursesArchived', { count: selectedIds.size }));
643
- setSelectedIds(new Set());
676
+ setCursoToDelete(null);
677
+ setDeleteDialogOpen(false);
644
678
  }
645
679
 
646
- // ── Count badges ───────────────────────────────────────────────────────────
680
+ // ── KPI counts ────────────────────────────────────────────────────────────
647
681
 
682
+ const totalAlunos = cursos.reduce((a, c) => a + c.alunosInscritos, 0);
648
683
  const countAtivos = cursos.filter((c) => c.status === 'ativo').length;
649
684
  const countRascunhos = cursos.filter((c) => c.status === 'rascunho').length;
650
- const countArquivados = cursos.filter((c) => c.status === 'arquivado').length;
685
+ const countDestaque = cursos.filter((c) => c.destaque).length;
651
686
 
652
- // ── Render ─────────────────────────────────────────────────────────────────
687
+ const kpis = [
688
+ {
689
+ label: t('kpis.totalCourses.label'),
690
+ valor: cursos.length,
691
+ sub: t('kpis.totalCourses.sub'),
692
+ icon: BookOpen,
693
+ iconBg: 'bg-orange-100',
694
+ iconColor: 'text-orange-600',
695
+ },
696
+ {
697
+ label: t('kpis.activeCourses.label'),
698
+ valor: countAtivos,
699
+ sub: t('kpis.activeCourses.sub'),
700
+ icon: TrendingUp,
701
+ iconBg: 'bg-muted',
702
+ iconColor: 'text-foreground',
703
+ },
704
+ {
705
+ label: t('kpis.totalStudents.label'),
706
+ valor: totalAlunos.toLocaleString('pt-BR'),
707
+ sub: t('kpis.totalStudents.sub'),
708
+ icon: Users,
709
+ iconBg: 'bg-muted',
710
+ iconColor: 'text-foreground',
711
+ },
712
+ {
713
+ label: t('kpis.featured.label'),
714
+ valor: countDestaque,
715
+ sub: t('kpis.featured.sub'),
716
+ icon: Star,
717
+ iconBg: 'bg-muted',
718
+ iconColor: 'text-foreground',
719
+ },
720
+ ];
721
+
722
+ const allPageSelected =
723
+ paginatedCursos.length > 0 &&
724
+ paginatedCursos.every((c) => selectedIds.has(c.id));
725
+
726
+ // ── Render ────────────────────────────────────────────────────────────────
653
727
 
654
728
  return (
655
729
  <Page>
@@ -673,436 +747,507 @@ export default function CursosPage() {
673
747
  }
674
748
  />
675
749
 
676
- <motion.div initial="hidden" animate="show" variants={stagger}>
677
- {/* Filters */}
678
- <motion.div variants={fadeUp}>
679
- <Card className="mb-6">
680
- <CardContent className="p-4">
681
- <div className="flex flex-col gap-3 lg:flex-row lg:items-center">
682
- <div className="relative flex-1">
683
- <Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
684
- <Input
685
- placeholder={t('filters.searchPlaceholder')}
686
- value={busca}
687
- onChange={(e) => setBusca(e.target.value)}
688
- className="pl-9"
689
- />
690
- </div>
691
- <div className="flex flex-wrap items-center gap-2">
692
- <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
693
- <Filter className="size-3.5" />
694
- {t('filters.filtersLabel')}
695
- </div>
696
- <Select value={filtroStatus} onValueChange={setFiltroStatus}>
697
- <SelectTrigger className="h-9 w-[130px] text-xs">
698
- <SelectValue placeholder={t('status.active')} />
699
- </SelectTrigger>
700
- <SelectContent>
701
- <SelectItem value="todos">
702
- {t('filters.allStatuses')}
703
- </SelectItem>
704
- <SelectItem value="ativo">
705
- {t('status.active')}
706
- </SelectItem>
707
- <SelectItem value="rascunho">
708
- {t('status.draft')}
709
- </SelectItem>
710
- <SelectItem value="arquivado">
711
- {t('status.archived')}
712
- </SelectItem>
713
- </SelectContent>
714
- </Select>
715
- <Select value={filtroNivel} onValueChange={setFiltroNivel}>
716
- <SelectTrigger className="h-9 w-[140px] text-xs">
717
- <SelectValue placeholder={t('levels.beginner')} />
718
- </SelectTrigger>
719
- <SelectContent>
720
- <SelectItem value="todos">
721
- {t('filters.allLevels')}
722
- </SelectItem>
723
- <SelectItem value="iniciante">
724
- {t('levels.beginner')}
725
- </SelectItem>
726
- <SelectItem value="intermediario">
727
- {t('levels.intermediate')}
728
- </SelectItem>
729
- <SelectItem value="avancado">
730
- {t('levels.advanced')}
731
- </SelectItem>
732
- </SelectContent>
733
- </Select>
734
- <Select
735
- value={filtroCategoria}
736
- onValueChange={setFiltroCategoria}
737
- >
738
- <SelectTrigger className="h-9 w-[140px] text-xs">
739
- <SelectValue placeholder={t('categories.technology')} />
740
- </SelectTrigger>
741
- <SelectContent>
742
- <SelectItem value="todos">
743
- {t('filters.allCategories')}
744
- </SelectItem>
745
- {CATEGORIAS.map((cat) => (
746
- <SelectItem key={cat} value={cat}>
747
- {t(`categories.${cat}`)}
748
- </SelectItem>
749
- ))}
750
- </SelectContent>
751
- </Select>
752
- {(filtroStatus !== 'todos' ||
753
- filtroNivel !== 'todos' ||
754
- filtroCategoria !== 'todos' ||
755
- busca) && (
756
- <Button
757
- variant="ghost"
758
- size="sm"
759
- className="h-9 text-xs text-muted-foreground"
760
- onClick={() => {
761
- setFiltroStatus('todos');
762
- setFiltroNivel('todos');
763
- setFiltroCategoria('todos');
764
- setBusca('');
765
- }}
750
+ {/* ── KPI Cards ────────────────────────────────────────────────────── */}
751
+ <div className="mb-2 grid grid-cols-2 gap-4 lg:grid-cols-4">
752
+ {loading
753
+ ? Array.from({ length: 4 }).map((_, i) => (
754
+ <Card key={i}>
755
+ <CardContent className="p-4">
756
+ <Skeleton className="mb-2 h-8 w-16" />
757
+ <Skeleton className="h-4 w-28" />
758
+ </CardContent>
759
+ </Card>
760
+ ))
761
+ : kpis.map((kpi, i) => (
762
+ <motion.div
763
+ key={kpi.label}
764
+ initial={{ opacity: 0, y: 12 }}
765
+ animate={{ opacity: 1, y: 0 }}
766
+ transition={{ delay: i * 0.07 }}
767
+ >
768
+ <Card className="overflow-hidden">
769
+ <CardContent className="flex items-start justify-between p-5">
770
+ <div>
771
+ <p className="text-sm text-muted-foreground">
772
+ {kpi.label}
773
+ </p>
774
+ <p className="mt-1 text-3xl font-bold tracking-tight">
775
+ {kpi.valor}
776
+ </p>
777
+ <p className="mt-0.5 text-xs text-muted-foreground">
778
+ {kpi.sub}
779
+ </p>
780
+ </div>
781
+ <div
782
+ className={`flex size-10 shrink-0 items-center justify-center rounded-lg ${kpi.iconBg}`}
766
783
  >
767
- <X className="mr-1 size-3" />
768
- {t('filters.clear')}
769
- </Button>
770
- )}
771
- </div>
772
- </div>
773
- </CardContent>
774
- </Card>
775
- </motion.div>
784
+ <kpi.icon className={`size-5 ${kpi.iconColor}`} />
785
+ </div>
786
+ </CardContent>
787
+ </Card>
788
+ </motion.div>
789
+ ))}
790
+ </div>
791
+
792
+ {/* ── Search bar ───────────────────────────────────────────────────── */}
793
+ <form onSubmit={handleSearch} className="mb-2 mt-0">
794
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
795
+ {/* Search input — grows to fill space */}
796
+ <div className="relative flex-1">
797
+ <Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
798
+ <Input
799
+ placeholder={t('filters.searchPlaceholder')}
800
+ value={buscaInput}
801
+ onChange={(e) => setBuscaInput(e.target.value)}
802
+ className="pl-9"
803
+ />
804
+ </div>
805
+
806
+ {/* Filters — inline, right of search */}
807
+ <div className="flex flex-wrap items-center gap-2">
808
+ <Select
809
+ value={filtroStatusInput}
810
+ onValueChange={setFiltroStatusInput}
811
+ >
812
+ <SelectTrigger className="h-9 w-[130px] text-sm">
813
+ <SelectValue placeholder={t('table.headers.status')} />
814
+ </SelectTrigger>
815
+ <SelectContent>
816
+ <SelectItem value="todos">
817
+ {t('filters.allStatuses')}
818
+ </SelectItem>
819
+ <SelectItem value="ativo">{t('status.active')}</SelectItem>
820
+ <SelectItem value="rascunho">{t('status.draft')}</SelectItem>
821
+ <SelectItem value="arquivado">
822
+ {t('status.archived')}
823
+ </SelectItem>
824
+ </SelectContent>
825
+ </Select>
776
826
 
777
- {/* Bulk actions bar */}
778
- <AnimatePresence>
779
- {selectedIds.size > 0 && (
780
- <motion.div
781
- initial={{ opacity: 0, y: -8, height: 0 }}
782
- animate={{ opacity: 1, y: 0, height: 'auto' }}
783
- exit={{ opacity: 0, y: -8, height: 0 }}
784
- transition={{ duration: 0.2 }}
785
- className="mb-4 overflow-hidden"
827
+ <Select
828
+ value={filtroNivelInput}
829
+ onValueChange={setFiltroNivelInput}
786
830
  >
787
- <div className="flex items-center gap-3 rounded-lg border bg-muted/50 px-4 py-2.5">
788
- <CheckSquare className="size-4 text-muted-foreground" />
789
- <span className="text-sm font-medium">
831
+ <SelectTrigger className="h-9 w-[130px] text-sm">
832
+ <SelectValue placeholder={t('table.headers.level')} />
833
+ </SelectTrigger>
834
+ <SelectContent>
835
+ <SelectItem value="todos">{t('filters.allLevels')}</SelectItem>
836
+ <SelectItem value="iniciante">
837
+ {t('levels.beginner')}
838
+ </SelectItem>
839
+ <SelectItem value="intermediario">
840
+ {t('levels.intermediate')}
841
+ </SelectItem>
842
+ <SelectItem value="avancado">{t('levels.advanced')}</SelectItem>
843
+ </SelectContent>
844
+ </Select>
845
+
846
+ <Select value={filtroCatInput} onValueChange={setFiltroCatInput}>
847
+ <SelectTrigger className="h-9 w-[130px] text-sm">
848
+ <SelectValue placeholder={t('table.headers.categories')} />
849
+ </SelectTrigger>
850
+ <SelectContent>
851
+ <SelectItem value="todos">
852
+ {t('filters.allCategories')}
853
+ </SelectItem>
854
+ {CATEGORIAS.map((c) => (
855
+ <SelectItem key={c} value={c}>
856
+ {c}
857
+ </SelectItem>
858
+ ))}
859
+ </SelectContent>
860
+ </Select>
861
+
862
+ {hasActiveFilters && (
863
+ <Button
864
+ type="button"
865
+ variant="ghost"
866
+ size="sm"
867
+ onClick={clearFilters}
868
+ className="h-9 text-muted-foreground"
869
+ >
870
+ <X className="mr-1 size-3.5" />
871
+ {t('filters.clear')}
872
+ </Button>
873
+ )}
874
+
875
+ {/* Submit button */}
876
+ <Button type="submit" size="sm" className="h-9 gap-2">
877
+ <Search className="size-3.5" />
878
+ {t('filters.search')}
879
+ </Button>
880
+ </div>
881
+ </div>
882
+ </form>
883
+
884
+ {/* ── Bulk action bar ───────────────────────────────────────────────── */}
885
+ <AnimatePresence>
886
+ {selectedIds.size > 0 && (
887
+ <motion.div
888
+ initial={{ opacity: 0, y: -8 }}
889
+ animate={{ opacity: 1, y: 0 }}
890
+ exit={{ opacity: 0, y: -8 }}
891
+ className="mb-4"
892
+ >
893
+ <Card className="border-foreground/20 bg-foreground">
894
+ <CardContent className="flex flex-wrap items-center gap-3 p-3">
895
+ <span className="text-sm font-medium text-background">
790
896
  {t('bulkActions.selected', { count: selectedIds.size })}
791
897
  </span>
792
- <Separator orientation="vertical" className="h-5" />
898
+ <Separator
899
+ orientation="vertical"
900
+ className="h-5 bg-background/20"
901
+ />
793
902
  <Button
794
- variant="outline"
795
903
  size="sm"
796
- className="h-7 gap-1.5 text-xs"
797
- onClick={bulkArchive}
904
+ variant="secondary"
905
+ className="gap-1.5"
906
+ onClick={() => {
907
+ toast.info(
908
+ t('toasts.coursesArchived', { count: selectedIds.size })
909
+ );
910
+ setSelectedIds(new Set());
911
+ }}
798
912
  >
799
- <Archive className="size-3" />
800
- {t('bulkActions.archive')}
913
+ <Archive className="size-3.5" /> {t('bulkActions.archive')}
801
914
  </Button>
802
915
  <Button
803
- variant="outline"
804
916
  size="sm"
805
- className="h-7 gap-1.5 text-xs text-destructive hover:text-destructive"
806
- onClick={bulkDelete}
917
+ variant="secondary"
918
+ className="gap-1.5 text-destructive hover:text-destructive"
919
+ onClick={() => {
920
+ setCursos((p) => p.filter((c) => !selectedIds.has(c.id)));
921
+ toast.success(
922
+ t('toasts.coursesRemoved', { count: selectedIds.size })
923
+ );
924
+ setSelectedIds(new Set());
925
+ }}
807
926
  >
808
- <Trash2 className="size-3" />
809
- {t('bulkActions.delete')}
927
+ <Trash2 className="size-3.5" /> {t('bulkActions.delete')}
810
928
  </Button>
811
929
  <Button
812
- variant="ghost"
813
930
  size="sm"
814
- className="ml-auto h-7 text-xs"
931
+ variant="ghost"
932
+ className="ml-auto text-background hover:text-background/80"
815
933
  onClick={() => setSelectedIds(new Set())}
816
934
  >
817
935
  {t('bulkActions.clearSelection')}
818
936
  </Button>
819
- </div>
820
- </motion.div>
821
- )}
822
- </AnimatePresence>
823
-
824
- {/* Table */}
825
- <motion.div variants={fadeUp}>
826
- {loading ? (
827
- <Card>
828
- <CardContent className="p-0">
829
- <div className="p-4">
830
- {Array.from({ length: 8 }).map((_, i) => (
831
- <div
832
- key={i}
833
- className="flex items-center gap-4 border-b py-4 last:border-0"
834
- >
835
- <Skeleton className="size-4 rounded" />
836
- <Skeleton className="h-4 w-20" />
837
- <Skeleton className="h-4 w-40" />
838
- <Skeleton className="h-5 w-20 rounded-full" />
839
- <Skeleton className="h-5 w-16 rounded-full" />
840
- <Skeleton className="h-5 w-20 rounded-full" />
841
- <Skeleton className="ml-auto size-8 rounded-md" />
842
- </div>
843
- ))}
937
+ </CardContent>
938
+ </Card>
939
+ </motion.div>
940
+ )}
941
+ </AnimatePresence>
942
+
943
+ {/* ── Cards grid ───────────────────────────────────────────────────── */}
944
+ {loading ? (
945
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
946
+ {Array.from({ length: 6 }).map((_, i) => (
947
+ <Card key={i} className="overflow-hidden">
948
+ <CardContent className="p-5">
949
+ <div className="mb-3 flex items-start justify-between">
950
+ <Skeleton className="h-5 w-20 rounded-full" />
951
+ <Skeleton className="size-8 rounded-md" />
952
+ </div>
953
+ <Skeleton className="mb-1.5 h-5 w-3/4" />
954
+ <Skeleton className="mb-4 h-4 w-full" />
955
+ <Skeleton className="mb-4 h-4 w-2/3" />
956
+ <div className="flex gap-2">
957
+ <Skeleton className="h-6 w-16 rounded-full" />
958
+ <Skeleton className="h-6 w-20 rounded-full" />
844
959
  </div>
845
960
  </CardContent>
846
961
  </Card>
847
- ) : (
848
- <Card>
849
- <CardContent className="p-0">
850
- <div className="overflow-x-auto">
851
- <Table>
852
- <TableHeader>
853
- <TableRow>
854
- <TableHead className="w-[40px]">
855
- <Checkbox
856
- checked={allPageSelected}
857
- onCheckedChange={toggleSelectAll}
858
- aria-label={t('table.selectAll')}
859
- />
860
- </TableHead>
861
- <TableHead className="w-[100px]">
862
- {t('table.headers.code')}
863
- </TableHead>
864
- <TableHead>{t('table.headers.title')}</TableHead>
865
- <TableHead className="hidden md:table-cell">
866
- {t('table.headers.level')}
867
- </TableHead>
868
- <TableHead>{t('table.headers.status')}</TableHead>
869
- <TableHead className="hidden lg:table-cell">
870
- {t('table.headers.categories')}
871
- </TableHead>
872
- <TableHead className="hidden sm:table-cell text-right">
873
- {t('table.headers.students')}
874
- </TableHead>
875
- <TableHead className="w-[50px]" />
876
- </TableRow>
877
- </TableHeader>
878
- <TableBody>
879
- <AnimatePresence mode="popLayout">
880
- {paginatedCursos.map((curso) => (
881
- <motion.tr
882
- key={curso.id}
883
- layout
884
- initial={{ opacity: 0 }}
885
- animate={{ opacity: 1 }}
886
- exit={{ opacity: 0, x: -20 }}
887
- transition={{ duration: 0.2 }}
888
- className="group border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted"
889
- data-state={
890
- selectedIds.has(curso.id) ? 'selected' : undefined
891
- }
892
- >
893
- <TableCell>
894
- <Checkbox
895
- checked={selectedIds.has(curso.id)}
896
- onCheckedChange={() => toggleSelect(curso.id)}
897
- aria-label={t('table.selectCourse', {
898
- title: curso.tituloComercial,
899
- })}
900
- />
901
- </TableCell>
902
- <TableCell>
903
- <code className="rounded bg-muted px-1.5 py-0.5 text-xs font-medium">
904
- {curso.codigo}
905
- </code>
906
- </TableCell>
907
- <TableCell>
908
- <div className="flex flex-col">
909
- <span className="font-medium">
910
- {curso.tituloComercial}
911
- </span>
912
- <span className="text-xs text-muted-foreground">
913
- {curso.nomeInterno}
914
- </span>
915
- </div>
916
- </TableCell>
917
- <TableCell className="hidden md:table-cell">
918
- <Badge
919
- variant="outline"
920
- className="text-xs font-normal"
962
+ ))}
963
+ </div>
964
+ ) : filteredCursos.length === 0 ? (
965
+ <div className="flex flex-col items-center justify-center py-20 text-center">
966
+ <BookOpen className="mb-4 size-12 text-muted-foreground/40" />
967
+ <p className="text-lg font-medium">{t('table.empty.title')}</p>
968
+ <p className="mt-1 text-sm text-muted-foreground">
969
+ {t('table.empty.description')}
970
+ </p>
971
+ <Button className="mt-6 gap-2" onClick={openCreateSheet}>
972
+ <Plus className="size-4" />
973
+ {t('actions.createCourse')}
974
+ </Button>
975
+ </div>
976
+ ) : (
977
+ <motion.div
978
+ className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"
979
+ variants={stagger}
980
+ initial="hidden"
981
+ animate="show"
982
+ >
983
+ {paginatedCursos.map((curso) => {
984
+ const nivelColor = NIVEL_COLOR[curso.nivel];
985
+ const statusVariant = STATUS_VARIANT[curso.status];
986
+ const isSelected = selectedIds.has(curso.id);
987
+
988
+ return (
989
+ <motion.div key={curso.id} variants={fadeUp}>
990
+ <Card
991
+ className={`group relative cursor-pointer overflow-hidden border-0 shadow-sm transition-all duration-300 hover:shadow-lg hover:-translate-y-1 ${isSelected ? 'ring-2 ring-primary ring-offset-2' : ''}`}
992
+ onClick={() => handleCardClick(curso)}
993
+ title={t('cards.tooltip')}
994
+ >
995
+ {/* Top accent */}
996
+ <div className="h-1 w-full bg-foreground" />
997
+
998
+ {/* Selection checkbox */}
999
+ <div
1000
+ className="absolute left-4 top-5 z-10 opacity-0 transition-opacity group-hover:opacity-100 data-[selected=true]:opacity-100"
1001
+ data-selected={isSelected}
1002
+ >
1003
+ <Checkbox
1004
+ checked={isSelected}
1005
+ onCheckedChange={() => {}}
1006
+ onClick={(e) => toggleSelect(curso.id, e)}
1007
+ aria-label={t('table.selectCourse', {
1008
+ title: curso.tituloComercial,
1009
+ })}
1010
+ className="border-2 bg-background shadow-sm"
1011
+ />
1012
+ </div>
1013
+
1014
+ <CardContent className="p-5">
1015
+ {/* Header with Logo + Title + Actions */}
1016
+ <div className="mb-4 flex items-start gap-4">
1017
+ {/* Logo placeholder */}
1018
+ <div className="flex size-14 shrink-0 items-center justify-center rounded-xl bg-muted border">
1019
+ <BookOpen className="size-7 text-foreground" />
1020
+ </div>
1021
+ <div className="min-w-0 flex-1">
1022
+ <div className="mb-1.5 flex items-start justify-between gap-2">
1023
+ <h3 className="line-clamp-2 font-semibold leading-snug text-foreground">
1024
+ {curso.tituloComercial}
1025
+ </h3>
1026
+ <DropdownMenu>
1027
+ <DropdownMenuTrigger asChild>
1028
+ <Button
1029
+ variant="ghost"
1030
+ size="icon"
1031
+ className="size-8 shrink-0 -mr-2 -mt-1"
1032
+ onClick={(e) => e.stopPropagation()}
1033
+ aria-label={t('table.actions.label')}
921
1034
  >
922
- {t(
923
- `levels.${curso.nivel === 'iniciante' ? 'beginner' : curso.nivel === 'intermediario' ? 'intermediate' : 'advanced'}`
924
- )}
925
- </Badge>
926
- </TableCell>
927
- <TableCell>
928
- <Badge
929
- variant={
930
- STATUS_MAP[curso.status]?.variant || 'default'
931
- }
1035
+ <MoreHorizontal className="size-4" />
1036
+ </Button>
1037
+ </DropdownMenuTrigger>
1038
+ <DropdownMenuContent align="end" className="w-48">
1039
+ <DropdownMenuItem
1040
+ onClick={(e) => {
1041
+ e.stopPropagation();
1042
+ router.push(`/lms/courses/${curso.id}`);
1043
+ }}
932
1044
  >
933
- {t(
934
- `status.${curso.status === 'ativo' ? 'active' : curso.status === 'rascunho' ? 'draft' : 'archived'}`
935
- )}
936
- </Badge>
937
- </TableCell>
938
- <TableCell className="hidden lg:table-cell">
939
- <div className="flex flex-wrap gap-1">
940
- {curso.categorias.map((cat) => (
941
- <Badge
942
- key={cat}
943
- variant="secondary"
944
- className="text-[10px] font-normal"
945
- >
946
- {t(`categories.${cat}`)}
947
- </Badge>
948
- ))}
949
- </div>
950
- </TableCell>
951
- <TableCell className="hidden sm:table-cell text-right tabular-nums text-muted-foreground">
952
- {curso.alunosInscritos.toLocaleString('pt-BR')}
953
- </TableCell>
954
- <TableCell>
955
- <DropdownMenu>
956
- <DropdownMenuTrigger asChild>
957
- <Button
958
- variant="ghost"
959
- size="icon"
960
- className="size-8 opacity-0 transition-opacity group-hover:opacity-100 focus:opacity-100"
961
- >
962
- <MoreHorizontal className="size-4" />
963
- <span className="sr-only">
964
- {t('table.actions.label')}
965
- </span>
966
- </Button>
967
- </DropdownMenuTrigger>
968
- <DropdownMenuContent
969
- align="end"
970
- className="w-44"
971
- >
972
- <DropdownMenuItem
973
- onClick={() =>
974
- router.push(`/cursos/${curso.id}`)
975
- }
976
- className="gap-2"
977
- >
978
- <Eye className="size-3.5" />
979
- {t('table.actions.viewDetails')}
980
- </DropdownMenuItem>
981
- <DropdownMenuItem
982
- onClick={() => openEditSheet(curso)}
983
- className="gap-2"
984
- >
985
- <Pencil className="size-3.5" />
986
- {t('table.actions.edit')}
987
- </DropdownMenuItem>
988
- <DropdownMenuItem
989
- onClick={() => {
990
- navigator.clipboard.writeText(
991
- curso.codigo
992
- );
993
- toast.info(
994
- t('toasts.codeCopied', {
995
- code: curso.codigo,
996
- })
997
- );
998
- }}
999
- className="gap-2"
1000
- >
1001
- <Copy className="size-3.5" />
1002
- {t('table.actions.copyCode')}
1003
- </DropdownMenuItem>
1004
- <DropdownMenuSeparator />
1005
- <DropdownMenuItem
1006
- onClick={() => {
1007
- setCursoToDelete(curso);
1008
- setDeleteDialogOpen(true);
1009
- }}
1010
- className="gap-2 text-destructive focus:text-destructive"
1011
- >
1012
- <Trash2 className="size-3.5" />
1013
- {t('table.actions.delete')}
1014
- </DropdownMenuItem>
1015
- </DropdownMenuContent>
1016
- </DropdownMenu>
1017
- </TableCell>
1018
- </motion.tr>
1019
- ))}
1020
- </AnimatePresence>
1021
- {paginatedCursos.length === 0 && (
1022
- <TableRow>
1023
- <TableCell colSpan={8} className="py-16 text-center">
1024
- <div className="flex flex-col items-center gap-2">
1025
- <BookOpen className="size-10 text-muted-foreground/40" />
1026
- <p className="text-sm font-medium text-muted-foreground">
1027
- {t('table.empty.title')}
1028
- </p>
1029
- <p className="text-xs text-muted-foreground/70">
1030
- {t('table.empty.description')}
1031
- </p>
1032
- </div>
1033
- </TableCell>
1034
- </TableRow>
1035
- )}
1036
- </TableBody>
1037
- </Table>
1038
- </div>
1045
+ <Eye className="mr-2 size-4" />{' '}
1046
+ {t('table.actions.viewDetails')}
1047
+ </DropdownMenuItem>
1048
+ <DropdownMenuItem
1049
+ onClick={(e) => openEditSheet(curso, e)}
1050
+ >
1051
+ <Pencil className="mr-2 size-4" />{' '}
1052
+ {t('table.actions.edit')}
1053
+ </DropdownMenuItem>
1054
+ <DropdownMenuSeparator />
1055
+ <DropdownMenuItem
1056
+ className="text-destructive focus:text-destructive"
1057
+ onClick={(e) => {
1058
+ e.stopPropagation();
1059
+ setCursoToDelete(curso);
1060
+ setDeleteDialogOpen(true);
1061
+ }}
1062
+ >
1063
+ <Trash2 className="mr-2 size-4" />{' '}
1064
+ {t('table.actions.delete')}
1065
+ </DropdownMenuItem>
1066
+ </DropdownMenuContent>
1067
+ </DropdownMenu>
1068
+ </div>
1069
+ <p className="text-xs text-muted-foreground">
1070
+ <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px]">
1071
+ {curso.codigo}
1072
+ </code>
1073
+ <span className="mx-1.5 text-muted-foreground/50">
1074
+ |
1075
+ </span>
1076
+ <span>{curso.nomeInterno}</span>
1077
+ </p>
1078
+ </div>
1079
+ </div>
1039
1080
 
1040
- {/* Pagination */}
1041
- {filteredCursos.length > 0 && (
1042
- <div className="flex flex-col items-center justify-between gap-3 border-t px-4 py-3 sm:flex-row">
1043
- <p className="text-xs text-muted-foreground">
1044
- {t('pagination.showing')}{' '}
1045
- <span className="font-medium text-foreground">
1046
- {(safePage - 1) * ITEMS_PER_PAGE + 1}
1047
- </span>{' '}
1048
- {t('pagination.to')}{' '}
1049
- <span className="font-medium text-foreground">
1050
- {Math.min(
1051
- safePage * ITEMS_PER_PAGE,
1052
- filteredCursos.length
1081
+ {/* Badges row */}
1082
+ <div className="mb-3 flex flex-wrap items-center gap-1.5 pl-[72px]">
1083
+ <span
1084
+ className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-[11px] font-medium ${nivelColor}`}
1085
+ >
1086
+ {t(
1087
+ `levels.${curso.nivel === 'iniciante' ? 'beginner' : curso.nivel === 'intermediario' ? 'intermediate' : 'advanced'}`
1088
+ )}
1089
+ </span>
1090
+ <Badge variant={statusVariant} className="text-[11px]">
1091
+ {t(
1092
+ `status.${curso.status === 'ativo' ? 'active' : curso.status === 'rascunho' ? 'draft' : 'archived'}`
1053
1093
  )}
1054
- </span>{' '}
1055
- {t('pagination.of')}{' '}
1056
- <span className="font-medium text-foreground">
1057
- {filteredCursos.length}
1058
- </span>{' '}
1059
- {t('pagination.results')}
1094
+ </Badge>
1095
+ {curso.destaque && (
1096
+ <span className="inline-flex items-center gap-1 rounded-full bg-gradient-to-r from-amber-50 to-orange-50 px-2.5 py-0.5 text-[11px] font-medium text-amber-700 border border-amber-200/60 shadow-sm">
1097
+ <Star className="size-3 fill-amber-400 text-amber-400" />{' '}
1098
+ {t('form.flags.featured.label')}
1099
+ </span>
1100
+ )}
1101
+ </div>
1102
+
1103
+ {/* Description */}
1104
+ <p className="mb-4 line-clamp-2 text-sm text-muted-foreground leading-relaxed">
1105
+ {curso.descricao}
1060
1106
  </p>
1061
- <div className="flex items-center gap-1">
1062
- <Button
1063
- variant="outline"
1064
- size="icon"
1065
- className="size-8"
1066
- disabled={safePage === 1}
1067
- onClick={() => setCurrentPage((p) => p - 1)}
1068
- aria-label={t('pagination.previousPage')}
1069
- >
1070
- <ChevronLeft className="size-4" />
1071
- </Button>
1072
- {Array.from({ length: totalPages }).map((_, i) => (
1073
- <Button
1074
- key={i}
1075
- variant={safePage === i + 1 ? 'default' : 'outline'}
1076
- size="icon"
1077
- className="size-8 text-xs"
1078
- onClick={() => setCurrentPage(i + 1)}
1107
+
1108
+ {/* Categories */}
1109
+ <div className="mb-4 flex flex-wrap gap-1.5">
1110
+ {curso.categorias.slice(0, 3).map((cat) => (
1111
+ <span
1112
+ key={cat}
1113
+ className="rounded-md bg-muted/80 px-2 py-0.5 text-[11px] font-medium text-muted-foreground"
1079
1114
  >
1080
- {i + 1}
1081
- </Button>
1115
+ {cat}
1116
+ </span>
1082
1117
  ))}
1083
- <Button
1084
- variant="outline"
1085
- size="icon"
1086
- className="size-8"
1087
- disabled={safePage === totalPages}
1088
- onClick={() => setCurrentPage((p) => p + 1)}
1089
- aria-label={t('pagination.nextPage')}
1090
- >
1091
- <ChevronRight className="size-4" />
1092
- </Button>
1118
+ {curso.categorias.length > 3 && (
1119
+ <span className="rounded-md bg-muted/60 px-2 py-0.5 text-[11px] text-muted-foreground">
1120
+ +{curso.categorias.length - 3}
1121
+ </span>
1122
+ )}
1093
1123
  </div>
1094
- </div>
1095
- )}
1096
- </CardContent>
1097
- </Card>
1098
- )}
1124
+
1125
+ {/* Footer stats */}
1126
+ <div className="flex items-center justify-between rounded-lg bg-muted/40 px-3 py-2.5">
1127
+ <div className="flex items-center gap-1.5">
1128
+ <Users className="size-4 text-muted-foreground" />
1129
+ <span className="text-sm font-medium">
1130
+ {curso.alunosInscritos.toLocaleString('pt-BR')}
1131
+ </span>
1132
+ <span className="text-xs text-muted-foreground">
1133
+ {t('cards.studentsLabel')}
1134
+ </span>
1135
+ </div>
1136
+ {curso.certificado && (
1137
+ <div className="flex items-center gap-1.5">
1138
+ <Award className="size-4 text-foreground" />
1139
+ <span className="text-xs font-medium">
1140
+ {t('form.flags.certificate.label')}
1141
+ </span>
1142
+ </div>
1143
+ )}
1144
+ </div>
1145
+ </CardContent>
1146
+ </Card>
1147
+ </motion.div>
1148
+ );
1149
+ })}
1099
1150
  </motion.div>
1100
- </motion.div>
1151
+ )}
1152
+
1153
+ {/* ── Pagination footer ─────────────────────────────────────────────── */}
1154
+ {!loading && filteredCursos.length > 0 && (
1155
+ <div className="mt-6 flex flex-col items-center justify-between gap-4 sm:flex-row">
1156
+ {/* Total count */}
1157
+ <p className="text-sm text-muted-foreground">
1158
+ {filteredCursos.length}{' '}
1159
+ {filteredCursos.length === 1
1160
+ ? t('pagination.course')
1161
+ : t('pagination.courses')}{' '}
1162
+ {filteredCursos.length === 1
1163
+ ? t('pagination.found')
1164
+ : t('pagination.foundPlural')}
1165
+ </p>
1101
1166
 
1102
- {/* ── Sheet: Criar / Editar ──────────────────────────────────────────── */}
1167
+ {/* Center: page nav */}
1168
+ <div className="flex items-center gap-1">
1169
+ <Button
1170
+ variant="outline"
1171
+ size="icon"
1172
+ className="size-8"
1173
+ onClick={() => setCurrentPage(1)}
1174
+ disabled={safePage === 1}
1175
+ aria-label={t('pagination.firstPage')}
1176
+ >
1177
+ <ChevronsLeft className="size-4" />
1178
+ </Button>
1179
+ <Button
1180
+ variant="outline"
1181
+ size="icon"
1182
+ className="size-8"
1183
+ onClick={() => setCurrentPage((p) => p - 1)}
1184
+ disabled={safePage === 1}
1185
+ aria-label={t('pagination.previousPage')}
1186
+ >
1187
+ <ChevronLeft className="size-4" />
1188
+ </Button>
1189
+ <span className="px-3 text-sm">
1190
+ {t('pagination.page')}{' '}
1191
+ <span className="font-semibold">{safePage}</span>{' '}
1192
+ {t('pagination.of')}{' '}
1193
+ <span className="font-semibold">{totalPages}</span>
1194
+ </span>
1195
+ <Button
1196
+ variant="outline"
1197
+ size="icon"
1198
+ className="size-8"
1199
+ onClick={() => setCurrentPage((p) => p + 1)}
1200
+ disabled={safePage === totalPages}
1201
+ aria-label={t('pagination.nextPage')}
1202
+ >
1203
+ <ChevronRight className="size-4" />
1204
+ </Button>
1205
+ <Button
1206
+ variant="outline"
1207
+ size="icon"
1208
+ className="size-8"
1209
+ onClick={() => setCurrentPage(totalPages)}
1210
+ disabled={safePage === totalPages}
1211
+ aria-label={t('pagination.lastPage')}
1212
+ >
1213
+ <ChevronsRight className="size-4" />
1214
+ </Button>
1215
+ </div>
1216
+
1217
+ {/* Items per page */}
1218
+ <div className="flex items-center gap-2 text-sm">
1219
+ <span className="text-muted-foreground">
1220
+ {t('pagination.itemsPerPage')}
1221
+ </span>
1222
+ <Select
1223
+ value={String(pageSize)}
1224
+ onValueChange={(v) => {
1225
+ setPageSize(Number(v));
1226
+ setCurrentPage(1);
1227
+ }}
1228
+ >
1229
+ <SelectTrigger className="h-8 w-16 text-sm">
1230
+ <SelectValue />
1231
+ </SelectTrigger>
1232
+ <SelectContent>
1233
+ {PAGE_SIZES.map((s) => (
1234
+ <SelectItem key={s} value={String(s)}>
1235
+ {s}
1236
+ </SelectItem>
1237
+ ))}
1238
+ </SelectContent>
1239
+ </Select>
1240
+ </div>
1241
+ </div>
1242
+ )}
1243
+
1244
+ {/* ── Create / Edit Sheet ────────────────────────────────────────────── */}
1103
1245
  <Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
1104
- <SheetContent className="w-full overflow-y-auto sm:max-w-lg">
1105
- <SheetHeader>
1246
+ <SheetContent
1247
+ side="right"
1248
+ className="flex w-full flex-col sm:max-w-lg overflow-y-auto"
1249
+ >
1250
+ <SheetHeader className="shrink-0">
1106
1251
  <SheetTitle>
1107
1252
  {editingCurso ? t('form.title.edit') : t('form.title.create')}
1108
1253
  </SheetTitle>
@@ -1115,13 +1260,13 @@ export default function CursosPage() {
1115
1260
 
1116
1261
  <form
1117
1262
  onSubmit={form.handleSubmit(onSubmit)}
1118
- className="mt-6 flex flex-col px-4 gap-5"
1263
+ className="flex flex-1 flex-col gap-5 py-6 px-4"
1119
1264
  >
1120
1265
  {/* Codigo */}
1121
- <Field data-invalid={!!form.formState.errors.codigo}>
1266
+ <Field>
1122
1267
  <FieldLabel htmlFor="codigo">
1123
- <Hash className="size-3.5 text-muted-foreground" />
1124
- {t('form.fields.code.label')}
1268
+ {t('form.fields.code.label')}{' '}
1269
+ <span className="text-destructive">*</span>
1125
1270
  </FieldLabel>
1126
1271
  <Input
1127
1272
  id="codigo"
@@ -1129,78 +1274,73 @@ export default function CursosPage() {
1129
1274
  className="uppercase"
1130
1275
  {...form.register('codigo')}
1131
1276
  />
1132
- {form.formState.errors.codigo && (
1133
- <FieldError>{form.formState.errors.codigo.message}</FieldError>
1134
- )}
1135
- <FieldDescription>
1136
- {t('form.fields.code.description')}
1137
- </FieldDescription>
1277
+ <FieldError>{form.formState.errors.codigo?.message}</FieldError>
1138
1278
  </Field>
1139
1279
 
1140
- {/* Nome Interno */}
1141
- <Field data-invalid={!!form.formState.errors.nomeInterno}>
1280
+ {/* Nome interno */}
1281
+ <Field>
1142
1282
  <FieldLabel htmlFor="nomeInterno">
1143
- {t('form.fields.internalName.label')}
1283
+ {t('form.fields.internalName.label')}{' '}
1284
+ <span className="text-destructive">*</span>
1144
1285
  </FieldLabel>
1145
1286
  <Input
1146
1287
  id="nomeInterno"
1147
1288
  placeholder={t('form.fields.internalName.placeholder')}
1148
1289
  {...form.register('nomeInterno')}
1149
1290
  />
1150
- {form.formState.errors.nomeInterno && (
1151
- <FieldError>
1152
- {form.formState.errors.nomeInterno.message}
1153
- </FieldError>
1154
- )}
1155
1291
  <FieldDescription>
1156
1292
  {t('form.fields.internalName.description')}
1157
1293
  </FieldDescription>
1294
+ <FieldError>
1295
+ {form.formState.errors.nomeInterno?.message}
1296
+ </FieldError>
1158
1297
  </Field>
1159
1298
 
1160
- {/* Titulo Comercial */}
1161
- <Field data-invalid={!!form.formState.errors.tituloComercial}>
1299
+ {/* Titulo comercial */}
1300
+ <Field>
1162
1301
  <FieldLabel htmlFor="tituloComercial">
1163
- {t('form.fields.commercialTitle.label')}
1302
+ {t('form.fields.commercialTitle.label')}{' '}
1303
+ <span className="text-destructive">*</span>
1164
1304
  </FieldLabel>
1165
1305
  <Input
1166
1306
  id="tituloComercial"
1167
1307
  placeholder={t('form.fields.commercialTitle.placeholder')}
1168
1308
  {...form.register('tituloComercial')}
1169
1309
  />
1170
- {form.formState.errors.tituloComercial && (
1171
- <FieldError>
1172
- {form.formState.errors.tituloComercial.message}
1173
- </FieldError>
1174
- )}
1310
+ <FieldError>
1311
+ {form.formState.errors.tituloComercial?.message}
1312
+ </FieldError>
1175
1313
  </Field>
1176
1314
 
1177
1315
  {/* Descricao */}
1178
- <Field data-invalid={!!form.formState.errors.descricao}>
1316
+ <Field>
1179
1317
  <FieldLabel htmlFor="descricao">
1180
- {t('form.fields.description.label')}
1318
+ {t('form.fields.description.label')}{' '}
1319
+ <span className="text-destructive">*</span>
1181
1320
  </FieldLabel>
1182
1321
  <Textarea
1183
1322
  id="descricao"
1323
+ rows={3}
1184
1324
  placeholder={t('form.fields.description.placeholder')}
1185
- rows={4}
1186
1325
  {...form.register('descricao')}
1187
1326
  />
1188
- {form.formState.errors.descricao && (
1189
- <FieldError>
1190
- {form.formState.errors.descricao.message}
1191
- </FieldError>
1192
- )}
1327
+ <FieldError>
1328
+ {form.formState.errors.descricao?.message}
1329
+ </FieldError>
1193
1330
  </Field>
1194
1331
 
1195
- {/* Nivel + Status side by side */}
1332
+ {/* Nivel + Status */}
1196
1333
  <div className="grid grid-cols-2 gap-4">
1197
- <Field data-invalid={!!form.formState.errors.nivel}>
1198
- <FieldLabel>{t('form.fields.level.label')}</FieldLabel>
1334
+ <Field>
1335
+ <FieldLabel>
1336
+ {t('form.fields.level.label')}{' '}
1337
+ <span className="text-destructive">*</span>
1338
+ </FieldLabel>
1199
1339
  <Controller
1200
- control={form.control}
1201
1340
  name="nivel"
1341
+ control={form.control}
1202
1342
  render={({ field }) => (
1203
- <Select value={field.value} onValueChange={field.onChange}>
1343
+ <Select onValueChange={field.onChange} value={field.value}>
1204
1344
  <SelectTrigger>
1205
1345
  <SelectValue
1206
1346
  placeholder={t('form.fields.level.placeholder')}
@@ -1220,18 +1360,18 @@ export default function CursosPage() {
1220
1360
  </Select>
1221
1361
  )}
1222
1362
  />
1223
- {form.formState.errors.nivel && (
1224
- <FieldError>{form.formState.errors.nivel.message}</FieldError>
1225
- )}
1363
+ <FieldError>{form.formState.errors.nivel?.message}</FieldError>
1226
1364
  </Field>
1227
-
1228
- <Field data-invalid={!!form.formState.errors.status}>
1229
- <FieldLabel>{t('form.fields.status.label')}</FieldLabel>
1365
+ <Field>
1366
+ <FieldLabel>
1367
+ {t('form.fields.status.label')}{' '}
1368
+ <span className="text-destructive">*</span>
1369
+ </FieldLabel>
1230
1370
  <Controller
1231
- control={form.control}
1232
1371
  name="status"
1372
+ control={form.control}
1233
1373
  render={({ field }) => (
1234
- <Select value={field.value} onValueChange={field.onChange}>
1374
+ <Select onValueChange={field.onChange} value={field.value}>
1235
1375
  <SelectTrigger>
1236
1376
  <SelectValue
1237
1377
  placeholder={t('form.fields.status.placeholder')}
@@ -1251,128 +1391,91 @@ export default function CursosPage() {
1251
1391
  </Select>
1252
1392
  )}
1253
1393
  />
1254
- {form.formState.errors.status && (
1255
- <FieldError>
1256
- {form.formState.errors.status.message}
1257
- </FieldError>
1258
- )}
1394
+ <FieldError>{form.formState.errors.status?.message}</FieldError>
1259
1395
  </Field>
1260
1396
  </div>
1261
1397
 
1262
- <Separator />
1263
-
1264
- {/* Categorias - Multi select via checkboxes */}
1265
- <Field data-invalid={!!form.formState.errors.categorias}>
1266
- <FieldLabel>{t('form.fields.categories.label')}</FieldLabel>
1267
- <FieldDescription>
1268
- {t('form.fields.categories.description')}
1269
- </FieldDescription>
1398
+ {/* Categorias */}
1399
+ <Field>
1400
+ <FieldLabel>
1401
+ {t('form.fields.categories.label')}{' '}
1402
+ <span className="text-destructive">*</span>
1403
+ </FieldLabel>
1270
1404
  <Controller
1271
- control={form.control}
1272
1405
  name="categorias"
1406
+ control={form.control}
1273
1407
  render={({ field }) => (
1274
- <div className="grid grid-cols-2 gap-2.5">
1275
- {CATEGORIAS.map((cat) => {
1276
- const checked = field.value.includes(cat);
1277
- return (
1278
- <label
1279
- key={cat}
1280
- className="flex cursor-pointer items-center gap-2.5 rounded-md border px-3 py-2 text-sm transition-colors hover:bg-muted/50 has-[:checked]:border-foreground has-[:checked]:bg-muted/50"
1281
- >
1282
- <Checkbox
1283
- checked={checked}
1284
- onCheckedChange={(isChecked) => {
1285
- if (isChecked) {
1286
- field.onChange([...field.value, cat]);
1287
- } else {
1288
- field.onChange(
1289
- field.value.filter((v: string) => v !== cat)
1290
- );
1291
- }
1292
- }}
1293
- />
1294
- {t(`categories.${cat}`)}
1295
- </label>
1296
- );
1297
- })}
1408
+ <div className="grid grid-cols-2 gap-2">
1409
+ {CATEGORIAS.map((cat) => (
1410
+ <label
1411
+ key={cat}
1412
+ className="flex cursor-pointer items-center gap-2 rounded-md border p-2.5 text-sm hover:bg-muted has-[:checked]:border-foreground has-[:checked]:bg-muted"
1413
+ >
1414
+ <Checkbox
1415
+ checked={field.value.includes(cat)}
1416
+ onCheckedChange={(checked) => {
1417
+ const next = checked
1418
+ ? [...field.value, cat]
1419
+ : field.value.filter((v) => v !== cat);
1420
+ field.onChange(next);
1421
+ }}
1422
+ />
1423
+ {cat}
1424
+ </label>
1425
+ ))}
1298
1426
  </div>
1299
1427
  )}
1300
1428
  />
1301
- {form.formState.errors.categorias && (
1302
- <FieldError>
1303
- {form.formState.errors.categorias.message}
1304
- </FieldError>
1305
- )}
1429
+ <FieldError>
1430
+ {form.formState.errors.categorias?.message}
1431
+ </FieldError>
1306
1432
  </Field>
1307
1433
 
1308
- <Separator />
1309
-
1310
1434
  {/* Flags */}
1311
- <div className="flex flex-col gap-4">
1435
+ <div className="space-y-3">
1312
1436
  <p className="text-sm font-medium">{t('form.flags.title')}</p>
1313
- <Controller
1314
- control={form.control}
1315
- name="destaque"
1316
- render={({ field }) => (
1317
- <div className="flex items-center justify-between rounded-md border px-4 py-3">
1318
- <div className="flex flex-col gap-0.5">
1319
- <span className="text-sm font-medium">
1320
- {t('form.flags.featured.label')}
1321
- </span>
1322
- <span className="text-xs text-muted-foreground">
1323
- {t('form.flags.featured.description')}
1324
- </span>
1325
- </div>
1326
- <Switch
1327
- checked={field.value}
1328
- onCheckedChange={field.onChange}
1329
- />
1330
- </div>
1331
- )}
1332
- />
1333
- <Controller
1334
- control={form.control}
1335
- name="certificado"
1336
- render={({ field }) => (
1337
- <div className="flex items-center justify-between rounded-md border px-4 py-3">
1338
- <div className="flex flex-col gap-0.5">
1339
- <span className="text-sm font-medium">
1340
- {t('form.flags.certificate.label')}
1341
- </span>
1342
- <span className="text-xs text-muted-foreground">
1343
- {t('form.flags.certificate.description')}
1344
- </span>
1345
- </div>
1346
- <Switch
1347
- checked={field.value}
1348
- onCheckedChange={field.onChange}
1349
- />
1350
- </div>
1351
- )}
1352
- />
1353
- <Controller
1354
- control={form.control}
1355
- name="listado"
1356
- render={({ field }) => (
1357
- <div className="flex items-center justify-between rounded-md border px-4 py-3">
1358
- <div className="flex flex-col gap-0.5">
1359
- <span className="text-sm font-medium">
1360
- {t('form.flags.listed.label')}
1361
- </span>
1362
- <span className="text-xs text-muted-foreground">
1363
- {t('form.flags.listed.description')}
1364
- </span>
1365
- </div>
1366
- <Switch
1367
- checked={field.value}
1368
- onCheckedChange={field.onChange}
1369
- />
1437
+ {(
1438
+ [
1439
+ {
1440
+ name: 'destaque' as const,
1441
+ label: t('form.flags.featured.label'),
1442
+ desc: t('form.flags.featured.description'),
1443
+ },
1444
+ {
1445
+ name: 'certificado' as const,
1446
+ label: t('form.flags.certificate.label'),
1447
+ desc: t('form.flags.certificate.description'),
1448
+ },
1449
+ {
1450
+ name: 'listado' as const,
1451
+ label: t('form.flags.listed.label'),
1452
+ desc: t('form.flags.listed.description'),
1453
+ },
1454
+ ] as const
1455
+ ).map((flag) => (
1456
+ <label
1457
+ key={flag.name}
1458
+ className="flex cursor-pointer items-center justify-between rounded-lg border p-3 hover:bg-muted"
1459
+ >
1460
+ <div>
1461
+ <p className="text-sm font-medium">{flag.label}</p>
1462
+ <p className="text-xs text-muted-foreground">{flag.desc}</p>
1370
1463
  </div>
1371
- )}
1372
- />
1464
+ <Controller
1465
+ name={flag.name}
1466
+ control={form.control}
1467
+ render={({ field }) => (
1468
+ <Switch
1469
+ checked={field.value}
1470
+ onCheckedChange={field.onChange}
1471
+ />
1472
+ )}
1473
+ />
1474
+ </label>
1475
+ ))}
1373
1476
  </div>
1374
1477
 
1375
- <SheetFooter className="mt-2 gap-2 pb-6">
1478
+ <SheetFooter className="mt-auto shrink-0 gap-2 pt-4 px-0">
1376
1479
  <Button type="submit" disabled={saving} className="gap-2">
1377
1480
  {saving && <Loader2 className="size-4 animate-spin" />}
1378
1481
  {editingCurso
@@ -1384,9 +1487,9 @@ export default function CursosPage() {
1384
1487
  </SheetContent>
1385
1488
  </Sheet>
1386
1489
 
1387
- {/* ── Dialog: Confirmar Exclusao ─────────────────────────────────────── */}
1490
+ {/* ── Delete dialog ────────────────────���────────────────────────────── */}
1388
1491
  <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
1389
- <DialogContent>
1492
+ <DialogContent className="max-w-3xl">
1390
1493
  <DialogHeader>
1391
1494
  <DialogTitle className="flex items-center gap-2">
1392
1495
  <AlertTriangle className="size-5 text-destructive" />
@@ -1394,15 +1497,11 @@ export default function CursosPage() {
1394
1497
  </DialogTitle>
1395
1498
  <DialogDescription>
1396
1499
  {t('deleteDialog.description')}{' '}
1397
- <strong className="text-foreground">
1398
- {cursoToDelete?.tituloComercial}
1399
- </strong>
1400
- ?
1401
- {cursoToDelete && cursoToDelete.alunosInscritos > 0 && (
1402
- <span className="mt-2 flex items-center gap-1.5 rounded-md bg-amber-50 px-3 py-2 text-xs font-medium text-amber-700">
1403
- <AlertTriangle className="size-3.5" />
1500
+ <strong>{cursoToDelete?.tituloComercial}</strong>?
1501
+ {(cursoToDelete?.alunosInscritos ?? 0) > 0 && (
1502
+ <span className="mt-2 block rounded-md bg-destructive/10 p-2 text-destructive text-sm">
1404
1503
  {t('deleteDialog.warning', {
1405
- count: cursoToDelete.alunosInscritos,
1504
+ count: cursoToDelete?.alunosInscritos ?? 0,
1406
1505
  })}
1407
1506
  </span>
1408
1507
  )}
@@ -1420,8 +1519,7 @@ export default function CursosPage() {
1420
1519
  onClick={confirmDelete}
1421
1520
  className="gap-2"
1422
1521
  >
1423
- <Trash2 className="size-4" />
1424
- {t('deleteDialog.actions.delete')}
1522
+ <Trash2 className="size-4" /> {t('deleteDialog.actions.delete')}
1425
1523
  </Button>
1426
1524
  </DialogFooter>
1427
1525
  </DialogContent>