@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.
- package/hedhog/data/menu.yaml +35 -2
- package/hedhog/frontend/app/courses/page.tsx.ejs +1 -8
- package/hedhog/frontend/app/exams/page.tsx.ejs +1 -9
- package/hedhog/frontend/app/reports/page.tsx.ejs +910 -0
- package/hedhog/frontend/app/training/page.tsx.ejs +1164 -0
- package/hedhog/frontend/messages/en.json +310 -73
- package/hedhog/frontend/messages/pt.json +310 -73
- package/hedhog/table/reports.yaml +3 -0
- package/hedhog/table/training.yaml +3 -0
- package/package.json +5 -5
|
@@ -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
|
+
}
|