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