@hed-hog/lms 0.0.2
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/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/lms.module.d.ts +3 -0
- package/dist/lms.module.d.ts.map +1 -0
- package/dist/lms.module.js +28 -0
- package/dist/lms.module.js.map +1 -0
- package/hedhog/data/menu.yaml +12 -0
- package/hedhog/data/role.yaml +7 -0
- package/hedhog/frontend/app/courses/page.tsx.ejs +1394 -0
- package/hedhog/frontend/app/page.tsx.ejs +1222 -0
- package/hedhog/frontend/messages/en.json +10 -0
- package/hedhog/frontend/messages/pt.json +10 -0
- package/package.json +37 -0
- package/src/index.ts +1 -0
- package/src/lms.module.ts +15 -0
|
@@ -0,0 +1,1394 @@
|
|
|
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 {
|
|
24
|
+
Field,
|
|
25
|
+
FieldDescription,
|
|
26
|
+
FieldError,
|
|
27
|
+
FieldLabel,
|
|
28
|
+
} from '@/components/ui/field';
|
|
29
|
+
import { Input } from '@/components/ui/input';
|
|
30
|
+
import {
|
|
31
|
+
Select,
|
|
32
|
+
SelectContent,
|
|
33
|
+
SelectItem,
|
|
34
|
+
SelectTrigger,
|
|
35
|
+
SelectValue,
|
|
36
|
+
} from '@/components/ui/select';
|
|
37
|
+
import { Separator } from '@/components/ui/separator';
|
|
38
|
+
import {
|
|
39
|
+
Sheet,
|
|
40
|
+
SheetContent,
|
|
41
|
+
SheetDescription,
|
|
42
|
+
SheetFooter,
|
|
43
|
+
SheetHeader,
|
|
44
|
+
SheetTitle,
|
|
45
|
+
} from '@/components/ui/sheet';
|
|
46
|
+
import { Skeleton } from '@/components/ui/skeleton';
|
|
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
|
+
import { Textarea } from '@/components/ui/textarea';
|
|
57
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
58
|
+
import { AnimatePresence, motion } from 'framer-motion';
|
|
59
|
+
import {
|
|
60
|
+
AlertTriangle,
|
|
61
|
+
Archive,
|
|
62
|
+
BarChart3,
|
|
63
|
+
BookOpen,
|
|
64
|
+
CheckSquare,
|
|
65
|
+
ChevronLeft,
|
|
66
|
+
ChevronRight,
|
|
67
|
+
Copy,
|
|
68
|
+
Eye,
|
|
69
|
+
FileCheck,
|
|
70
|
+
Filter,
|
|
71
|
+
GraduationCap,
|
|
72
|
+
Hash,
|
|
73
|
+
LayoutDashboard,
|
|
74
|
+
Loader2,
|
|
75
|
+
MoreHorizontal,
|
|
76
|
+
Pencil,
|
|
77
|
+
Plus,
|
|
78
|
+
Search,
|
|
79
|
+
Trash2,
|
|
80
|
+
Users,
|
|
81
|
+
X,
|
|
82
|
+
} from 'lucide-react';
|
|
83
|
+
import { usePathname, useRouter } from 'next/navigation';
|
|
84
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
85
|
+
import { Controller, useForm } from 'react-hook-form';
|
|
86
|
+
import { toast } from 'sonner';
|
|
87
|
+
import { z } from 'zod';
|
|
88
|
+
|
|
89
|
+
// ── Navigation ───────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
const NAV_ITEMS = [
|
|
92
|
+
{ label: 'Dashboard', href: '/', icon: LayoutDashboard },
|
|
93
|
+
{ label: 'Cursos', href: '/cursos', icon: BookOpen },
|
|
94
|
+
{ label: 'Turmas', href: '/turmas', icon: Users },
|
|
95
|
+
{ label: 'Exames', href: '/exames', icon: FileCheck },
|
|
96
|
+
{ label: 'Formacoes', href: '/formacoes', icon: GraduationCap },
|
|
97
|
+
{ label: 'Relatorios', href: '/relatorios', icon: BarChart3 },
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
interface Curso {
|
|
103
|
+
id: number;
|
|
104
|
+
codigo: string;
|
|
105
|
+
nomeInterno: string;
|
|
106
|
+
tituloComercial: string;
|
|
107
|
+
descricao: string;
|
|
108
|
+
nivel: 'iniciante' | 'intermediario' | 'avancado';
|
|
109
|
+
status: 'ativo' | 'rascunho' | 'arquivado';
|
|
110
|
+
categorias: string[];
|
|
111
|
+
destaque: boolean;
|
|
112
|
+
certificado: boolean;
|
|
113
|
+
listado: boolean;
|
|
114
|
+
alunosInscritos: number;
|
|
115
|
+
criadoEm: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Schema ───────────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
const cursoSchema = z.object({
|
|
121
|
+
codigo: z
|
|
122
|
+
.string()
|
|
123
|
+
.min(2, 'Codigo deve ter pelo menos 2 caracteres')
|
|
124
|
+
.max(16, 'Codigo deve ter no maximo 16 caracteres')
|
|
125
|
+
.regex(/^[A-Z0-9-]+$/i, 'Apenas letras, numeros e hifens'),
|
|
126
|
+
nomeInterno: z
|
|
127
|
+
.string()
|
|
128
|
+
.min(3, 'Nome interno deve ter pelo menos 3 caracteres'),
|
|
129
|
+
tituloComercial: z
|
|
130
|
+
.string()
|
|
131
|
+
.min(3, 'Titulo comercial deve ter pelo menos 3 caracteres'),
|
|
132
|
+
descricao: z.string().min(10, 'Descricao deve ter pelo menos 10 caracteres'),
|
|
133
|
+
nivel: z.enum(['iniciante', 'intermediario', 'avancado'], {
|
|
134
|
+
errorMap: () => ({ message: 'Selecione um nivel' }),
|
|
135
|
+
}),
|
|
136
|
+
status: z.enum(['ativo', 'rascunho', 'arquivado'], {
|
|
137
|
+
errorMap: () => ({ message: 'Selecione um status' }),
|
|
138
|
+
}),
|
|
139
|
+
categorias: z.array(z.string()).min(1, 'Selecione pelo menos uma categoria'),
|
|
140
|
+
destaque: z.boolean(),
|
|
141
|
+
certificado: z.boolean(),
|
|
142
|
+
listado: z.boolean(),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
type CursoForm = z.infer<typeof cursoSchema>;
|
|
146
|
+
|
|
147
|
+
// ── Constants ────────────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
const CATEGORIAS = [
|
|
150
|
+
'Tecnologia',
|
|
151
|
+
'Design',
|
|
152
|
+
'Gestao',
|
|
153
|
+
'Marketing',
|
|
154
|
+
'Financas',
|
|
155
|
+
'Saude',
|
|
156
|
+
'Idiomas',
|
|
157
|
+
'Direito',
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
const NIVEIS = [
|
|
161
|
+
{ value: 'iniciante', label: 'Iniciante' },
|
|
162
|
+
{ value: 'intermediario', label: 'Intermediario' },
|
|
163
|
+
{ value: 'avancado', label: 'Avancado' },
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
const STATUS_OPTIONS = [
|
|
167
|
+
{ value: 'ativo', label: 'Ativo' },
|
|
168
|
+
{ value: 'rascunho', label: 'Rascunho' },
|
|
169
|
+
{ value: 'arquivado', label: 'Arquivado' },
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
const STATUS_MAP: Record<
|
|
173
|
+
string,
|
|
174
|
+
{ label: string; variant: 'default' | 'secondary' | 'outline' }
|
|
175
|
+
> = {
|
|
176
|
+
ativo: { label: 'Ativo', variant: 'default' },
|
|
177
|
+
rascunho: { label: 'Rascunho', variant: 'secondary' },
|
|
178
|
+
arquivado: { label: 'Arquivado', variant: 'outline' },
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const NIVEL_MAP: Record<string, string> = {
|
|
182
|
+
iniciante: 'Iniciante',
|
|
183
|
+
intermediario: 'Intermediario',
|
|
184
|
+
avancado: 'Avancado',
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
// ── Seed Data ────────────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
const initialCursos: Curso[] = [
|
|
190
|
+
{
|
|
191
|
+
id: 1,
|
|
192
|
+
codigo: 'REACT-ADV',
|
|
193
|
+
nomeInterno: 'react-avancado',
|
|
194
|
+
tituloComercial: 'React Avancado',
|
|
195
|
+
descricao:
|
|
196
|
+
'Curso completo de React com hooks, context e patterns avancados para aplicacoes modernas',
|
|
197
|
+
nivel: 'avancado',
|
|
198
|
+
status: 'ativo',
|
|
199
|
+
categorias: ['Tecnologia'],
|
|
200
|
+
destaque: true,
|
|
201
|
+
certificado: true,
|
|
202
|
+
listado: true,
|
|
203
|
+
alunosInscritos: 245,
|
|
204
|
+
criadoEm: '2025-01-15',
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
id: 2,
|
|
208
|
+
codigo: 'UX-FUND',
|
|
209
|
+
nomeInterno: 'ux-fundamentals',
|
|
210
|
+
tituloComercial: 'UX Design Fundamentals',
|
|
211
|
+
descricao:
|
|
212
|
+
'Fundamentos de design de experiencia do usuario com ferramentas modernas e pesquisa',
|
|
213
|
+
nivel: 'iniciante',
|
|
214
|
+
status: 'ativo',
|
|
215
|
+
categorias: ['Design'],
|
|
216
|
+
destaque: false,
|
|
217
|
+
certificado: true,
|
|
218
|
+
listado: true,
|
|
219
|
+
alunosInscritos: 189,
|
|
220
|
+
criadoEm: '2025-02-10',
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
id: 3,
|
|
224
|
+
codigo: 'GEST-AGIL',
|
|
225
|
+
nomeInterno: 'gestao-agil',
|
|
226
|
+
tituloComercial: 'Gestao de Projetos Ageis',
|
|
227
|
+
descricao:
|
|
228
|
+
'Metodologias ageis para gestao de projetos incluindo Scrum, Kanban e SAFe em equipes',
|
|
229
|
+
nivel: 'intermediario',
|
|
230
|
+
status: 'ativo',
|
|
231
|
+
categorias: ['Gestao'],
|
|
232
|
+
destaque: true,
|
|
233
|
+
certificado: true,
|
|
234
|
+
listado: true,
|
|
235
|
+
alunosInscritos: 312,
|
|
236
|
+
criadoEm: '2025-01-20',
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
id: 4,
|
|
240
|
+
codigo: 'MKT-DIG',
|
|
241
|
+
nomeInterno: 'marketing-digital',
|
|
242
|
+
tituloComercial: 'Marketing Digital Completo',
|
|
243
|
+
descricao:
|
|
244
|
+
'Estrategias de marketing digital para negocios modernos incluindo SEO, SEM e redes sociais',
|
|
245
|
+
nivel: 'intermediario',
|
|
246
|
+
status: 'rascunho',
|
|
247
|
+
categorias: ['Marketing'],
|
|
248
|
+
destaque: false,
|
|
249
|
+
certificado: false,
|
|
250
|
+
listado: false,
|
|
251
|
+
alunosInscritos: 0,
|
|
252
|
+
criadoEm: '2025-03-05',
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
id: 5,
|
|
256
|
+
codigo: 'PY-DS',
|
|
257
|
+
nomeInterno: 'python-data-science',
|
|
258
|
+
tituloComercial: 'Python para Data Science',
|
|
259
|
+
descricao:
|
|
260
|
+
'Introducao a Python focado em ciencia de dados, analise estatistica e machine learning basico',
|
|
261
|
+
nivel: 'intermediario',
|
|
262
|
+
status: 'ativo',
|
|
263
|
+
categorias: ['Tecnologia'],
|
|
264
|
+
destaque: false,
|
|
265
|
+
certificado: true,
|
|
266
|
+
listado: true,
|
|
267
|
+
alunosInscritos: 178,
|
|
268
|
+
criadoEm: '2025-02-28',
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
id: 6,
|
|
272
|
+
codigo: 'NODE-API',
|
|
273
|
+
nomeInterno: 'node-completo',
|
|
274
|
+
tituloComercial: 'Node.js Completo',
|
|
275
|
+
descricao:
|
|
276
|
+
'Backend com Node.js, Express e bancos de dados relacionais e NoSQL para APIs robustas',
|
|
277
|
+
nivel: 'avancado',
|
|
278
|
+
status: 'ativo',
|
|
279
|
+
categorias: ['Tecnologia'],
|
|
280
|
+
destaque: true,
|
|
281
|
+
certificado: true,
|
|
282
|
+
listado: true,
|
|
283
|
+
alunosInscritos: 156,
|
|
284
|
+
criadoEm: '2025-01-10',
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
id: 7,
|
|
288
|
+
codigo: 'FIGMA-INI',
|
|
289
|
+
nomeInterno: 'figma-iniciantes',
|
|
290
|
+
tituloComercial: 'Figma para Iniciantes',
|
|
291
|
+
descricao:
|
|
292
|
+
'Aprenda a usar o Figma do zero para criar interfaces profissionais e prototipos interativos',
|
|
293
|
+
nivel: 'iniciante',
|
|
294
|
+
status: 'arquivado',
|
|
295
|
+
categorias: ['Design'],
|
|
296
|
+
destaque: false,
|
|
297
|
+
certificado: true,
|
|
298
|
+
listado: false,
|
|
299
|
+
alunosInscritos: 420,
|
|
300
|
+
criadoEm: '2024-11-05',
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
id: 8,
|
|
304
|
+
codigo: 'LIDER-COM',
|
|
305
|
+
nomeInterno: 'lideranca-comunicacao',
|
|
306
|
+
tituloComercial: 'Lideranca e Comunicacao',
|
|
307
|
+
descricao:
|
|
308
|
+
'Desenvolva habilidades de lideranca e comunicacao assertiva para ambientes corporativos',
|
|
309
|
+
nivel: 'iniciante',
|
|
310
|
+
status: 'ativo',
|
|
311
|
+
categorias: ['Gestao'],
|
|
312
|
+
destaque: false,
|
|
313
|
+
certificado: true,
|
|
314
|
+
listado: true,
|
|
315
|
+
alunosInscritos: 98,
|
|
316
|
+
criadoEm: '2025-03-12',
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
id: 9,
|
|
320
|
+
codigo: 'SEO-ADV',
|
|
321
|
+
nomeInterno: 'seo-avancado',
|
|
322
|
+
tituloComercial: 'SEO Avancado',
|
|
323
|
+
descricao:
|
|
324
|
+
'Tecnicas avancadas de otimizacao para motores de busca, conteudo web e performance de sites',
|
|
325
|
+
nivel: 'avancado',
|
|
326
|
+
status: 'rascunho',
|
|
327
|
+
categorias: ['Marketing', 'Tecnologia'],
|
|
328
|
+
destaque: false,
|
|
329
|
+
certificado: false,
|
|
330
|
+
listado: false,
|
|
331
|
+
alunosInscritos: 0,
|
|
332
|
+
criadoEm: '2025-04-01',
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
id: 10,
|
|
336
|
+
codigo: 'TS-PRAT',
|
|
337
|
+
nomeInterno: 'typescript-pratica',
|
|
338
|
+
tituloComercial: 'TypeScript na Pratica',
|
|
339
|
+
descricao:
|
|
340
|
+
'TypeScript aplicado em projetos reais com boas praticas, design patterns e testes',
|
|
341
|
+
nivel: 'intermediario',
|
|
342
|
+
status: 'ativo',
|
|
343
|
+
categorias: ['Tecnologia'],
|
|
344
|
+
destaque: true,
|
|
345
|
+
certificado: true,
|
|
346
|
+
listado: true,
|
|
347
|
+
alunosInscritos: 201,
|
|
348
|
+
criadoEm: '2025-02-15',
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
id: 11,
|
|
352
|
+
codigo: 'DS-SYS',
|
|
353
|
+
nomeInterno: 'design-system',
|
|
354
|
+
tituloComercial: 'Design System Completo',
|
|
355
|
+
descricao:
|
|
356
|
+
'Como criar e manter um design system escalavel para grandes equipes de produto e engenharia',
|
|
357
|
+
nivel: 'avancado',
|
|
358
|
+
status: 'ativo',
|
|
359
|
+
categorias: ['Design', 'Tecnologia'],
|
|
360
|
+
destaque: false,
|
|
361
|
+
certificado: true,
|
|
362
|
+
listado: true,
|
|
363
|
+
alunosInscritos: 87,
|
|
364
|
+
criadoEm: '2025-03-20',
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
id: 12,
|
|
368
|
+
codigo: 'EXCEL-BIZ',
|
|
369
|
+
nomeInterno: 'excel-negocios',
|
|
370
|
+
tituloComercial: 'Excel para Negocios',
|
|
371
|
+
descricao:
|
|
372
|
+
'Domine Excel com formulas avancadas, dashboards profissionais e analise de dados empresariais',
|
|
373
|
+
nivel: 'iniciante',
|
|
374
|
+
status: 'ativo',
|
|
375
|
+
categorias: ['Gestao', 'Financas'],
|
|
376
|
+
destaque: false,
|
|
377
|
+
certificado: true,
|
|
378
|
+
listado: true,
|
|
379
|
+
alunosInscritos: 534,
|
|
380
|
+
criadoEm: '2024-10-15',
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
id: 13,
|
|
384
|
+
codigo: 'FIN-PESSOAL',
|
|
385
|
+
nomeInterno: 'financas-pessoais',
|
|
386
|
+
tituloComercial: 'Financas Pessoais',
|
|
387
|
+
descricao:
|
|
388
|
+
'Aprenda a gerenciar suas financas, investir e planejar sua aposentadoria de forma inteligente',
|
|
389
|
+
nivel: 'iniciante',
|
|
390
|
+
status: 'ativo',
|
|
391
|
+
categorias: ['Financas'],
|
|
392
|
+
destaque: false,
|
|
393
|
+
certificado: true,
|
|
394
|
+
listado: true,
|
|
395
|
+
alunosInscritos: 342,
|
|
396
|
+
criadoEm: '2025-01-08',
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
id: 14,
|
|
400
|
+
codigo: 'FLUTTER-MOB',
|
|
401
|
+
nomeInterno: 'flutter-mobile',
|
|
402
|
+
tituloComercial: 'Flutter para Mobile',
|
|
403
|
+
descricao:
|
|
404
|
+
'Desenvolvimento de aplicativos moveis multiplataforma com Flutter e Dart do zero ao deploy',
|
|
405
|
+
nivel: 'intermediario',
|
|
406
|
+
status: 'ativo',
|
|
407
|
+
categorias: ['Tecnologia'],
|
|
408
|
+
destaque: true,
|
|
409
|
+
certificado: true,
|
|
410
|
+
listado: true,
|
|
411
|
+
alunosInscritos: 167,
|
|
412
|
+
criadoEm: '2025-04-02',
|
|
413
|
+
},
|
|
414
|
+
{
|
|
415
|
+
id: 15,
|
|
416
|
+
codigo: 'DIR-TRAB',
|
|
417
|
+
nomeInterno: 'direito-trabalhista',
|
|
418
|
+
tituloComercial: 'Direito Trabalhista Essencial',
|
|
419
|
+
descricao:
|
|
420
|
+
'Conceitos essenciais de direito trabalhista para gestores de RH e empreendedores',
|
|
421
|
+
nivel: 'iniciante',
|
|
422
|
+
status: 'rascunho',
|
|
423
|
+
categorias: ['Direito', 'Gestao'],
|
|
424
|
+
destaque: false,
|
|
425
|
+
certificado: false,
|
|
426
|
+
listado: false,
|
|
427
|
+
alunosInscritos: 0,
|
|
428
|
+
criadoEm: '2025-04-10',
|
|
429
|
+
},
|
|
430
|
+
];
|
|
431
|
+
|
|
432
|
+
const ITEMS_PER_PAGE = 8;
|
|
433
|
+
|
|
434
|
+
// ── Animations ───────────────────────────────────────────────────────────────
|
|
435
|
+
|
|
436
|
+
const stagger = {
|
|
437
|
+
hidden: {},
|
|
438
|
+
show: { transition: { staggerChildren: 0.06 } },
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
const fadeUp = {
|
|
442
|
+
hidden: { opacity: 0, y: 16 },
|
|
443
|
+
show: { opacity: 1, y: 0, transition: { duration: 0.35, ease: 'easeOut' } },
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
// ── Component ────────────────────────────────────────────────────────────────
|
|
447
|
+
|
|
448
|
+
export default function CursosPage() {
|
|
449
|
+
const pathname = usePathname();
|
|
450
|
+
const router = useRouter();
|
|
451
|
+
|
|
452
|
+
// UI states
|
|
453
|
+
const [loading, setLoading] = useState(true);
|
|
454
|
+
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
|
455
|
+
const [sheetOpen, setSheetOpen] = useState(false);
|
|
456
|
+
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
457
|
+
const [saving, setSaving] = useState(false);
|
|
458
|
+
|
|
459
|
+
// Data
|
|
460
|
+
const [cursos, setCursos] = useState<Curso[]>(initialCursos);
|
|
461
|
+
const [editingCurso, setEditingCurso] = useState<Curso | null>(null);
|
|
462
|
+
const [cursoToDelete, setCursoToDelete] = useState<Curso | null>(null);
|
|
463
|
+
|
|
464
|
+
// Selection
|
|
465
|
+
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
|
466
|
+
|
|
467
|
+
// Filters
|
|
468
|
+
const [busca, setBusca] = useState('');
|
|
469
|
+
const [filtroStatus, setFiltroStatus] = useState('todos');
|
|
470
|
+
const [filtroNivel, setFiltroNivel] = useState('todos');
|
|
471
|
+
const [filtroCategoria, setFiltroCategoria] = useState('todos');
|
|
472
|
+
|
|
473
|
+
// Pagination
|
|
474
|
+
const [currentPage, setCurrentPage] = useState(1);
|
|
475
|
+
|
|
476
|
+
const form = useForm<CursoForm>({
|
|
477
|
+
resolver: zodResolver(cursoSchema),
|
|
478
|
+
defaultValues: {
|
|
479
|
+
codigo: '',
|
|
480
|
+
nomeInterno: '',
|
|
481
|
+
tituloComercial: '',
|
|
482
|
+
descricao: '',
|
|
483
|
+
nivel: 'iniciante',
|
|
484
|
+
status: 'rascunho',
|
|
485
|
+
categorias: [],
|
|
486
|
+
destaque: false,
|
|
487
|
+
certificado: true,
|
|
488
|
+
listado: false,
|
|
489
|
+
},
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
useEffect(() => {
|
|
493
|
+
const t = setTimeout(() => setLoading(false), 900);
|
|
494
|
+
return () => clearTimeout(t);
|
|
495
|
+
}, []);
|
|
496
|
+
|
|
497
|
+
// ── Filtering ──────────────────────────────────────────────────────────────
|
|
498
|
+
|
|
499
|
+
const filteredCursos = useMemo(() => {
|
|
500
|
+
return cursos.filter((c) => {
|
|
501
|
+
const q = busca.toLowerCase();
|
|
502
|
+
const matchSearch =
|
|
503
|
+
!q ||
|
|
504
|
+
c.codigo.toLowerCase().includes(q) ||
|
|
505
|
+
c.tituloComercial.toLowerCase().includes(q) ||
|
|
506
|
+
c.nomeInterno.toLowerCase().includes(q);
|
|
507
|
+
const matchStatus = filtroStatus === 'todos' || c.status === filtroStatus;
|
|
508
|
+
const matchNivel = filtroNivel === 'todos' || c.nivel === filtroNivel;
|
|
509
|
+
const matchCategoria =
|
|
510
|
+
filtroCategoria === 'todos' || c.categorias.includes(filtroCategoria);
|
|
511
|
+
return matchSearch && matchStatus && matchNivel && matchCategoria;
|
|
512
|
+
});
|
|
513
|
+
}, [cursos, busca, filtroStatus, filtroNivel, filtroCategoria]);
|
|
514
|
+
|
|
515
|
+
const totalPages = Math.max(
|
|
516
|
+
1,
|
|
517
|
+
Math.ceil(filteredCursos.length / ITEMS_PER_PAGE)
|
|
518
|
+
);
|
|
519
|
+
const safePage = Math.min(currentPage, totalPages);
|
|
520
|
+
const paginatedCursos = filteredCursos.slice(
|
|
521
|
+
(safePage - 1) * ITEMS_PER_PAGE,
|
|
522
|
+
safePage * ITEMS_PER_PAGE
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
// Reset page on filter change
|
|
526
|
+
useEffect(() => {
|
|
527
|
+
setCurrentPage(1);
|
|
528
|
+
}, [busca, filtroStatus, filtroNivel, filtroCategoria]);
|
|
529
|
+
|
|
530
|
+
// ── Selection ──────────────────────────────────────────────────────────────
|
|
531
|
+
|
|
532
|
+
const allPageSelected =
|
|
533
|
+
paginatedCursos.length > 0 &&
|
|
534
|
+
paginatedCursos.every((c) => selectedIds.has(c.id));
|
|
535
|
+
|
|
536
|
+
function toggleSelectAll() {
|
|
537
|
+
setSelectedIds((prev) => {
|
|
538
|
+
const next = new Set(prev);
|
|
539
|
+
if (allPageSelected) {
|
|
540
|
+
paginatedCursos.forEach((c) => next.delete(c.id));
|
|
541
|
+
} else {
|
|
542
|
+
paginatedCursos.forEach((c) => next.add(c.id));
|
|
543
|
+
}
|
|
544
|
+
return next;
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function toggleSelect(id: number) {
|
|
549
|
+
setSelectedIds((prev) => {
|
|
550
|
+
const next = new Set(prev);
|
|
551
|
+
if (next.has(id)) {
|
|
552
|
+
next.delete(id);
|
|
553
|
+
} else {
|
|
554
|
+
next.add(id);
|
|
555
|
+
}
|
|
556
|
+
return next;
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ── CRUD ───────────────────────────────────────────────────────────────────
|
|
561
|
+
|
|
562
|
+
function openCreateSheet() {
|
|
563
|
+
setEditingCurso(null);
|
|
564
|
+
form.reset({
|
|
565
|
+
codigo: '',
|
|
566
|
+
nomeInterno: '',
|
|
567
|
+
tituloComercial: '',
|
|
568
|
+
descricao: '',
|
|
569
|
+
nivel: 'iniciante',
|
|
570
|
+
status: 'rascunho',
|
|
571
|
+
categorias: [],
|
|
572
|
+
destaque: false,
|
|
573
|
+
certificado: true,
|
|
574
|
+
listado: false,
|
|
575
|
+
});
|
|
576
|
+
setSheetOpen(true);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function openEditSheet(curso: Curso) {
|
|
580
|
+
setEditingCurso(curso);
|
|
581
|
+
form.reset({
|
|
582
|
+
codigo: curso.codigo,
|
|
583
|
+
nomeInterno: curso.nomeInterno,
|
|
584
|
+
tituloComercial: curso.tituloComercial,
|
|
585
|
+
descricao: curso.descricao,
|
|
586
|
+
nivel: curso.nivel,
|
|
587
|
+
status: curso.status,
|
|
588
|
+
categorias: curso.categorias,
|
|
589
|
+
destaque: curso.destaque,
|
|
590
|
+
certificado: curso.certificado,
|
|
591
|
+
listado: curso.listado,
|
|
592
|
+
});
|
|
593
|
+
setSheetOpen(true);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
async function onSubmit(data: CursoForm) {
|
|
597
|
+
setSaving(true);
|
|
598
|
+
// Simula um delay de API
|
|
599
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
600
|
+
|
|
601
|
+
if (editingCurso) {
|
|
602
|
+
setCursos((prev) =>
|
|
603
|
+
prev.map((c) => (c.id === editingCurso.id ? { ...c, ...data } : c))
|
|
604
|
+
);
|
|
605
|
+
toast.success('Curso atualizado com sucesso!');
|
|
606
|
+
setSaving(false);
|
|
607
|
+
setSheetOpen(false);
|
|
608
|
+
} else {
|
|
609
|
+
const newId = Date.now();
|
|
610
|
+
const newCurso: Curso = {
|
|
611
|
+
id: newId,
|
|
612
|
+
...data,
|
|
613
|
+
alunosInscritos: 0,
|
|
614
|
+
criadoEm: new Date().toISOString().split('T')[0],
|
|
615
|
+
};
|
|
616
|
+
setCursos((prev) => [newCurso, ...prev]);
|
|
617
|
+
toast.success('Curso criado com sucesso! Redirecionando...');
|
|
618
|
+
setSaving(false);
|
|
619
|
+
setSheetOpen(false);
|
|
620
|
+
// Redirecionar para pagina dedicada do curso
|
|
621
|
+
setTimeout(() => {
|
|
622
|
+
router.push(`/cursos/${newId}`);
|
|
623
|
+
}, 400);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function confirmDelete() {
|
|
628
|
+
if (cursoToDelete) {
|
|
629
|
+
setCursos((prev) => prev.filter((c) => c.id !== cursoToDelete.id));
|
|
630
|
+
setSelectedIds((prev) => {
|
|
631
|
+
const next = new Set(prev);
|
|
632
|
+
next.delete(cursoToDelete.id);
|
|
633
|
+
return next;
|
|
634
|
+
});
|
|
635
|
+
toast.success(`Curso "${cursoToDelete.tituloComercial}" removido.`);
|
|
636
|
+
setCursoToDelete(null);
|
|
637
|
+
setDeleteDialogOpen(false);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function bulkDelete() {
|
|
642
|
+
if (selectedIds.size === 0) return;
|
|
643
|
+
setCursos((prev) => prev.filter((c) => !selectedIds.has(c.id)));
|
|
644
|
+
toast.success(`${selectedIds.size} curso(s) removido(s).`);
|
|
645
|
+
setSelectedIds(new Set());
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function bulkArchive() {
|
|
649
|
+
if (selectedIds.size === 0) return;
|
|
650
|
+
setCursos((prev) =>
|
|
651
|
+
prev.map((c) =>
|
|
652
|
+
selectedIds.has(c.id) ? { ...c, status: 'arquivado' as const } : c
|
|
653
|
+
)
|
|
654
|
+
);
|
|
655
|
+
toast.success(`${selectedIds.size} curso(s) arquivado(s).`);
|
|
656
|
+
setSelectedIds(new Set());
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// ── Count badges ───────────────────────────────────────────────────────────
|
|
660
|
+
|
|
661
|
+
const countAtivos = cursos.filter((c) => c.status === 'ativo').length;
|
|
662
|
+
const countRascunhos = cursos.filter((c) => c.status === 'rascunho').length;
|
|
663
|
+
const countArquivados = cursos.filter((c) => c.status === 'arquivado').length;
|
|
664
|
+
|
|
665
|
+
// ── Render ─────────────────────────────────────────────────────────────────
|
|
666
|
+
|
|
667
|
+
return (
|
|
668
|
+
<Page>
|
|
669
|
+
<PageHeader
|
|
670
|
+
title="Cursos"
|
|
671
|
+
description="Gerencie seus cursos, categorias e niveis de forma facil e rapida."
|
|
672
|
+
breadcrumbs={[
|
|
673
|
+
{
|
|
674
|
+
label: 'Home',
|
|
675
|
+
href: '/',
|
|
676
|
+
},
|
|
677
|
+
{
|
|
678
|
+
label: 'Cursos',
|
|
679
|
+
},
|
|
680
|
+
]}
|
|
681
|
+
actions={
|
|
682
|
+
<Button onClick={openCreateSheet} className="gap-2">
|
|
683
|
+
<Plus className="size-4" />
|
|
684
|
+
Criar Curso
|
|
685
|
+
</Button>
|
|
686
|
+
}
|
|
687
|
+
/>
|
|
688
|
+
|
|
689
|
+
<motion.div initial="hidden" animate="show" variants={stagger}>
|
|
690
|
+
{/* Filters */}
|
|
691
|
+
<motion.div variants={fadeUp}>
|
|
692
|
+
<Card className="mb-6">
|
|
693
|
+
<CardContent className="p-4">
|
|
694
|
+
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
|
695
|
+
<div className="relative flex-1">
|
|
696
|
+
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
697
|
+
<Input
|
|
698
|
+
placeholder="Buscar por codigo, titulo ou nome interno..."
|
|
699
|
+
value={busca}
|
|
700
|
+
onChange={(e) => setBusca(e.target.value)}
|
|
701
|
+
className="pl-9"
|
|
702
|
+
/>
|
|
703
|
+
</div>
|
|
704
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
705
|
+
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
706
|
+
<Filter className="size-3.5" />
|
|
707
|
+
Filtros:
|
|
708
|
+
</div>
|
|
709
|
+
<Select value={filtroStatus} onValueChange={setFiltroStatus}>
|
|
710
|
+
<SelectTrigger className="h-9 w-[130px] text-xs">
|
|
711
|
+
<SelectValue placeholder="Status" />
|
|
712
|
+
</SelectTrigger>
|
|
713
|
+
<SelectContent>
|
|
714
|
+
<SelectItem value="todos">Todos Status</SelectItem>
|
|
715
|
+
<SelectItem value="ativo">Ativo</SelectItem>
|
|
716
|
+
<SelectItem value="rascunho">Rascunho</SelectItem>
|
|
717
|
+
<SelectItem value="arquivado">Arquivado</SelectItem>
|
|
718
|
+
</SelectContent>
|
|
719
|
+
</Select>
|
|
720
|
+
<Select value={filtroNivel} onValueChange={setFiltroNivel}>
|
|
721
|
+
<SelectTrigger className="h-9 w-[140px] text-xs">
|
|
722
|
+
<SelectValue placeholder="Nivel" />
|
|
723
|
+
</SelectTrigger>
|
|
724
|
+
<SelectContent>
|
|
725
|
+
<SelectItem value="todos">Todos Niveis</SelectItem>
|
|
726
|
+
<SelectItem value="iniciante">Iniciante</SelectItem>
|
|
727
|
+
<SelectItem value="intermediario">
|
|
728
|
+
Intermediario
|
|
729
|
+
</SelectItem>
|
|
730
|
+
<SelectItem value="avancado">Avancado</SelectItem>
|
|
731
|
+
</SelectContent>
|
|
732
|
+
</Select>
|
|
733
|
+
<Select
|
|
734
|
+
value={filtroCategoria}
|
|
735
|
+
onValueChange={setFiltroCategoria}
|
|
736
|
+
>
|
|
737
|
+
<SelectTrigger className="h-9 w-[140px] text-xs">
|
|
738
|
+
<SelectValue placeholder="Categoria" />
|
|
739
|
+
</SelectTrigger>
|
|
740
|
+
<SelectContent>
|
|
741
|
+
<SelectItem value="todos">Todas Categorias</SelectItem>
|
|
742
|
+
{CATEGORIAS.map((cat) => (
|
|
743
|
+
<SelectItem key={cat} value={cat}>
|
|
744
|
+
{cat}
|
|
745
|
+
</SelectItem>
|
|
746
|
+
))}
|
|
747
|
+
</SelectContent>
|
|
748
|
+
</Select>
|
|
749
|
+
{(filtroStatus !== 'todos' ||
|
|
750
|
+
filtroNivel !== 'todos' ||
|
|
751
|
+
filtroCategoria !== 'todos' ||
|
|
752
|
+
busca) && (
|
|
753
|
+
<Button
|
|
754
|
+
variant="ghost"
|
|
755
|
+
size="sm"
|
|
756
|
+
className="h-9 text-xs text-muted-foreground"
|
|
757
|
+
onClick={() => {
|
|
758
|
+
setFiltroStatus('todos');
|
|
759
|
+
setFiltroNivel('todos');
|
|
760
|
+
setFiltroCategoria('todos');
|
|
761
|
+
setBusca('');
|
|
762
|
+
}}
|
|
763
|
+
>
|
|
764
|
+
<X className="mr-1 size-3" />
|
|
765
|
+
Limpar
|
|
766
|
+
</Button>
|
|
767
|
+
)}
|
|
768
|
+
</div>
|
|
769
|
+
</div>
|
|
770
|
+
</CardContent>
|
|
771
|
+
</Card>
|
|
772
|
+
</motion.div>
|
|
773
|
+
|
|
774
|
+
{/* Bulk actions bar */}
|
|
775
|
+
<AnimatePresence>
|
|
776
|
+
{selectedIds.size > 0 && (
|
|
777
|
+
<motion.div
|
|
778
|
+
initial={{ opacity: 0, y: -8, height: 0 }}
|
|
779
|
+
animate={{ opacity: 1, y: 0, height: 'auto' }}
|
|
780
|
+
exit={{ opacity: 0, y: -8, height: 0 }}
|
|
781
|
+
transition={{ duration: 0.2 }}
|
|
782
|
+
className="mb-4 overflow-hidden"
|
|
783
|
+
>
|
|
784
|
+
<div className="flex items-center gap-3 rounded-lg border bg-muted/50 px-4 py-2.5">
|
|
785
|
+
<CheckSquare className="size-4 text-muted-foreground" />
|
|
786
|
+
<span className="text-sm font-medium">
|
|
787
|
+
{selectedIds.size} selecionado(s)
|
|
788
|
+
</span>
|
|
789
|
+
<Separator orientation="vertical" className="h-5" />
|
|
790
|
+
<Button
|
|
791
|
+
variant="outline"
|
|
792
|
+
size="sm"
|
|
793
|
+
className="h-7 gap-1.5 text-xs"
|
|
794
|
+
onClick={bulkArchive}
|
|
795
|
+
>
|
|
796
|
+
<Archive className="size-3" />
|
|
797
|
+
Arquivar
|
|
798
|
+
</Button>
|
|
799
|
+
<Button
|
|
800
|
+
variant="outline"
|
|
801
|
+
size="sm"
|
|
802
|
+
className="h-7 gap-1.5 text-xs text-destructive hover:text-destructive"
|
|
803
|
+
onClick={bulkDelete}
|
|
804
|
+
>
|
|
805
|
+
<Trash2 className="size-3" />
|
|
806
|
+
Excluir
|
|
807
|
+
</Button>
|
|
808
|
+
<Button
|
|
809
|
+
variant="ghost"
|
|
810
|
+
size="sm"
|
|
811
|
+
className="ml-auto h-7 text-xs"
|
|
812
|
+
onClick={() => setSelectedIds(new Set())}
|
|
813
|
+
>
|
|
814
|
+
Limpar selecao
|
|
815
|
+
</Button>
|
|
816
|
+
</div>
|
|
817
|
+
</motion.div>
|
|
818
|
+
)}
|
|
819
|
+
</AnimatePresence>
|
|
820
|
+
|
|
821
|
+
{/* Table */}
|
|
822
|
+
<motion.div variants={fadeUp}>
|
|
823
|
+
{loading ? (
|
|
824
|
+
<Card>
|
|
825
|
+
<CardContent className="p-0">
|
|
826
|
+
<div className="p-4">
|
|
827
|
+
{Array.from({ length: 8 }).map((_, i) => (
|
|
828
|
+
<div
|
|
829
|
+
key={i}
|
|
830
|
+
className="flex items-center gap-4 border-b py-4 last:border-0"
|
|
831
|
+
>
|
|
832
|
+
<Skeleton className="size-4 rounded" />
|
|
833
|
+
<Skeleton className="h-4 w-20" />
|
|
834
|
+
<Skeleton className="h-4 w-40" />
|
|
835
|
+
<Skeleton className="h-5 w-20 rounded-full" />
|
|
836
|
+
<Skeleton className="h-5 w-16 rounded-full" />
|
|
837
|
+
<Skeleton className="h-5 w-20 rounded-full" />
|
|
838
|
+
<Skeleton className="ml-auto size-8 rounded-md" />
|
|
839
|
+
</div>
|
|
840
|
+
))}
|
|
841
|
+
</div>
|
|
842
|
+
</CardContent>
|
|
843
|
+
</Card>
|
|
844
|
+
) : (
|
|
845
|
+
<Card>
|
|
846
|
+
<CardContent className="p-0">
|
|
847
|
+
<div className="overflow-x-auto">
|
|
848
|
+
<Table>
|
|
849
|
+
<TableHeader>
|
|
850
|
+
<TableRow>
|
|
851
|
+
<TableHead className="w-[40px]">
|
|
852
|
+
<Checkbox
|
|
853
|
+
checked={allPageSelected}
|
|
854
|
+
onCheckedChange={toggleSelectAll}
|
|
855
|
+
aria-label="Selecionar todos"
|
|
856
|
+
/>
|
|
857
|
+
</TableHead>
|
|
858
|
+
<TableHead className="w-[100px]">Codigo</TableHead>
|
|
859
|
+
<TableHead>Titulo</TableHead>
|
|
860
|
+
<TableHead className="hidden md:table-cell">
|
|
861
|
+
Nivel
|
|
862
|
+
</TableHead>
|
|
863
|
+
<TableHead>Status</TableHead>
|
|
864
|
+
<TableHead className="hidden lg:table-cell">
|
|
865
|
+
Categorias
|
|
866
|
+
</TableHead>
|
|
867
|
+
<TableHead className="hidden sm:table-cell text-right">
|
|
868
|
+
Alunos
|
|
869
|
+
</TableHead>
|
|
870
|
+
<TableHead className="w-[50px]" />
|
|
871
|
+
</TableRow>
|
|
872
|
+
</TableHeader>
|
|
873
|
+
<TableBody>
|
|
874
|
+
<AnimatePresence mode="popLayout">
|
|
875
|
+
{paginatedCursos.map((curso) => (
|
|
876
|
+
<motion.tr
|
|
877
|
+
key={curso.id}
|
|
878
|
+
layout
|
|
879
|
+
initial={{ opacity: 0 }}
|
|
880
|
+
animate={{ opacity: 1 }}
|
|
881
|
+
exit={{ opacity: 0, x: -20 }}
|
|
882
|
+
transition={{ duration: 0.2 }}
|
|
883
|
+
className="group border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted"
|
|
884
|
+
data-state={
|
|
885
|
+
selectedIds.has(curso.id) ? 'selected' : undefined
|
|
886
|
+
}
|
|
887
|
+
>
|
|
888
|
+
<TableCell>
|
|
889
|
+
<Checkbox
|
|
890
|
+
checked={selectedIds.has(curso.id)}
|
|
891
|
+
onCheckedChange={() => toggleSelect(curso.id)}
|
|
892
|
+
aria-label={`Selecionar ${curso.tituloComercial}`}
|
|
893
|
+
/>
|
|
894
|
+
</TableCell>
|
|
895
|
+
<TableCell>
|
|
896
|
+
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-medium">
|
|
897
|
+
{curso.codigo}
|
|
898
|
+
</code>
|
|
899
|
+
</TableCell>
|
|
900
|
+
<TableCell>
|
|
901
|
+
<div className="flex flex-col">
|
|
902
|
+
<span className="font-medium">
|
|
903
|
+
{curso.tituloComercial}
|
|
904
|
+
</span>
|
|
905
|
+
<span className="text-xs text-muted-foreground">
|
|
906
|
+
{curso.nomeInterno}
|
|
907
|
+
</span>
|
|
908
|
+
</div>
|
|
909
|
+
</TableCell>
|
|
910
|
+
<TableCell className="hidden md:table-cell">
|
|
911
|
+
<Badge
|
|
912
|
+
variant="outline"
|
|
913
|
+
className="text-xs font-normal"
|
|
914
|
+
>
|
|
915
|
+
{NIVEL_MAP[curso.nivel]}
|
|
916
|
+
</Badge>
|
|
917
|
+
</TableCell>
|
|
918
|
+
<TableCell>
|
|
919
|
+
<Badge variant={STATUS_MAP[curso.status].variant}>
|
|
920
|
+
{STATUS_MAP[curso.status].label}
|
|
921
|
+
</Badge>
|
|
922
|
+
</TableCell>
|
|
923
|
+
<TableCell className="hidden lg:table-cell">
|
|
924
|
+
<div className="flex flex-wrap gap-1">
|
|
925
|
+
{curso.categorias.map((cat) => (
|
|
926
|
+
<Badge
|
|
927
|
+
key={cat}
|
|
928
|
+
variant="secondary"
|
|
929
|
+
className="text-[10px] font-normal"
|
|
930
|
+
>
|
|
931
|
+
{cat}
|
|
932
|
+
</Badge>
|
|
933
|
+
))}
|
|
934
|
+
</div>
|
|
935
|
+
</TableCell>
|
|
936
|
+
<TableCell className="hidden sm:table-cell text-right tabular-nums text-muted-foreground">
|
|
937
|
+
{curso.alunosInscritos.toLocaleString('pt-BR')}
|
|
938
|
+
</TableCell>
|
|
939
|
+
<TableCell>
|
|
940
|
+
<DropdownMenu>
|
|
941
|
+
<DropdownMenuTrigger asChild>
|
|
942
|
+
<Button
|
|
943
|
+
variant="ghost"
|
|
944
|
+
size="icon"
|
|
945
|
+
className="size-8 opacity-0 transition-opacity group-hover:opacity-100 focus:opacity-100"
|
|
946
|
+
>
|
|
947
|
+
<MoreHorizontal className="size-4" />
|
|
948
|
+
<span className="sr-only">Acoes</span>
|
|
949
|
+
</Button>
|
|
950
|
+
</DropdownMenuTrigger>
|
|
951
|
+
<DropdownMenuContent
|
|
952
|
+
align="end"
|
|
953
|
+
className="w-44"
|
|
954
|
+
>
|
|
955
|
+
<DropdownMenuItem
|
|
956
|
+
onClick={() =>
|
|
957
|
+
router.push(`/cursos/${curso.id}`)
|
|
958
|
+
}
|
|
959
|
+
className="gap-2"
|
|
960
|
+
>
|
|
961
|
+
<Eye className="size-3.5" />
|
|
962
|
+
Ver detalhes
|
|
963
|
+
</DropdownMenuItem>
|
|
964
|
+
<DropdownMenuItem
|
|
965
|
+
onClick={() => openEditSheet(curso)}
|
|
966
|
+
className="gap-2"
|
|
967
|
+
>
|
|
968
|
+
<Pencil className="size-3.5" />
|
|
969
|
+
Editar
|
|
970
|
+
</DropdownMenuItem>
|
|
971
|
+
<DropdownMenuItem
|
|
972
|
+
onClick={() => {
|
|
973
|
+
navigator.clipboard.writeText(
|
|
974
|
+
curso.codigo
|
|
975
|
+
);
|
|
976
|
+
toast.info(
|
|
977
|
+
`Codigo "${curso.codigo}" copiado.`
|
|
978
|
+
);
|
|
979
|
+
}}
|
|
980
|
+
className="gap-2"
|
|
981
|
+
>
|
|
982
|
+
<Copy className="size-3.5" />
|
|
983
|
+
Copiar codigo
|
|
984
|
+
</DropdownMenuItem>
|
|
985
|
+
<DropdownMenuSeparator />
|
|
986
|
+
<DropdownMenuItem
|
|
987
|
+
onClick={() => {
|
|
988
|
+
setCursoToDelete(curso);
|
|
989
|
+
setDeleteDialogOpen(true);
|
|
990
|
+
}}
|
|
991
|
+
className="gap-2 text-destructive focus:text-destructive"
|
|
992
|
+
>
|
|
993
|
+
<Trash2 className="size-3.5" />
|
|
994
|
+
Excluir
|
|
995
|
+
</DropdownMenuItem>
|
|
996
|
+
</DropdownMenuContent>
|
|
997
|
+
</DropdownMenu>
|
|
998
|
+
</TableCell>
|
|
999
|
+
</motion.tr>
|
|
1000
|
+
))}
|
|
1001
|
+
</AnimatePresence>
|
|
1002
|
+
{paginatedCursos.length === 0 && (
|
|
1003
|
+
<TableRow>
|
|
1004
|
+
<TableCell colSpan={8} className="py-16 text-center">
|
|
1005
|
+
<div className="flex flex-col items-center gap-2">
|
|
1006
|
+
<BookOpen className="size-10 text-muted-foreground/40" />
|
|
1007
|
+
<p className="text-sm font-medium text-muted-foreground">
|
|
1008
|
+
Nenhum curso encontrado
|
|
1009
|
+
</p>
|
|
1010
|
+
<p className="text-xs text-muted-foreground/70">
|
|
1011
|
+
Tente ajustar os filtros ou criar um novo curso.
|
|
1012
|
+
</p>
|
|
1013
|
+
</div>
|
|
1014
|
+
</TableCell>
|
|
1015
|
+
</TableRow>
|
|
1016
|
+
)}
|
|
1017
|
+
</TableBody>
|
|
1018
|
+
</Table>
|
|
1019
|
+
</div>
|
|
1020
|
+
|
|
1021
|
+
{/* Pagination */}
|
|
1022
|
+
{filteredCursos.length > 0 && (
|
|
1023
|
+
<div className="flex flex-col items-center justify-between gap-3 border-t px-4 py-3 sm:flex-row">
|
|
1024
|
+
<p className="text-xs text-muted-foreground">
|
|
1025
|
+
Mostrando{' '}
|
|
1026
|
+
<span className="font-medium text-foreground">
|
|
1027
|
+
{(safePage - 1) * ITEMS_PER_PAGE + 1}
|
|
1028
|
+
</span>{' '}
|
|
1029
|
+
a{' '}
|
|
1030
|
+
<span className="font-medium text-foreground">
|
|
1031
|
+
{Math.min(
|
|
1032
|
+
safePage * ITEMS_PER_PAGE,
|
|
1033
|
+
filteredCursos.length
|
|
1034
|
+
)}
|
|
1035
|
+
</span>{' '}
|
|
1036
|
+
de{' '}
|
|
1037
|
+
<span className="font-medium text-foreground">
|
|
1038
|
+
{filteredCursos.length}
|
|
1039
|
+
</span>{' '}
|
|
1040
|
+
resultados
|
|
1041
|
+
</p>
|
|
1042
|
+
<div className="flex items-center gap-1">
|
|
1043
|
+
<Button
|
|
1044
|
+
variant="outline"
|
|
1045
|
+
size="icon"
|
|
1046
|
+
className="size-8"
|
|
1047
|
+
disabled={safePage === 1}
|
|
1048
|
+
onClick={() => setCurrentPage((p) => p - 1)}
|
|
1049
|
+
aria-label="Pagina anterior"
|
|
1050
|
+
>
|
|
1051
|
+
<ChevronLeft className="size-4" />
|
|
1052
|
+
</Button>
|
|
1053
|
+
{Array.from({ length: totalPages }).map((_, i) => (
|
|
1054
|
+
<Button
|
|
1055
|
+
key={i}
|
|
1056
|
+
variant={safePage === i + 1 ? 'default' : 'outline'}
|
|
1057
|
+
size="icon"
|
|
1058
|
+
className="size-8 text-xs"
|
|
1059
|
+
onClick={() => setCurrentPage(i + 1)}
|
|
1060
|
+
>
|
|
1061
|
+
{i + 1}
|
|
1062
|
+
</Button>
|
|
1063
|
+
))}
|
|
1064
|
+
<Button
|
|
1065
|
+
variant="outline"
|
|
1066
|
+
size="icon"
|
|
1067
|
+
className="size-8"
|
|
1068
|
+
disabled={safePage === totalPages}
|
|
1069
|
+
onClick={() => setCurrentPage((p) => p + 1)}
|
|
1070
|
+
aria-label="Proxima pagina"
|
|
1071
|
+
>
|
|
1072
|
+
<ChevronRight className="size-4" />
|
|
1073
|
+
</Button>
|
|
1074
|
+
</div>
|
|
1075
|
+
</div>
|
|
1076
|
+
)}
|
|
1077
|
+
</CardContent>
|
|
1078
|
+
</Card>
|
|
1079
|
+
)}
|
|
1080
|
+
</motion.div>
|
|
1081
|
+
</motion.div>
|
|
1082
|
+
|
|
1083
|
+
{/* ── Sheet: Criar / Editar ──────────────────────────────────────────── */}
|
|
1084
|
+
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
|
1085
|
+
<SheetContent className="w-full overflow-y-auto sm:max-w-lg">
|
|
1086
|
+
<SheetHeader>
|
|
1087
|
+
<SheetTitle>
|
|
1088
|
+
{editingCurso ? 'Editar Curso' : 'Criar Novo Curso'}
|
|
1089
|
+
</SheetTitle>
|
|
1090
|
+
<SheetDescription>
|
|
1091
|
+
{editingCurso
|
|
1092
|
+
? 'Atualize as informacoes do curso abaixo.'
|
|
1093
|
+
: 'Preencha os dados para criar um novo curso. Apos criar, voce sera redirecionado para a pagina do curso.'}
|
|
1094
|
+
</SheetDescription>
|
|
1095
|
+
</SheetHeader>
|
|
1096
|
+
|
|
1097
|
+
<form
|
|
1098
|
+
onSubmit={form.handleSubmit(onSubmit)}
|
|
1099
|
+
className="mt-6 flex flex-col gap-5 px-1"
|
|
1100
|
+
>
|
|
1101
|
+
{/* Codigo */}
|
|
1102
|
+
<Field data-invalid={!!form.formState.errors.codigo}>
|
|
1103
|
+
<FieldLabel htmlFor="codigo">
|
|
1104
|
+
<Hash className="size-3.5 text-muted-foreground" />
|
|
1105
|
+
Codigo do Curso
|
|
1106
|
+
</FieldLabel>
|
|
1107
|
+
<Input
|
|
1108
|
+
id="codigo"
|
|
1109
|
+
placeholder="Ex: REACT-ADV"
|
|
1110
|
+
className="uppercase"
|
|
1111
|
+
{...form.register('codigo')}
|
|
1112
|
+
/>
|
|
1113
|
+
{form.formState.errors.codigo && (
|
|
1114
|
+
<FieldError>{form.formState.errors.codigo.message}</FieldError>
|
|
1115
|
+
)}
|
|
1116
|
+
<FieldDescription>
|
|
1117
|
+
Identificador unico do curso (ex: REACT-ADV)
|
|
1118
|
+
</FieldDescription>
|
|
1119
|
+
</Field>
|
|
1120
|
+
|
|
1121
|
+
{/* Nome Interno */}
|
|
1122
|
+
<Field data-invalid={!!form.formState.errors.nomeInterno}>
|
|
1123
|
+
<FieldLabel htmlFor="nomeInterno">Nome Interno</FieldLabel>
|
|
1124
|
+
<Input
|
|
1125
|
+
id="nomeInterno"
|
|
1126
|
+
placeholder="Ex: react-avancado"
|
|
1127
|
+
{...form.register('nomeInterno')}
|
|
1128
|
+
/>
|
|
1129
|
+
{form.formState.errors.nomeInterno && (
|
|
1130
|
+
<FieldError>
|
|
1131
|
+
{form.formState.errors.nomeInterno.message}
|
|
1132
|
+
</FieldError>
|
|
1133
|
+
)}
|
|
1134
|
+
<FieldDescription>
|
|
1135
|
+
Slug interno para uso do sistema
|
|
1136
|
+
</FieldDescription>
|
|
1137
|
+
</Field>
|
|
1138
|
+
|
|
1139
|
+
{/* Titulo Comercial */}
|
|
1140
|
+
<Field data-invalid={!!form.formState.errors.tituloComercial}>
|
|
1141
|
+
<FieldLabel htmlFor="tituloComercial">
|
|
1142
|
+
Titulo Comercial
|
|
1143
|
+
</FieldLabel>
|
|
1144
|
+
<Input
|
|
1145
|
+
id="tituloComercial"
|
|
1146
|
+
placeholder="Ex: React Avancado"
|
|
1147
|
+
{...form.register('tituloComercial')}
|
|
1148
|
+
/>
|
|
1149
|
+
{form.formState.errors.tituloComercial && (
|
|
1150
|
+
<FieldError>
|
|
1151
|
+
{form.formState.errors.tituloComercial.message}
|
|
1152
|
+
</FieldError>
|
|
1153
|
+
)}
|
|
1154
|
+
</Field>
|
|
1155
|
+
|
|
1156
|
+
{/* Descricao */}
|
|
1157
|
+
<Field data-invalid={!!form.formState.errors.descricao}>
|
|
1158
|
+
<FieldLabel htmlFor="descricao">Descricao</FieldLabel>
|
|
1159
|
+
<Textarea
|
|
1160
|
+
id="descricao"
|
|
1161
|
+
placeholder="Descreva o conteudo e objetivos do curso..."
|
|
1162
|
+
rows={4}
|
|
1163
|
+
{...form.register('descricao')}
|
|
1164
|
+
/>
|
|
1165
|
+
{form.formState.errors.descricao && (
|
|
1166
|
+
<FieldError>
|
|
1167
|
+
{form.formState.errors.descricao.message}
|
|
1168
|
+
</FieldError>
|
|
1169
|
+
)}
|
|
1170
|
+
</Field>
|
|
1171
|
+
|
|
1172
|
+
{/* Nivel + Status side by side */}
|
|
1173
|
+
<div className="grid grid-cols-2 gap-4">
|
|
1174
|
+
<Field data-invalid={!!form.formState.errors.nivel}>
|
|
1175
|
+
<FieldLabel>Nivel</FieldLabel>
|
|
1176
|
+
<Controller
|
|
1177
|
+
control={form.control}
|
|
1178
|
+
name="nivel"
|
|
1179
|
+
render={({ field }) => (
|
|
1180
|
+
<Select value={field.value} onValueChange={field.onChange}>
|
|
1181
|
+
<SelectTrigger>
|
|
1182
|
+
<SelectValue placeholder="Selecione" />
|
|
1183
|
+
</SelectTrigger>
|
|
1184
|
+
<SelectContent>
|
|
1185
|
+
{NIVEIS.map((n) => (
|
|
1186
|
+
<SelectItem key={n.value} value={n.value}>
|
|
1187
|
+
{n.label}
|
|
1188
|
+
</SelectItem>
|
|
1189
|
+
))}
|
|
1190
|
+
</SelectContent>
|
|
1191
|
+
</Select>
|
|
1192
|
+
)}
|
|
1193
|
+
/>
|
|
1194
|
+
{form.formState.errors.nivel && (
|
|
1195
|
+
<FieldError>{form.formState.errors.nivel.message}</FieldError>
|
|
1196
|
+
)}
|
|
1197
|
+
</Field>
|
|
1198
|
+
|
|
1199
|
+
<Field data-invalid={!!form.formState.errors.status}>
|
|
1200
|
+
<FieldLabel>Status</FieldLabel>
|
|
1201
|
+
<Controller
|
|
1202
|
+
control={form.control}
|
|
1203
|
+
name="status"
|
|
1204
|
+
render={({ field }) => (
|
|
1205
|
+
<Select value={field.value} onValueChange={field.onChange}>
|
|
1206
|
+
<SelectTrigger>
|
|
1207
|
+
<SelectValue placeholder="Selecione" />
|
|
1208
|
+
</SelectTrigger>
|
|
1209
|
+
<SelectContent>
|
|
1210
|
+
{STATUS_OPTIONS.map((s) => (
|
|
1211
|
+
<SelectItem key={s.value} value={s.value}>
|
|
1212
|
+
{s.label}
|
|
1213
|
+
</SelectItem>
|
|
1214
|
+
))}
|
|
1215
|
+
</SelectContent>
|
|
1216
|
+
</Select>
|
|
1217
|
+
)}
|
|
1218
|
+
/>
|
|
1219
|
+
{form.formState.errors.status && (
|
|
1220
|
+
<FieldError>
|
|
1221
|
+
{form.formState.errors.status.message}
|
|
1222
|
+
</FieldError>
|
|
1223
|
+
)}
|
|
1224
|
+
</Field>
|
|
1225
|
+
</div>
|
|
1226
|
+
|
|
1227
|
+
<Separator />
|
|
1228
|
+
|
|
1229
|
+
{/* Categorias - Multi select via checkboxes */}
|
|
1230
|
+
<Field data-invalid={!!form.formState.errors.categorias}>
|
|
1231
|
+
<FieldLabel>Categorias</FieldLabel>
|
|
1232
|
+
<FieldDescription>
|
|
1233
|
+
Selecione uma ou mais categorias
|
|
1234
|
+
</FieldDescription>
|
|
1235
|
+
<Controller
|
|
1236
|
+
control={form.control}
|
|
1237
|
+
name="categorias"
|
|
1238
|
+
render={({ field }) => (
|
|
1239
|
+
<div className="grid grid-cols-2 gap-2.5">
|
|
1240
|
+
{CATEGORIAS.map((cat) => {
|
|
1241
|
+
const checked = field.value.includes(cat);
|
|
1242
|
+
return (
|
|
1243
|
+
<label
|
|
1244
|
+
key={cat}
|
|
1245
|
+
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"
|
|
1246
|
+
>
|
|
1247
|
+
<Checkbox
|
|
1248
|
+
checked={checked}
|
|
1249
|
+
onCheckedChange={(isChecked) => {
|
|
1250
|
+
if (isChecked) {
|
|
1251
|
+
field.onChange([...field.value, cat]);
|
|
1252
|
+
} else {
|
|
1253
|
+
field.onChange(
|
|
1254
|
+
field.value.filter((v: string) => v !== cat)
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
}}
|
|
1258
|
+
/>
|
|
1259
|
+
{cat}
|
|
1260
|
+
</label>
|
|
1261
|
+
);
|
|
1262
|
+
})}
|
|
1263
|
+
</div>
|
|
1264
|
+
)}
|
|
1265
|
+
/>
|
|
1266
|
+
{form.formState.errors.categorias && (
|
|
1267
|
+
<FieldError>
|
|
1268
|
+
{form.formState.errors.categorias.message}
|
|
1269
|
+
</FieldError>
|
|
1270
|
+
)}
|
|
1271
|
+
</Field>
|
|
1272
|
+
|
|
1273
|
+
<Separator />
|
|
1274
|
+
|
|
1275
|
+
{/* Flags */}
|
|
1276
|
+
<div className="flex flex-col gap-4">
|
|
1277
|
+
<p className="text-sm font-medium">Flags Principais</p>
|
|
1278
|
+
<Controller
|
|
1279
|
+
control={form.control}
|
|
1280
|
+
name="destaque"
|
|
1281
|
+
render={({ field }) => (
|
|
1282
|
+
<div className="flex items-center justify-between rounded-md border px-4 py-3">
|
|
1283
|
+
<div className="flex flex-col gap-0.5">
|
|
1284
|
+
<span className="text-sm font-medium">Destaque</span>
|
|
1285
|
+
<span className="text-xs text-muted-foreground">
|
|
1286
|
+
Exibir como curso em destaque na vitrine
|
|
1287
|
+
</span>
|
|
1288
|
+
</div>
|
|
1289
|
+
<Switch
|
|
1290
|
+
checked={field.value}
|
|
1291
|
+
onCheckedChange={field.onChange}
|
|
1292
|
+
/>
|
|
1293
|
+
</div>
|
|
1294
|
+
)}
|
|
1295
|
+
/>
|
|
1296
|
+
<Controller
|
|
1297
|
+
control={form.control}
|
|
1298
|
+
name="certificado"
|
|
1299
|
+
render={({ field }) => (
|
|
1300
|
+
<div className="flex items-center justify-between rounded-md border px-4 py-3">
|
|
1301
|
+
<div className="flex flex-col gap-0.5">
|
|
1302
|
+
<span className="text-sm font-medium">Certificado</span>
|
|
1303
|
+
<span className="text-xs text-muted-foreground">
|
|
1304
|
+
Emitir certificado ao concluir o curso
|
|
1305
|
+
</span>
|
|
1306
|
+
</div>
|
|
1307
|
+
<Switch
|
|
1308
|
+
checked={field.value}
|
|
1309
|
+
onCheckedChange={field.onChange}
|
|
1310
|
+
/>
|
|
1311
|
+
</div>
|
|
1312
|
+
)}
|
|
1313
|
+
/>
|
|
1314
|
+
<Controller
|
|
1315
|
+
control={form.control}
|
|
1316
|
+
name="listado"
|
|
1317
|
+
render={({ field }) => (
|
|
1318
|
+
<div className="flex items-center justify-between rounded-md border px-4 py-3">
|
|
1319
|
+
<div className="flex flex-col gap-0.5">
|
|
1320
|
+
<span className="text-sm font-medium">Listado</span>
|
|
1321
|
+
<span className="text-xs text-muted-foreground">
|
|
1322
|
+
Visivel no catalogo publico de cursos
|
|
1323
|
+
</span>
|
|
1324
|
+
</div>
|
|
1325
|
+
<Switch
|
|
1326
|
+
checked={field.value}
|
|
1327
|
+
onCheckedChange={field.onChange}
|
|
1328
|
+
/>
|
|
1329
|
+
</div>
|
|
1330
|
+
)}
|
|
1331
|
+
/>
|
|
1332
|
+
</div>
|
|
1333
|
+
|
|
1334
|
+
<SheetFooter className="mt-2 gap-2 pb-6">
|
|
1335
|
+
<Button
|
|
1336
|
+
type="button"
|
|
1337
|
+
variant="outline"
|
|
1338
|
+
onClick={() => setSheetOpen(false)}
|
|
1339
|
+
>
|
|
1340
|
+
Cancelar
|
|
1341
|
+
</Button>
|
|
1342
|
+
<Button type="submit" disabled={saving} className="gap-2">
|
|
1343
|
+
{saving && <Loader2 className="size-4 animate-spin" />}
|
|
1344
|
+
{editingCurso ? 'Salvar Alteracoes' : 'Criar Curso'}
|
|
1345
|
+
</Button>
|
|
1346
|
+
</SheetFooter>
|
|
1347
|
+
</form>
|
|
1348
|
+
</SheetContent>
|
|
1349
|
+
</Sheet>
|
|
1350
|
+
|
|
1351
|
+
{/* ── Dialog: Confirmar Exclusao ─────────────────────────────────────── */}
|
|
1352
|
+
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
|
1353
|
+
<DialogContent>
|
|
1354
|
+
<DialogHeader>
|
|
1355
|
+
<DialogTitle className="flex items-center gap-2">
|
|
1356
|
+
<AlertTriangle className="size-5 text-destructive" />
|
|
1357
|
+
Confirmar Exclusao
|
|
1358
|
+
</DialogTitle>
|
|
1359
|
+
<DialogDescription>
|
|
1360
|
+
Tem certeza que deseja excluir o curso{' '}
|
|
1361
|
+
<strong className="text-foreground">
|
|
1362
|
+
{cursoToDelete?.tituloComercial}
|
|
1363
|
+
</strong>
|
|
1364
|
+
?
|
|
1365
|
+
{cursoToDelete && cursoToDelete.alunosInscritos > 0 && (
|
|
1366
|
+
<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">
|
|
1367
|
+
<AlertTriangle className="size-3.5" />
|
|
1368
|
+
Este curso possui {cursoToDelete.alunosInscritos} aluno(s)
|
|
1369
|
+
matriculado(s). A exclusao e irreversivel.
|
|
1370
|
+
</span>
|
|
1371
|
+
)}
|
|
1372
|
+
</DialogDescription>
|
|
1373
|
+
</DialogHeader>
|
|
1374
|
+
<DialogFooter className="gap-2">
|
|
1375
|
+
<Button
|
|
1376
|
+
variant="outline"
|
|
1377
|
+
onClick={() => setDeleteDialogOpen(false)}
|
|
1378
|
+
>
|
|
1379
|
+
Cancelar
|
|
1380
|
+
</Button>
|
|
1381
|
+
<Button
|
|
1382
|
+
variant="destructive"
|
|
1383
|
+
onClick={confirmDelete}
|
|
1384
|
+
className="gap-2"
|
|
1385
|
+
>
|
|
1386
|
+
<Trash2 className="size-4" />
|
|
1387
|
+
Excluir Curso
|
|
1388
|
+
</Button>
|
|
1389
|
+
</DialogFooter>
|
|
1390
|
+
</DialogContent>
|
|
1391
|
+
</Dialog>
|
|
1392
|
+
</Page>
|
|
1393
|
+
);
|
|
1394
|
+
}
|