@hed-hog/lms 0.0.265 → 0.0.267

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.
@@ -0,0 +1,1164 @@
1
+ 'use client';
2
+
3
+ import { Page, PageHeader } from '@/components/entity-list';
4
+ import { Badge } from '@/components/ui/badge';
5
+ import { Button } from '@/components/ui/button';
6
+ import { Card, CardContent } from '@/components/ui/card';
7
+ import { Checkbox } from '@/components/ui/checkbox';
8
+ import {
9
+ Dialog,
10
+ DialogContent,
11
+ DialogDescription,
12
+ DialogFooter,
13
+ DialogHeader,
14
+ DialogTitle,
15
+ } from '@/components/ui/dialog';
16
+ import {
17
+ DropdownMenu,
18
+ DropdownMenuContent,
19
+ DropdownMenuItem,
20
+ DropdownMenuSeparator,
21
+ DropdownMenuTrigger,
22
+ } from '@/components/ui/dropdown-menu';
23
+ import { Field, FieldError, FieldLabel } from '@/components/ui/field';
24
+ import { Input } from '@/components/ui/input';
25
+ import {
26
+ Select,
27
+ SelectContent,
28
+ SelectItem,
29
+ SelectTrigger,
30
+ SelectValue,
31
+ } from '@/components/ui/select';
32
+ import { Separator } from '@/components/ui/separator';
33
+ import {
34
+ Sheet,
35
+ SheetContent,
36
+ SheetDescription,
37
+ SheetFooter,
38
+ SheetHeader,
39
+ SheetTitle,
40
+ } from '@/components/ui/sheet';
41
+ import { Skeleton } from '@/components/ui/skeleton';
42
+ import { Textarea } from '@/components/ui/textarea';
43
+ import { zodResolver } from '@hookform/resolvers/zod';
44
+ import { motion } from 'framer-motion';
45
+ import {
46
+ AlertTriangle,
47
+ BarChart3,
48
+ BookOpen,
49
+ ChevronLeft,
50
+ ChevronRight,
51
+ ChevronsLeft,
52
+ ChevronsRight,
53
+ Clock,
54
+ Eye,
55
+ FileCheck,
56
+ GraduationCap,
57
+ Layers,
58
+ LayoutDashboard,
59
+ Loader2,
60
+ MoreHorizontal,
61
+ Pencil,
62
+ Plus,
63
+ Search,
64
+ Target,
65
+ Trash2,
66
+ Users,
67
+ X,
68
+ } from 'lucide-react';
69
+ import { useTranslations } from 'next-intl';
70
+ import { usePathname, useRouter } from 'next/navigation';
71
+ import { useEffect, useMemo, useRef, useState } from 'react';
72
+ import { Controller, useForm } from 'react-hook-form';
73
+ import { toast } from 'sonner';
74
+ import { z } from 'zod';
75
+
76
+ // ── Nav ───────────────────────────────────────────────────────────────────────
77
+
78
+ const NAV_ITEMS = [
79
+ { label: 'Dashboard', href: '/', icon: LayoutDashboard },
80
+ { label: 'Cursos', href: '/cursos', icon: BookOpen },
81
+ { label: 'Turmas', href: '/turmas', icon: Users },
82
+ { label: 'Exames', href: '/exames', icon: FileCheck },
83
+ { label: 'Formacoes', href: '/formacoes', icon: GraduationCap },
84
+ { label: 'Relatorios', href: '/relatorios', icon: BarChart3 },
85
+ ];
86
+
87
+ // ── Types ─────────────────────────────────────────────────────────────────────
88
+
89
+ interface Formacao {
90
+ id: number;
91
+ nome: string;
92
+ descricao: string;
93
+ area: string;
94
+ nivel: string;
95
+ prerequisitos: string;
96
+ cursos: string[];
97
+ cargaTotal: number;
98
+ alunos: number;
99
+ status: 'ativa' | 'rascunho' | 'encerrada';
100
+ criadoEm: string;
101
+ }
102
+
103
+ // ── Schema ────────────────────────────────────────────────────────────────────
104
+
105
+ const formacaoSchema = z.object({
106
+ nome: z.string().min(3, 'Minimo 3 caracteres'),
107
+ descricao: z.string().min(10, 'Minimo 10 caracteres'),
108
+ area: z.string().min(1, 'Selecione uma area'),
109
+ nivel: z.string().min(1, 'Selecione um nivel'),
110
+ prerequisitos: z.string().optional(),
111
+ status: z.string().min(1, 'Selecione um status'),
112
+ });
113
+
114
+ type FormacaoForm = z.infer<typeof formacaoSchema>;
115
+
116
+ // ── Constants ─────────────────────────────────────────────────────────────────
117
+
118
+ const STATUS_MAP: Record<
119
+ string,
120
+ { label: string; variant: 'default' | 'secondary' | 'outline' }
121
+ > = {
122
+ ativa: { label: 'Ativa', variant: 'default' },
123
+ rascunho: { label: 'Rascunho', variant: 'secondary' },
124
+ encerrada: { label: 'Encerrada', variant: 'outline' },
125
+ };
126
+
127
+ const AREA_COLORS: Record<string, string> = {
128
+ Tecnologia: 'bg-blue-50 text-blue-700 border-blue-200',
129
+ Design: 'bg-purple-50 text-purple-700 border-purple-200',
130
+ Gestao: 'bg-amber-50 text-amber-700 border-amber-200',
131
+ Marketing: 'bg-orange-50 text-orange-700 border-orange-200',
132
+ Financas: 'bg-emerald-50 text-emerald-700 border-emerald-200',
133
+ };
134
+
135
+ const PAGE_SIZES = [6, 12, 24];
136
+
137
+ const availableCursos = [
138
+ { id: 'react', nome: 'React Avancado', cargaHoraria: 60 },
139
+ { id: 'ux', nome: 'UX Design Fundamentals', cargaHoraria: 40 },
140
+ { id: 'python', nome: 'Python para Data Science', cargaHoraria: 80 },
141
+ { id: 'node', nome: 'Node.js Completo', cargaHoraria: 70 },
142
+ { id: 'typescript', nome: 'TypeScript na Pratica', cargaHoraria: 50 },
143
+ { id: 'figma', nome: 'Figma para Iniciantes', cargaHoraria: 25 },
144
+ { id: 'agile', nome: 'Gestao de Projetos Ageis', cargaHoraria: 30 },
145
+ { id: 'marketing', nome: 'Marketing Digital', cargaHoraria: 45 },
146
+ { id: 'design-system', nome: 'Design System', cargaHoraria: 35 },
147
+ { id: 'excel', nome: 'Excel para Negocios', cargaHoraria: 30 },
148
+ { id: 'lideranca', nome: 'Lideranca e Comunicacao', cargaHoraria: 20 },
149
+ { id: 'seo', nome: 'SEO Avancado', cargaHoraria: 35 },
150
+ ];
151
+
152
+ // ── Seed Data ─────────────────────────────────────────────────────────────────
153
+
154
+ const initialFormacoes: Formacao[] = [
155
+ {
156
+ id: 1,
157
+ nome: 'Full Stack Developer',
158
+ descricao:
159
+ 'Formacao completa para desenvolvimento full stack com React, Node.js e TypeScript.',
160
+ area: 'Tecnologia',
161
+ nivel: 'Avancado',
162
+ prerequisitos: 'JavaScript basico',
163
+ cursos: ['React Avancado', 'Node.js Completo', 'TypeScript na Pratica'],
164
+ cargaTotal: 180,
165
+ alunos: 342,
166
+ status: 'ativa',
167
+ criadoEm: '2024-01-01',
168
+ },
169
+ {
170
+ id: 2,
171
+ nome: 'UX/UI Designer Profissional',
172
+ descricao:
173
+ 'Torne-se um designer completo com habilidades em UX, UI e design systems.',
174
+ area: 'Design',
175
+ nivel: 'Intermediario',
176
+ prerequisitos: 'Nenhum',
177
+ cursos: [
178
+ 'UX Design Fundamentals',
179
+ 'Figma para Iniciantes',
180
+ 'Design System',
181
+ ],
182
+ cargaTotal: 100,
183
+ alunos: 198,
184
+ status: 'ativa',
185
+ criadoEm: '2024-01-15',
186
+ },
187
+ {
188
+ id: 3,
189
+ nome: 'Data Science com Python',
190
+ descricao:
191
+ 'Domine ciencia de dados desde fundamentos ate machine learning com Python.',
192
+ area: 'Tecnologia',
193
+ nivel: 'Intermediario',
194
+ prerequisitos: 'Logica de programacao',
195
+ cursos: ['Python para Data Science', 'Excel para Negocios'],
196
+ cargaTotal: 110,
197
+ alunos: 267,
198
+ status: 'ativa',
199
+ criadoEm: '2024-02-01',
200
+ },
201
+ {
202
+ id: 4,
203
+ nome: 'Gestao e Lideranca',
204
+ descricao:
205
+ 'Formacao em gestao de projetos ageis e habilidades de lideranca para times modernos.',
206
+ area: 'Gestao',
207
+ nivel: 'Iniciante',
208
+ prerequisitos: 'Nenhum',
209
+ cursos: [
210
+ 'Gestao de Projetos Ageis',
211
+ 'Lideranca e Comunicacao',
212
+ 'Excel para Negocios',
213
+ ],
214
+ cargaTotal: 80,
215
+ alunos: 156,
216
+ status: 'ativa',
217
+ criadoEm: '2024-02-15',
218
+ },
219
+ {
220
+ id: 5,
221
+ nome: 'Marketing Digital Completo',
222
+ descricao:
223
+ 'Domine estrategias de marketing digital, SEO e conteudo para web.',
224
+ area: 'Marketing',
225
+ nivel: 'Intermediario',
226
+ prerequisitos: 'Nenhum',
227
+ cursos: ['Marketing Digital', 'SEO Avancado'],
228
+ cargaTotal: 80,
229
+ alunos: 0,
230
+ status: 'rascunho',
231
+ criadoEm: '2024-03-01',
232
+ },
233
+ {
234
+ id: 6,
235
+ nome: 'Frontend Developer',
236
+ descricao:
237
+ 'Especializacao em desenvolvimento frontend com as tecnologias mais atuais do mercado.',
238
+ area: 'Tecnologia',
239
+ nivel: 'Intermediario',
240
+ prerequisitos: 'HTML, CSS e JS basico',
241
+ cursos: ['React Avancado', 'TypeScript na Pratica', 'Design System'],
242
+ cargaTotal: 145,
243
+ alunos: 89,
244
+ status: 'ativa',
245
+ criadoEm: '2024-03-15',
246
+ },
247
+ {
248
+ id: 7,
249
+ nome: 'Design Thinking e Inovacao',
250
+ descricao:
251
+ 'Aprenda metodologias de design thinking e processos de inovacao para negócios.',
252
+ area: 'Design',
253
+ nivel: 'Iniciante',
254
+ prerequisitos: 'Nenhum',
255
+ cursos: ['UX Design Fundamentals', 'Gestao de Projetos Ageis'],
256
+ cargaTotal: 70,
257
+ alunos: 412,
258
+ status: 'encerrada',
259
+ criadoEm: '2023-09-01',
260
+ },
261
+ {
262
+ id: 8,
263
+ nome: 'Analista de Dados',
264
+ descricao:
265
+ 'Formacao completa para analise de dados empresariais com ferramentas modernas.',
266
+ area: 'Tecnologia',
267
+ nivel: 'Iniciante',
268
+ prerequisitos: 'Nenhum',
269
+ cursos: ['Excel para Negocios', 'Python para Data Science'],
270
+ cargaTotal: 110,
271
+ alunos: 203,
272
+ status: 'ativa',
273
+ criadoEm: '2024-04-01',
274
+ },
275
+ ];
276
+
277
+ // ── Animations ────────────────────────────────────────────────────────────────
278
+
279
+ const fadeUp = {
280
+ hidden: { opacity: 0, y: 16 },
281
+ show: {
282
+ opacity: 1,
283
+ y: 0,
284
+ transition: { duration: 0.3 },
285
+ },
286
+ };
287
+ const stagger = {
288
+ hidden: {},
289
+ show: { transition: { staggerChildren: 0.05 } },
290
+ };
291
+
292
+ // ── Page ──────────────────────────────────────────────────────────────────────
293
+
294
+ export default function TrainingPage() {
295
+ const t = useTranslations('lms.TrainingPage');
296
+ const pathname = usePathname();
297
+ const router = useRouter();
298
+
299
+ const [loading, setLoading] = useState(true);
300
+ const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
301
+ const [formacoes, setFormacoes] = useState<Formacao[]>(initialFormacoes);
302
+ const [sheetOpen, setSheetOpen] = useState(false);
303
+ const [editingFormacao, setEditingFormacao] = useState<Formacao | null>(null);
304
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
305
+ const [formacaoToDelete, setFormacaoToDelete] = useState<Formacao | null>(
306
+ null
307
+ );
308
+ const [selectedCursos, setSelectedCursos] = useState<string[]>([]);
309
+ const [saving, setSaving] = useState(false);
310
+
311
+ // Search/filter inputs
312
+ const [buscaInput, setBuscaInput] = useState('');
313
+ const [filtroAreaInput, setFiltroAreaInput] = useState('todos');
314
+ const [filtroNivelInput, setFiltroNivelInput] = useState('todos');
315
+
316
+ // Applied filters
317
+ const [buscaApplied, setBuscaApplied] = useState('');
318
+ const [filtroAreaApplied, setFiltroAreaApplied] = useState('todos');
319
+ const [filtroNivelApplied, setFiltroNivelApplied] = useState('todos');
320
+
321
+ // Pagination
322
+ const [currentPage, setCurrentPage] = useState(1);
323
+ const [pageSize, setPageSize] = useState(12);
324
+
325
+ // Double-click tracking
326
+ const clickTimers = useRef<Map<number, ReturnType<typeof setTimeout>>>(
327
+ new Map()
328
+ );
329
+
330
+ const form = useForm<FormacaoForm>({
331
+ resolver: zodResolver(formacaoSchema),
332
+ defaultValues: {
333
+ nome: '',
334
+ descricao: '',
335
+ area: '',
336
+ nivel: '',
337
+ prerequisitos: '',
338
+ status: 'rascunho',
339
+ },
340
+ });
341
+
342
+ useEffect(() => {
343
+ const t = setTimeout(() => setLoading(false), 800);
344
+ return () => clearTimeout(t);
345
+ }, []);
346
+
347
+ // ── Filtering ────────────────────────────────────────────────────────────
348
+
349
+ const filteredFormacoes = useMemo(
350
+ () =>
351
+ formacoes.filter((f) => {
352
+ const q = buscaApplied.toLowerCase();
353
+ return (
354
+ (!q ||
355
+ f.nome.toLowerCase().includes(q) ||
356
+ f.descricao.toLowerCase().includes(q)) &&
357
+ (filtroAreaApplied === 'todos' || f.area === filtroAreaApplied) &&
358
+ (filtroNivelApplied === 'todos' || f.nivel === filtroNivelApplied)
359
+ );
360
+ }),
361
+ [formacoes, buscaApplied, filtroAreaApplied, filtroNivelApplied]
362
+ );
363
+
364
+ const totalPages = Math.max(
365
+ 1,
366
+ Math.ceil(filteredFormacoes.length / pageSize)
367
+ );
368
+ const safePage = Math.min(currentPage, totalPages);
369
+ const paginatedFormacoes = filteredFormacoes.slice(
370
+ (safePage - 1) * pageSize,
371
+ safePage * pageSize
372
+ );
373
+
374
+ function handleSearch(e: React.FormEvent) {
375
+ e.preventDefault();
376
+ setBuscaApplied(buscaInput);
377
+ setFiltroAreaApplied(filtroAreaInput);
378
+ setFiltroNivelApplied(filtroNivelInput);
379
+ setCurrentPage(1);
380
+ }
381
+
382
+ function clearFilters() {
383
+ setBuscaInput('');
384
+ setFiltroAreaInput('todos');
385
+ setFiltroNivelInput('todos');
386
+ setBuscaApplied('');
387
+ setFiltroAreaApplied('todos');
388
+ setFiltroNivelApplied('todos');
389
+ setCurrentPage(1);
390
+ }
391
+
392
+ const hasActiveFilters =
393
+ buscaApplied ||
394
+ filtroAreaApplied !== 'todos' ||
395
+ filtroNivelApplied !== 'todos';
396
+
397
+ // ── Double-click ──────────────────────────────────────────────────────────
398
+
399
+ function handleCardClick(formacao: Formacao) {
400
+ const existing = clickTimers.current.get(formacao.id);
401
+ if (existing) {
402
+ clearTimeout(existing);
403
+ clickTimers.current.delete(formacao.id);
404
+ // Toast message for opening would go here if integrated
405
+ } else {
406
+ const timer = setTimeout(
407
+ () => clickTimers.current.delete(formacao.id),
408
+ 300
409
+ );
410
+ clickTimers.current.set(formacao.id, timer);
411
+ }
412
+ }
413
+
414
+ // ── CRUD ──────────────────────────────────────────────────────────────────
415
+
416
+ function openCreateSheet() {
417
+ setEditingFormacao(null);
418
+ setSelectedCursos([]);
419
+ form.reset({
420
+ nome: '',
421
+ descricao: '',
422
+ area: '',
423
+ nivel: '',
424
+ prerequisitos: '',
425
+ status: 'rascunho',
426
+ });
427
+ setSheetOpen(true);
428
+ }
429
+
430
+ function openEditSheet(formacao: Formacao, e?: React.MouseEvent) {
431
+ e?.stopPropagation();
432
+ setEditingFormacao(formacao);
433
+ setSelectedCursos(formacao.cursos);
434
+ form.reset({
435
+ nome: formacao.nome,
436
+ descricao: formacao.descricao,
437
+ area: formacao.area,
438
+ nivel: formacao.nivel,
439
+ prerequisitos: formacao.prerequisitos,
440
+ status: formacao.status,
441
+ });
442
+ setSheetOpen(true);
443
+ }
444
+
445
+ function toggleCurso(cursoNome: string) {
446
+ setSelectedCursos((prev) =>
447
+ prev.includes(cursoNome)
448
+ ? prev.filter((c) => c !== cursoNome)
449
+ : [...prev, cursoNome]
450
+ );
451
+ }
452
+
453
+ async function onSubmit(data: FormacaoForm) {
454
+ setSaving(true);
455
+ await new Promise((r) => setTimeout(r, 500));
456
+ const cargaTotal = availableCursos
457
+ .filter((c) => selectedCursos.includes(c.nome))
458
+ .reduce((acc, c) => acc + c.cargaHoraria, 0);
459
+ if (editingFormacao) {
460
+ setFormacoes((prev) =>
461
+ prev.map((f) =>
462
+ f.id === editingFormacao.id
463
+ ? {
464
+ ...f,
465
+ ...data,
466
+ status: data.status as Formacao['status'],
467
+ cursos: selectedCursos,
468
+ cargaTotal,
469
+ }
470
+ : f
471
+ )
472
+ );
473
+ toast.success(t('toasts.formacaoUpdated'));
474
+ } else {
475
+ const newFormacao: Formacao = {
476
+ id: Date.now(),
477
+ ...data,
478
+ prerequisitos: data.prerequisitos || '',
479
+ status: data.status as Formacao['status'],
480
+ cursos: selectedCursos,
481
+ cargaTotal,
482
+ alunos: 0,
483
+ criadoEm: new Date().toISOString().substring(0, 10),
484
+ };
485
+ setFormacoes((prev) => [newFormacao, ...prev]);
486
+ toast.success(t('toasts.formacaoCriada'));
487
+ }
488
+ setSaving(false);
489
+ setSheetOpen(false);
490
+ setSelectedCursos([]);
491
+ }
492
+
493
+ function confirmDelete() {
494
+ if (!formacaoToDelete) return;
495
+ setFormacoes((prev) => prev.filter((f) => f.id !== formacaoToDelete.id));
496
+ toast.success(t('toasts.formacaoRemovida'));
497
+ setFormacaoToDelete(null);
498
+ setDeleteDialogOpen(false);
499
+ }
500
+
501
+ // ── KPIs ─────────────────────────────────────────────────────���────────────
502
+
503
+ const kpis = [
504
+ {
505
+ label: t('kpis.totalTraining.label'),
506
+ valor: formacoes.length,
507
+ sub: t('kpis.totalTraining.sub'),
508
+ icon: GraduationCap,
509
+ iconBg: 'bg-orange-100',
510
+ iconColor: 'text-orange-600',
511
+ },
512
+ {
513
+ label: t('kpis.activeTraining.label'),
514
+ valor: formacoes.filter((f) => f.status === 'ativa').length,
515
+ sub: t('kpis.activeTraining.sub'),
516
+ icon: Target,
517
+ iconBg: 'bg-muted',
518
+ iconColor: 'text-foreground',
519
+ },
520
+ {
521
+ label: t('kpis.enrolledStudents.label'),
522
+ valor: formacoes
523
+ .reduce((a, f) => a + f.alunos, 0)
524
+ .toLocaleString('pt-BR'),
525
+ sub: t('kpis.enrolledStudents.sub'),
526
+ icon: Users,
527
+ iconBg: 'bg-muted',
528
+ iconColor: 'text-foreground',
529
+ },
530
+ {
531
+ label: t('kpis.coveredCourses.label'),
532
+ valor: new Set(formacoes.flatMap((f) => f.cursos)).size,
533
+ sub: t('kpis.coveredCourses.sub'),
534
+ icon: Layers,
535
+ iconBg: 'bg-muted',
536
+ iconColor: 'text-foreground',
537
+ },
538
+ ];
539
+
540
+ // ── Render ────────────────────────────────────────────────────────────────
541
+
542
+ return (
543
+ <Page>
544
+ <PageHeader
545
+ title={t('title')}
546
+ description={t('description')}
547
+ breadcrumbs={[
548
+ {
549
+ label: t('breadcrumbs.home'),
550
+ href: '/',
551
+ },
552
+ {
553
+ label: t('title'),
554
+ },
555
+ ]}
556
+ actions={
557
+ <Button onClick={openCreateSheet} className="gap-2">
558
+ <Plus className="size-4" />
559
+ {t('actions.createTraining')}
560
+ </Button>
561
+ }
562
+ />
563
+
564
+ {/* KPIs */}
565
+ <div className="mb-6 grid grid-cols-2 gap-4 lg:grid-cols-4">
566
+ {loading
567
+ ? Array.from({ length: 4 }).map((_, i) => (
568
+ <Card key={i}>
569
+ <CardContent className="p-4">
570
+ <Skeleton className="mb-2 h-8 w-16" />
571
+ <Skeleton className="h-4 w-28" />
572
+ </CardContent>
573
+ </Card>
574
+ ))
575
+ : kpis.map((kpi, i) => (
576
+ <motion.div
577
+ key={kpi.label}
578
+ initial={{ opacity: 0, y: 12 }}
579
+ animate={{ opacity: 1, y: 0 }}
580
+ transition={{ delay: i * 0.07 }}
581
+ >
582
+ <Card className="overflow-hidden">
583
+ <CardContent className="flex items-start justify-between p-5">
584
+ <div>
585
+ <p className="text-sm text-muted-foreground">
586
+ {kpi.label}
587
+ </p>
588
+ <p className="mt-1 text-3xl font-bold tracking-tight">
589
+ {kpi.valor}
590
+ </p>
591
+ <p className="mt-0.5 text-xs text-muted-foreground">
592
+ {kpi.sub}
593
+ </p>
594
+ </div>
595
+ <div
596
+ className={`flex size-10 shrink-0 items-center justify-center rounded-lg ${kpi.iconBg}`}
597
+ >
598
+ <kpi.icon className={`size-5 ${kpi.iconColor}`} />
599
+ </div>
600
+ </CardContent>
601
+ </Card>
602
+ </motion.div>
603
+ ))}
604
+ </div>
605
+
606
+ {/* Search bar */}
607
+ <form onSubmit={handleSearch} className="mb-6">
608
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
609
+ <div className="relative flex-1">
610
+ <Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
611
+ <Input
612
+ placeholder={t('filters.searchPlaceholder')}
613
+ value={buscaInput}
614
+ onChange={(e) => setBuscaInput(e.target.value)}
615
+ className="pl-9"
616
+ />
617
+ </div>
618
+ <div className="flex flex-wrap items-center gap-2">
619
+ <Select value={filtroAreaInput} onValueChange={setFiltroAreaInput}>
620
+ <SelectTrigger className="h-9 w-[130px] text-sm">
621
+ <SelectValue placeholder={t('filters.allAreas')} />
622
+ </SelectTrigger>
623
+ <SelectContent>
624
+ <SelectItem value="todos">{t('filters.allAreas')}</SelectItem>
625
+ <SelectItem value="Tecnologia">
626
+ {t('areas.technology')}
627
+ </SelectItem>
628
+ <SelectItem value="Design">{t('areas.design')}</SelectItem>
629
+ <SelectItem value="Gestao">{t('areas.management')}</SelectItem>
630
+ <SelectItem value="Marketing">
631
+ {t('areas.marketing')}
632
+ </SelectItem>
633
+ <SelectItem value="Financas">{t('areas.finance')}</SelectItem>
634
+ </SelectContent>
635
+ </Select>
636
+ <Select
637
+ value={filtroNivelInput}
638
+ onValueChange={setFiltroNivelInput}
639
+ >
640
+ <SelectTrigger className="h-9 w-[130px] text-sm">
641
+ <SelectValue placeholder={t('filters.allLevels')} />
642
+ </SelectTrigger>
643
+ <SelectContent>
644
+ <SelectItem value="todos">{t('filters.allLevels')}</SelectItem>
645
+ <SelectItem value="Iniciante">
646
+ {t('levels.beginner')}
647
+ </SelectItem>
648
+ <SelectItem value="Intermediario">
649
+ {t('levels.intermediate')}
650
+ </SelectItem>
651
+ <SelectItem value="Avancado">{t('levels.advanced')}</SelectItem>
652
+ </SelectContent>
653
+ </Select>
654
+ {hasActiveFilters && (
655
+ <Button
656
+ type="button"
657
+ variant="ghost"
658
+ size="sm"
659
+ onClick={clearFilters}
660
+ className="h-9 text-muted-foreground"
661
+ >
662
+ <X className="mr-1 size-3.5" /> {t('filters.clear')}
663
+ </Button>
664
+ )}
665
+ <Button type="submit" size="sm" className="h-9 gap-2">
666
+ <Search className="size-3.5" /> {t('filters.search')}
667
+ </Button>
668
+ </div>
669
+ </div>
670
+ </form>
671
+
672
+ {/* Cards grid */}
673
+ {loading ? (
674
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
675
+ {Array.from({ length: 6 }).map((_, i) => (
676
+ <Card key={i} className="overflow-hidden">
677
+ <CardContent className="p-5 min-h-[210px] flex flex-col justify-between">
678
+ <div>
679
+ <Skeleton className="mb-3 h-5 w-20 rounded-full" />
680
+ <Skeleton className="mb-1.5 h-5 w-3/4" />
681
+ <Skeleton className="mb-4 h-4 w-full" />
682
+ </div>
683
+ <div className="flex gap-2 mt-auto">
684
+ <Skeleton className="h-6 w-16 rounded-full" />
685
+ <Skeleton className="h-6 w-20 rounded-full" />
686
+ </div>
687
+ </CardContent>
688
+ </Card>
689
+ ))}
690
+ </div>
691
+ ) : filteredFormacoes.length === 0 ? (
692
+ <div className="flex flex-col items-center justify-center py-20 text-center">
693
+ <GraduationCap className="mb-4 size-12 text-muted-foreground/40" />
694
+ <p className="text-lg font-medium">{t('empty.title')}</p>
695
+ <p className="mt-1 text-sm text-muted-foreground">
696
+ {t('empty.description')}
697
+ </p>
698
+ <Button className="mt-6 gap-2" onClick={openCreateSheet}>
699
+ <Plus className="size-4" />
700
+ {t('empty.action')}
701
+ </Button>
702
+ </div>
703
+ ) : (
704
+ <motion.div
705
+ className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"
706
+ variants={stagger}
707
+ initial="hidden"
708
+ animate="show"
709
+ >
710
+ {paginatedFormacoes.map((formacao) => {
711
+ const statusCfg = STATUS_MAP[formacao.status] ?? {
712
+ label: formacao.status,
713
+ variant: 'default' as const,
714
+ };
715
+ const areaColor =
716
+ AREA_COLORS[formacao.area] ??
717
+ 'bg-muted text-foreground border-border';
718
+
719
+ return (
720
+ <motion.div key={formacao.id} variants={fadeUp}>
721
+ <Card
722
+ className="group relative cursor-pointer overflow-hidden transition-all duration-200 hover:shadow-md hover:-translate-y-0.5 min-h-[240px] max-h-[270px] flex flex-col"
723
+ onClick={() => handleCardClick(formacao)}
724
+ title={t('cards.tooltip')}
725
+ >
726
+ <CardContent className="p-5 flex flex-col h-full">
727
+ {/* Top */}
728
+ <div className="mb-3 flex items-center justify-between gap-2">
729
+ <div className="flex flex-wrap items-center gap-1.5">
730
+ <span
731
+ className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${areaColor}`}
732
+ >
733
+ {formacao.area}
734
+ </span>
735
+ <Badge variant={statusCfg.variant} className="text-xs">
736
+ {statusCfg.label}
737
+ </Badge>
738
+ </div>
739
+ <DropdownMenu>
740
+ <DropdownMenuTrigger asChild>
741
+ <Button
742
+ variant="ghost"
743
+ size="icon"
744
+ className="size-7 shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
745
+ onClick={(e) => e.stopPropagation()}
746
+ aria-label={t('cards.actions.label')}
747
+ >
748
+ <MoreHorizontal className="size-4" />
749
+ </Button>
750
+ </DropdownMenuTrigger>
751
+ <DropdownMenuContent align="end" className="w-48">
752
+ <DropdownMenuItem
753
+ onClick={(e) => {
754
+ e.stopPropagation();
755
+ toast.info(t('toasts.openingDetails'));
756
+ }}
757
+ >
758
+ <Eye className="mr-2 size-4" />{' '}
759
+ {t('cards.actions.viewDetails')}
760
+ </DropdownMenuItem>
761
+ <DropdownMenuItem
762
+ onClick={(e) => openEditSheet(formacao, e)}
763
+ >
764
+ <Pencil className="mr-2 size-4" />{' '}
765
+ {t('cards.actions.edit')}
766
+ </DropdownMenuItem>
767
+ <DropdownMenuSeparator />
768
+ <DropdownMenuItem
769
+ className="text-destructive focus:text-destructive"
770
+ onClick={(e) => {
771
+ e.stopPropagation();
772
+ setFormacaoToDelete(formacao);
773
+ setDeleteDialogOpen(true);
774
+ }}
775
+ >
776
+ <Trash2 className="mr-2 size-4" />{' '}
777
+ {t('cards.actions.delete')}
778
+ </DropdownMenuItem>
779
+ </DropdownMenuContent>
780
+ </DropdownMenu>
781
+ </div>
782
+
783
+ {/* Title */}
784
+ <h3 className="mb-0.5 font-semibold leading-tight">
785
+ {formacao.nome}
786
+ </h3>
787
+ <p className="mb-4 line-clamp-2 text-sm text-muted-foreground leading-relaxed">
788
+ {formacao.descricao}
789
+ </p>
790
+
791
+ {/* Course tags */}
792
+ <div className="mb-4 flex flex-wrap gap-1">
793
+ {formacao.cursos.slice(0, 3).map((c) => (
794
+ <Badge
795
+ key={c}
796
+ variant="outline"
797
+ className="text-xs px-1.5 py-0"
798
+ >
799
+ {c}
800
+ </Badge>
801
+ ))}
802
+ {formacao.cursos.length > 3 && (
803
+ <Badge
804
+ variant="outline"
805
+ className="text-xs px-1.5 py-0"
806
+ >
807
+ +{formacao.cursos.length - 3}
808
+ </Badge>
809
+ )}
810
+ </div>
811
+
812
+ <Separator className="mb-3" />
813
+
814
+ {/* Footer */}
815
+ <div className="flex items-center justify-between text-xs text-muted-foreground">
816
+ <div className="flex items-center gap-3">
817
+ <span className="flex items-center gap-1">
818
+ <Layers className="size-3.5" />
819
+ {formacao.cursos.length} {t('cards.coursesLabel')}
820
+ </span>
821
+ <span className="flex items-center gap-1">
822
+ <Clock className="size-3.5" />
823
+ {formacao.cargaTotal}
824
+ {t('cards.hoursLabel')}
825
+ </span>
826
+ </div>
827
+ <span className="flex items-center gap-1">
828
+ <Users className="size-3.5" />
829
+ {formacao.alunos.toLocaleString('pt-BR')}{' '}
830
+ {t('cards.studentsLabel')}
831
+ </span>
832
+ </div>
833
+ </CardContent>
834
+ </Card>
835
+ </motion.div>
836
+ );
837
+ })}
838
+ </motion.div>
839
+ )}
840
+
841
+ {/* Pagination footer */}
842
+ {!loading && filteredFormacoes.length > 0 && (
843
+ <div className="mt-6 flex flex-col items-center justify-between gap-4 sm:flex-row">
844
+ <p className="text-sm text-muted-foreground">
845
+ {filteredFormacoes.length}{' '}
846
+ {filteredFormacoes.length !== 1
847
+ ? t('pagination.formacoes')
848
+ : t('pagination.formacao')}{' '}
849
+ {t('pagination.found')}
850
+ {filteredFormacoes.length !== 1 ? t('pagination.foundPlural') : ''}
851
+ </p>
852
+ <div className="flex items-center gap-1">
853
+ <Button
854
+ variant="outline"
855
+ size="icon"
856
+ className="size-8"
857
+ onClick={() => setCurrentPage(1)}
858
+ disabled={safePage === 1}
859
+ aria-label={t('pagination.firstPage')}
860
+ >
861
+ <ChevronsLeft className="size-4" />
862
+ </Button>
863
+ <Button
864
+ variant="outline"
865
+ size="icon"
866
+ className="size-8"
867
+ onClick={() => setCurrentPage((p) => p - 1)}
868
+ disabled={safePage === 1}
869
+ aria-label={t('pagination.previousPage')}
870
+ >
871
+ <ChevronLeft className="size-4" />
872
+ </Button>
873
+ <span className="px-3 text-sm">
874
+ {t('pagination.page')}{' '}
875
+ <span className="font-semibold">{safePage}</span>{' '}
876
+ {t('pagination.of')}{' '}
877
+ <span className="font-semibold">{totalPages}</span>
878
+ </span>
879
+ <Button
880
+ variant="outline"
881
+ size="icon"
882
+ className="size-8"
883
+ onClick={() => setCurrentPage((p) => p + 1)}
884
+ disabled={safePage === totalPages}
885
+ aria-label={t('pagination.nextPage')}
886
+ >
887
+ <ChevronRight className="size-4" />
888
+ </Button>
889
+ <Button
890
+ variant="outline"
891
+ size="icon"
892
+ className="size-8"
893
+ onClick={() => setCurrentPage(totalPages)}
894
+ disabled={safePage === totalPages}
895
+ aria-label={t('pagination.lastPage')}
896
+ >
897
+ <ChevronsRight className="size-4" />
898
+ </Button>
899
+ </div>
900
+ <div className="flex items-center gap-2 text-sm">
901
+ <span className="text-muted-foreground">
902
+ {t('pagination.itemsPerPage')}
903
+ </span>
904
+ <Select
905
+ value={String(pageSize)}
906
+ onValueChange={(v) => {
907
+ setPageSize(Number(v));
908
+ setCurrentPage(1);
909
+ }}
910
+ >
911
+ <SelectTrigger className="h-8 w-16 text-sm">
912
+ <SelectValue />
913
+ </SelectTrigger>
914
+ <SelectContent>
915
+ {PAGE_SIZES.map((s) => (
916
+ <SelectItem key={s} value={String(s)}>
917
+ {s}
918
+ </SelectItem>
919
+ ))}
920
+ </SelectContent>
921
+ </Select>
922
+ </div>
923
+ </div>
924
+ )}
925
+
926
+ {/* Sheet */}
927
+ <Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
928
+ <SheetContent
929
+ side="right"
930
+ className="flex w-full flex-col sm:max-w-lg overflow-y-auto"
931
+ >
932
+ <SheetHeader className="shrink-0">
933
+ <SheetTitle>
934
+ {editingFormacao ? t('form.title.edit') : t('form.title.create')}
935
+ </SheetTitle>
936
+ <SheetDescription>{t('form.description.create')}</SheetDescription>
937
+ </SheetHeader>
938
+ <form
939
+ onSubmit={form.handleSubmit(onSubmit)}
940
+ className="flex flex-1 flex-col gap-4 px-4 py-6"
941
+ >
942
+ <Field>
943
+ <FieldLabel htmlFor="nome">
944
+ {t('form.fields.nome.label')}{' '}
945
+ <span className="text-destructive">*</span>
946
+ </FieldLabel>
947
+ <Input
948
+ id="nome"
949
+ placeholder={t('form.fields.nome.placeholder')}
950
+ {...form.register('nome')}
951
+ />
952
+ <FieldError>{form.formState.errors.nome?.message}</FieldError>
953
+ </Field>
954
+ <Field>
955
+ <FieldLabel htmlFor="descricao">
956
+ {t('form.fields.descricao.label')}{' '}
957
+ <span className="text-destructive">*</span>
958
+ </FieldLabel>
959
+ <Textarea
960
+ id="descricao"
961
+ rows={3}
962
+ placeholder={t('form.fields.descricao.placeholder')}
963
+ {...form.register('descricao')}
964
+ />
965
+ <FieldError>
966
+ {form.formState.errors.descricao?.message}
967
+ </FieldError>
968
+ </Field>
969
+ <div className="grid grid-cols-2 gap-4">
970
+ <Field>
971
+ <FieldLabel>
972
+ {t('form.fields.area.label')}{' '}
973
+ <span className="text-destructive">*</span>
974
+ </FieldLabel>
975
+ <Controller
976
+ name="area"
977
+ control={form.control}
978
+ render={({ field }) => (
979
+ <Select onValueChange={field.onChange} value={field.value}>
980
+ <SelectTrigger>
981
+ <SelectValue
982
+ placeholder={t('form.fields.area.placeholder')}
983
+ />
984
+ </SelectTrigger>
985
+ <SelectContent>
986
+ <SelectItem value="Tecnologia">
987
+ {t('areas.technology')}
988
+ </SelectItem>
989
+ <SelectItem value="Design">
990
+ {t('areas.design')}
991
+ </SelectItem>
992
+ <SelectItem value="Gestao">
993
+ {t('areas.management')}
994
+ </SelectItem>
995
+ <SelectItem value="Marketing">
996
+ {t('areas.marketing')}
997
+ </SelectItem>
998
+ <SelectItem value="Financas">
999
+ {t('areas.finance')}
1000
+ </SelectItem>
1001
+ </SelectContent>
1002
+ </Select>
1003
+ )}
1004
+ />
1005
+ <FieldError>{form.formState.errors.area?.message}</FieldError>
1006
+ </Field>
1007
+ <Field>
1008
+ <FieldLabel>
1009
+ {t('form.fields.nivel.label')}{' '}
1010
+ <span className="text-destructive">*</span>
1011
+ </FieldLabel>
1012
+ <Controller
1013
+ name="nivel"
1014
+ control={form.control}
1015
+ render={({ field }) => (
1016
+ <Select onValueChange={field.onChange} value={field.value}>
1017
+ <SelectTrigger>
1018
+ <SelectValue
1019
+ placeholder={t('form.fields.nivel.placeholder')}
1020
+ />
1021
+ </SelectTrigger>
1022
+ <SelectContent>
1023
+ <SelectItem value="Iniciante">
1024
+ {t('levels.beginner')}
1025
+ </SelectItem>
1026
+ <SelectItem value="Intermediario">
1027
+ {t('levels.intermediate')}
1028
+ </SelectItem>
1029
+ <SelectItem value="Avancado">
1030
+ {t('levels.advanced')}
1031
+ </SelectItem>
1032
+ </SelectContent>
1033
+ </Select>
1034
+ )}
1035
+ />
1036
+ <FieldError>{form.formState.errors.nivel?.message}</FieldError>
1037
+ </Field>
1038
+ </div>
1039
+ <Field>
1040
+ <FieldLabel>
1041
+ {t('form.fields.status.label')}{' '}
1042
+ <span className="text-destructive">*</span>
1043
+ </FieldLabel>
1044
+ <Controller
1045
+ name="status"
1046
+ control={form.control}
1047
+ render={({ field }) => (
1048
+ <Select onValueChange={field.onChange} value={field.value}>
1049
+ <SelectTrigger>
1050
+ <SelectValue />
1051
+ </SelectTrigger>
1052
+ <SelectContent>
1053
+ <SelectItem value="rascunho">
1054
+ {t('status.draft')}
1055
+ </SelectItem>
1056
+ <SelectItem value="ativa">
1057
+ {t('status.active')}
1058
+ </SelectItem>
1059
+ <SelectItem value="encerrada">
1060
+ {t('status.closed')}
1061
+ </SelectItem>
1062
+ </SelectContent>
1063
+ </Select>
1064
+ )}
1065
+ />
1066
+ <FieldError>{form.formState.errors.status?.message}</FieldError>
1067
+ </Field>
1068
+ <Field>
1069
+ <FieldLabel htmlFor="prerequisitos">
1070
+ {t('form.fields.prerequisitos.label')}
1071
+ </FieldLabel>
1072
+ <Input
1073
+ id="prerequisitos"
1074
+ placeholder={t('form.fields.prerequisitos.placeholder')}
1075
+ {...form.register('prerequisitos')}
1076
+ />
1077
+ </Field>
1078
+
1079
+ {/* Cursos */}
1080
+ <Field>
1081
+ <FieldLabel>{t('form.fields.cursos.label')}</FieldLabel>
1082
+ <div className="rounded-md border">
1083
+ {availableCursos.map((c) => (
1084
+ <label
1085
+ key={c.id}
1086
+ className="flex cursor-pointer items-center justify-between border-b p-2.5 last:border-0 hover:bg-muted has-[:checked]:bg-muted/50"
1087
+ >
1088
+ <div className="flex items-center gap-2">
1089
+ <Checkbox
1090
+ checked={selectedCursos.includes(c.nome)}
1091
+ onCheckedChange={() => toggleCurso(c.nome)}
1092
+ />
1093
+ <span className="text-sm">{c.nome}</span>
1094
+ </div>
1095
+ <span className="text-xs text-muted-foreground">
1096
+ {c.cargaHoraria}h
1097
+ </span>
1098
+ </label>
1099
+ ))}
1100
+ </div>
1101
+ {selectedCursos.length > 0 && (
1102
+ <p className="text-xs text-muted-foreground mt-1">
1103
+ {selectedCursos.length} {t('coursesSummary.courses')}{' '}
1104
+ {t('coursesSummary.dot')}{' '}
1105
+ {availableCursos
1106
+ .filter((c) => selectedCursos.includes(c.nome))
1107
+ .reduce((a, c) => a + c.cargaHoraria, 0)}
1108
+ {t('coursesSummary.hours')}
1109
+ </p>
1110
+ )}
1111
+ </Field>
1112
+
1113
+ <SheetFooter className="mt-auto shrink-0 gap-2 pt-4">
1114
+ <Button type="submit" disabled={saving} className="gap-2">
1115
+ {saving && <Loader2 className="size-4 animate-spin" />}
1116
+ {editingFormacao
1117
+ ? t('form.actions.save')
1118
+ : t('form.actions.create')}
1119
+ </Button>
1120
+ </SheetFooter>
1121
+ </form>
1122
+ </SheetContent>
1123
+ </Sheet>
1124
+
1125
+ {/* Delete Dialog */}
1126
+ <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
1127
+ <DialogContent>
1128
+ <DialogHeader>
1129
+ <DialogTitle className="flex items-center gap-2">
1130
+ <AlertTriangle className="size-5 text-destructive" />{' '}
1131
+ {t('deleteDialog.title')}
1132
+ </DialogTitle>
1133
+ <DialogDescription>
1134
+ {t('deleteDialog.description')}{' '}
1135
+ <strong>{formacaoToDelete?.nome}</strong>?
1136
+ {(formacaoToDelete?.alunos ?? 0) > 0 && (
1137
+ <span className="mt-2 block rounded-md bg-destructive/10 p-2 text-sm text-destructive">
1138
+ {t('deleteDialog.warning', {
1139
+ count: formacaoToDelete?.alunos ?? 0,
1140
+ })}
1141
+ </span>
1142
+ )}
1143
+ </DialogDescription>
1144
+ </DialogHeader>
1145
+ <DialogFooter className="gap-2">
1146
+ <Button
1147
+ variant="outline"
1148
+ onClick={() => setDeleteDialogOpen(false)}
1149
+ >
1150
+ {t('deleteDialog.actions.cancel')}
1151
+ </Button>
1152
+ <Button
1153
+ variant="destructive"
1154
+ onClick={confirmDelete}
1155
+ className="gap-2"
1156
+ >
1157
+ <Trash2 className="size-4" /> {t('deleteDialog.actions.delete')}
1158
+ </Button>
1159
+ </DialogFooter>
1160
+ </DialogContent>
1161
+ </Dialog>
1162
+ </Page>
1163
+ );
1164
+ }