@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
|
@@ -0,0 +1,1237 @@
|
|
|
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 {
|
|
7
|
+
Card,
|
|
8
|
+
CardContent,
|
|
9
|
+
CardDescription,
|
|
10
|
+
CardHeader,
|
|
11
|
+
CardTitle,
|
|
12
|
+
} from '@/components/ui/card';
|
|
13
|
+
import { Checkbox } from '@/components/ui/checkbox';
|
|
14
|
+
import {
|
|
15
|
+
Dialog,
|
|
16
|
+
DialogContent,
|
|
17
|
+
DialogDescription,
|
|
18
|
+
DialogFooter,
|
|
19
|
+
DialogHeader,
|
|
20
|
+
DialogTitle,
|
|
21
|
+
} from '@/components/ui/dialog';
|
|
22
|
+
import {
|
|
23
|
+
Field,
|
|
24
|
+
FieldDescription,
|
|
25
|
+
FieldError,
|
|
26
|
+
FieldLabel,
|
|
27
|
+
} from '@/components/ui/field';
|
|
28
|
+
import { Input } from '@/components/ui/input';
|
|
29
|
+
import {
|
|
30
|
+
Select,
|
|
31
|
+
SelectContent,
|
|
32
|
+
SelectItem,
|
|
33
|
+
SelectTrigger,
|
|
34
|
+
SelectValue,
|
|
35
|
+
} from '@/components/ui/select';
|
|
36
|
+
import { Separator } from '@/components/ui/separator';
|
|
37
|
+
import { Skeleton } from '@/components/ui/skeleton';
|
|
38
|
+
import { Switch } from '@/components/ui/switch';
|
|
39
|
+
import { Textarea } from '@/components/ui/textarea';
|
|
40
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
41
|
+
import { motion } from 'framer-motion';
|
|
42
|
+
import {
|
|
43
|
+
AlertTriangle,
|
|
44
|
+
Award,
|
|
45
|
+
BarChart3,
|
|
46
|
+
BookOpen,
|
|
47
|
+
CalendarDays,
|
|
48
|
+
FileCheck,
|
|
49
|
+
GraduationCap,
|
|
50
|
+
Hash,
|
|
51
|
+
ImageIcon,
|
|
52
|
+
Layers,
|
|
53
|
+
LayoutDashboard,
|
|
54
|
+
Loader2,
|
|
55
|
+
Percent,
|
|
56
|
+
Save,
|
|
57
|
+
Trash2,
|
|
58
|
+
TrendingUp,
|
|
59
|
+
Upload,
|
|
60
|
+
UserCheck,
|
|
61
|
+
Users,
|
|
62
|
+
Video,
|
|
63
|
+
XCircle,
|
|
64
|
+
} from 'lucide-react';
|
|
65
|
+
import { useTranslations } from 'next-intl';
|
|
66
|
+
import { usePathname, useRouter } from 'next/navigation';
|
|
67
|
+
import { use, useEffect, useRef, useState } from 'react';
|
|
68
|
+
import { Controller, useForm } from 'react-hook-form';
|
|
69
|
+
import {
|
|
70
|
+
Bar,
|
|
71
|
+
BarChart,
|
|
72
|
+
CartesianGrid,
|
|
73
|
+
ResponsiveContainer,
|
|
74
|
+
Tooltip,
|
|
75
|
+
XAxis,
|
|
76
|
+
YAxis,
|
|
77
|
+
} from 'recharts';
|
|
78
|
+
import { toast } from 'sonner';
|
|
79
|
+
import { z } from 'zod';
|
|
80
|
+
|
|
81
|
+
// ── Navigation ──────────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
const NAV_ITEMS = [
|
|
84
|
+
{ label: 'Dashboard', href: '/', icon: LayoutDashboard },
|
|
85
|
+
{ label: 'Cursos', href: '/cursos', icon: BookOpen },
|
|
86
|
+
{ label: 'Turmas', href: '/turmas', icon: Users },
|
|
87
|
+
{ label: 'Exames', href: '/exames', icon: FileCheck },
|
|
88
|
+
{ label: 'Formacoes', href: '/formacoes', icon: GraduationCap },
|
|
89
|
+
{ label: 'Relatorios', href: '/relatorios', icon: BarChart3 },
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
// ── Schema ──────────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
function getCursoEditSchema(t: (key: string) => string) {
|
|
95
|
+
return z.object({
|
|
96
|
+
codigo: z
|
|
97
|
+
.string()
|
|
98
|
+
.min(2, t('validation.codeMin'))
|
|
99
|
+
.max(16, t('validation.codeMax'))
|
|
100
|
+
.regex(/^[A-Z0-9-]+$/i, t('validation.codeFormat')),
|
|
101
|
+
nomeInterno: z.string().min(3, t('validation.internalNameMin')),
|
|
102
|
+
tituloComercial: z.string().min(3, t('validation.titleMin')),
|
|
103
|
+
descricaoPublica: z.string().min(10, t('validation.descriptionMin')),
|
|
104
|
+
nivel: z.enum(['iniciante', 'intermediario', 'avancado'], {
|
|
105
|
+
errorMap: () => ({ message: t('validation.levelRequired') }),
|
|
106
|
+
}),
|
|
107
|
+
status: z.enum(['ativo', 'rascunho', 'arquivado'], {
|
|
108
|
+
errorMap: () => ({ message: t('validation.statusRequired') }),
|
|
109
|
+
}),
|
|
110
|
+
categorias: z.array(z.string()).min(1, t('validation.categoryRequired')),
|
|
111
|
+
instrutores: z.array(z.string()),
|
|
112
|
+
preRequisitos: z.string(),
|
|
113
|
+
modeloCertificado: z.string(),
|
|
114
|
+
destaque: z.boolean(),
|
|
115
|
+
certificado: z.boolean(),
|
|
116
|
+
listado: z.boolean(),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
type CursoEditForm = z.infer<ReturnType<typeof getCursoEditSchema>>;
|
|
121
|
+
|
|
122
|
+
// ── Constants ───────────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
function getCategorias(t: (key: string) => string) {
|
|
125
|
+
return [
|
|
126
|
+
t('categories.technology'),
|
|
127
|
+
t('categories.design'),
|
|
128
|
+
t('categories.management'),
|
|
129
|
+
t('categories.marketing'),
|
|
130
|
+
t('categories.finance'),
|
|
131
|
+
t('categories.health'),
|
|
132
|
+
t('categories.languages'),
|
|
133
|
+
t('categories.law'),
|
|
134
|
+
];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function getNiveis(t: (key: string) => string) {
|
|
138
|
+
return [
|
|
139
|
+
{ value: 'iniciante', label: t('levels.beginner') },
|
|
140
|
+
{ value: 'intermediario', label: t('levels.intermediate') },
|
|
141
|
+
{ value: 'avancado', label: t('levels.advanced') },
|
|
142
|
+
];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function getStatusOptions(t: (key: string) => string) {
|
|
146
|
+
return [
|
|
147
|
+
{ value: 'ativo', label: t('status.active') },
|
|
148
|
+
{ value: 'rascunho', label: t('status.draft') },
|
|
149
|
+
{ value: 'arquivado', label: t('status.archived') },
|
|
150
|
+
];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function getStatusMap(
|
|
154
|
+
t: (key: string) => string
|
|
155
|
+
): Record<
|
|
156
|
+
string,
|
|
157
|
+
{ label: string; variant: 'default' | 'secondary' | 'outline' }
|
|
158
|
+
> {
|
|
159
|
+
return {
|
|
160
|
+
ativo: { label: t('status.active'), variant: 'default' },
|
|
161
|
+
rascunho: { label: t('status.draft'), variant: 'secondary' },
|
|
162
|
+
arquivado: { label: t('status.archived'), variant: 'outline' },
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const INSTRUTORES = [
|
|
167
|
+
{ id: 'inst-1', nome: 'Ana Paula Mendes' },
|
|
168
|
+
{ id: 'inst-2', nome: 'Carlos Ferreira' },
|
|
169
|
+
{ id: 'inst-3', nome: 'Juliana Santos' },
|
|
170
|
+
{ id: 'inst-4', nome: 'Roberto Lima' },
|
|
171
|
+
{ id: 'inst-5', nome: 'Mariana Costa' },
|
|
172
|
+
{ id: 'inst-6', nome: 'Pedro Almeida' },
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
function getModelosCertificado(t: (key: string) => string) {
|
|
176
|
+
return [
|
|
177
|
+
{ value: 'padrao', label: t('certificateModels.standard') },
|
|
178
|
+
{ value: 'premium', label: t('certificateModels.premium') },
|
|
179
|
+
{ value: 'minimalista', label: t('certificateModels.minimalist') },
|
|
180
|
+
{ value: 'tecnologia', label: t('certificateModels.technology') },
|
|
181
|
+
{ value: 'corporativo', label: t('certificateModels.corporate') },
|
|
182
|
+
];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── Mock Data ───────────────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
const MOCK_CURSO = {
|
|
188
|
+
id: 1,
|
|
189
|
+
codigo: 'REACT-ADV',
|
|
190
|
+
nomeInterno: 'react-avancado',
|
|
191
|
+
tituloComercial: 'React Avancado',
|
|
192
|
+
descricaoPublica:
|
|
193
|
+
'Curso completo de React com hooks, context e patterns avancados para aplicacoes modernas. Aprenda a construir interfaces performaticas e escalaveis com as melhores praticas do mercado.',
|
|
194
|
+
nivel: 'avancado' as const,
|
|
195
|
+
status: 'ativo' as const,
|
|
196
|
+
categorias: ['Tecnologia'],
|
|
197
|
+
instrutores: ['inst-1', 'inst-3'],
|
|
198
|
+
preRequisitos: 'JavaScript ES6+, HTML/CSS basico, React Fundamentals',
|
|
199
|
+
modeloCertificado: 'premium',
|
|
200
|
+
destaque: true,
|
|
201
|
+
certificado: true,
|
|
202
|
+
listado: true,
|
|
203
|
+
// KPIs
|
|
204
|
+
totalAlunos: 245,
|
|
205
|
+
conclusaoMedia: 78,
|
|
206
|
+
totalAulas: 48,
|
|
207
|
+
totalSessoes: 96,
|
|
208
|
+
certificadosEmitidos: 191,
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const progressData = [
|
|
212
|
+
{ modulo: 'Mod 1', progresso: 95 },
|
|
213
|
+
{ modulo: 'Mod 2', progresso: 88 },
|
|
214
|
+
{ modulo: 'Mod 3', progresso: 82 },
|
|
215
|
+
{ modulo: 'Mod 4', progresso: 74 },
|
|
216
|
+
{ modulo: 'Mod 5', progresso: 68 },
|
|
217
|
+
{ modulo: 'Mod 6', progresso: 55 },
|
|
218
|
+
{ modulo: 'Mod 7', progresso: 42 },
|
|
219
|
+
{ modulo: 'Mod 8', progresso: 31 },
|
|
220
|
+
];
|
|
221
|
+
|
|
222
|
+
// ── Animations ──────────────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
const stagger = {
|
|
225
|
+
hidden: {},
|
|
226
|
+
show: { transition: { staggerChildren: 0.06 } },
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const fadeUp = {
|
|
230
|
+
hidden: { opacity: 0, y: 16 },
|
|
231
|
+
show: { opacity: 1, y: 0, transition: { duration: 0.35 } },
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// ── Component ───────────────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
export default function CursoEditPage({
|
|
237
|
+
params,
|
|
238
|
+
}: {
|
|
239
|
+
params: Promise<{ id: string }>;
|
|
240
|
+
}) {
|
|
241
|
+
const { id } = use(params);
|
|
242
|
+
const pathname = usePathname();
|
|
243
|
+
const router = useRouter();
|
|
244
|
+
const t = useTranslations('lms.CursoEditPage');
|
|
245
|
+
|
|
246
|
+
const CATEGORIAS = getCategorias(t);
|
|
247
|
+
const NIVEIS = getNiveis(t);
|
|
248
|
+
const STATUS_OPTIONS = getStatusOptions(t);
|
|
249
|
+
const STATUS_MAP = getStatusMap(t);
|
|
250
|
+
const MODELOS_CERTIFICADO = getModelosCertificado(t);
|
|
251
|
+
|
|
252
|
+
const [loading, setLoading] = useState(true);
|
|
253
|
+
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
|
254
|
+
const [saving, setSaving] = useState(false);
|
|
255
|
+
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
256
|
+
const [deleting, setDeleting] = useState(false);
|
|
257
|
+
|
|
258
|
+
// File uploads (client-side preview only)
|
|
259
|
+
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
|
260
|
+
const [bannerPreview, setBannerPreview] = useState<string | null>(null);
|
|
261
|
+
const logoInputRef = useRef<HTMLInputElement>(null);
|
|
262
|
+
const bannerInputRef = useRef<HTMLInputElement>(null);
|
|
263
|
+
|
|
264
|
+
const form = useForm<CursoEditForm>({
|
|
265
|
+
resolver: zodResolver(getCursoEditSchema(t)),
|
|
266
|
+
defaultValues: {
|
|
267
|
+
codigo: '',
|
|
268
|
+
nomeInterno: '',
|
|
269
|
+
tituloComercial: '',
|
|
270
|
+
descricaoPublica: '',
|
|
271
|
+
nivel: 'iniciante',
|
|
272
|
+
status: 'rascunho',
|
|
273
|
+
categorias: [],
|
|
274
|
+
instrutores: [],
|
|
275
|
+
preRequisitos: '',
|
|
276
|
+
modeloCertificado: '',
|
|
277
|
+
destaque: false,
|
|
278
|
+
certificado: true,
|
|
279
|
+
listado: false,
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// Simulate loading + populate form
|
|
284
|
+
useEffect(() => {
|
|
285
|
+
const t = setTimeout(() => {
|
|
286
|
+
form.reset({
|
|
287
|
+
codigo: MOCK_CURSO.codigo,
|
|
288
|
+
nomeInterno: MOCK_CURSO.nomeInterno,
|
|
289
|
+
tituloComercial: MOCK_CURSO.tituloComercial,
|
|
290
|
+
descricaoPublica: MOCK_CURSO.descricaoPublica,
|
|
291
|
+
nivel: MOCK_CURSO.nivel,
|
|
292
|
+
status: MOCK_CURSO.status,
|
|
293
|
+
categorias: MOCK_CURSO.categorias,
|
|
294
|
+
instrutores: MOCK_CURSO.instrutores,
|
|
295
|
+
preRequisitos: MOCK_CURSO.preRequisitos,
|
|
296
|
+
modeloCertificado: MOCK_CURSO.modeloCertificado,
|
|
297
|
+
destaque: MOCK_CURSO.destaque,
|
|
298
|
+
certificado: MOCK_CURSO.certificado,
|
|
299
|
+
listado: MOCK_CURSO.listado,
|
|
300
|
+
});
|
|
301
|
+
setLoading(false);
|
|
302
|
+
}, 800);
|
|
303
|
+
return () => clearTimeout(t);
|
|
304
|
+
}, [form]);
|
|
305
|
+
|
|
306
|
+
// ── File upload handlers ─────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
function handleFileSelect(
|
|
309
|
+
e: React.ChangeEvent<HTMLInputElement>,
|
|
310
|
+
setter: (url: string | null) => void
|
|
311
|
+
) {
|
|
312
|
+
const file = e.target.files?.[0];
|
|
313
|
+
if (!file) return;
|
|
314
|
+
if (!file.type.startsWith('image/')) {
|
|
315
|
+
toast.error(t('toasts.onlyImages'));
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (file.size > 5 * 1024 * 1024) {
|
|
319
|
+
toast.error(t('toasts.maxSize'));
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const url = URL.createObjectURL(file);
|
|
323
|
+
setter(url);
|
|
324
|
+
toast.success(t('toasts.fileSelected', { name: file.name }));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ── Form submit ──────────────────────────────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
async function onSubmit(data: CursoEditForm) {
|
|
330
|
+
setSaving(true);
|
|
331
|
+
await new Promise((r) => setTimeout(r, 800));
|
|
332
|
+
setSaving(false);
|
|
333
|
+
toast.success(t('toasts.courseUpdated'));
|
|
334
|
+
console.log('Dados salvos:', data);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ── Delete ───────────────────────────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
async function handleDelete() {
|
|
340
|
+
setDeleting(true);
|
|
341
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
342
|
+
setDeleting(false);
|
|
343
|
+
setDeleteDialogOpen(false);
|
|
344
|
+
toast.success(t('toasts.courseDeleted'));
|
|
345
|
+
router.push('/cursos');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ── Render ───────────────────────────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
return (
|
|
351
|
+
<Page>
|
|
352
|
+
<PageHeader
|
|
353
|
+
title={` ${MOCK_CURSO.tituloComercial}`}
|
|
354
|
+
description={t('pageHeader.description', {
|
|
355
|
+
id: id,
|
|
356
|
+
creator: 'Ana Paula Mendes',
|
|
357
|
+
})}
|
|
358
|
+
breadcrumbs={[
|
|
359
|
+
{
|
|
360
|
+
label: t('breadcrumbs.home'),
|
|
361
|
+
href: '/',
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
label: t('breadcrumbs.courses'),
|
|
365
|
+
href: '/courses',
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
label: t('breadcrumbs.editCourse'),
|
|
369
|
+
},
|
|
370
|
+
]}
|
|
371
|
+
actions={
|
|
372
|
+
<div className="flex items-center gap-2">
|
|
373
|
+
<Button
|
|
374
|
+
variant="outline"
|
|
375
|
+
className="gap-2 shadow-none"
|
|
376
|
+
onClick={() => router.push('/lms/classes')}
|
|
377
|
+
>
|
|
378
|
+
<Users className="size-4" />
|
|
379
|
+
{t('actions.manageClasses')}
|
|
380
|
+
</Button>
|
|
381
|
+
<Button
|
|
382
|
+
variant="outline"
|
|
383
|
+
className="gap-2 shadow-none"
|
|
384
|
+
onClick={() => router.push('/lms/reports')}
|
|
385
|
+
>
|
|
386
|
+
<BarChart3 className="size-4" />
|
|
387
|
+
{t('actions.viewReports')}
|
|
388
|
+
</Button>
|
|
389
|
+
<Button
|
|
390
|
+
className="gap-2"
|
|
391
|
+
onClick={() => router.push(`/lms/courses/${id}/structure`)}
|
|
392
|
+
>
|
|
393
|
+
<Layers className="size-4" />
|
|
394
|
+
{t('actions.goToStructure')}
|
|
395
|
+
</Button>
|
|
396
|
+
</div>
|
|
397
|
+
}
|
|
398
|
+
/>
|
|
399
|
+
|
|
400
|
+
{/* ── Main ───────────────────────────────────────────────────────────── */}
|
|
401
|
+
<div>
|
|
402
|
+
{loading ? (
|
|
403
|
+
<LoadingSkeleton />
|
|
404
|
+
) : (
|
|
405
|
+
<motion.div initial="hidden" animate="show" variants={stagger}>
|
|
406
|
+
{/* Breadcrumb */}
|
|
407
|
+
{/*<motion.div variants={fadeUp} className="mb-6">
|
|
408
|
+
<Link
|
|
409
|
+
href="/cursos"
|
|
410
|
+
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
|
411
|
+
>
|
|
412
|
+
<ArrowLeft className="size-4" />
|
|
413
|
+
Voltar para Cursos
|
|
414
|
+
</Link>
|
|
415
|
+
</motion.div>*/}
|
|
416
|
+
|
|
417
|
+
{/* Page Header */}
|
|
418
|
+
<motion.div
|
|
419
|
+
variants={fadeUp}
|
|
420
|
+
className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
|
421
|
+
>
|
|
422
|
+
<div className="flex-1">
|
|
423
|
+
<div className="mb-2 flex flex-wrap items-center gap-2">
|
|
424
|
+
<Badge variant="outline" className="font-mono text-xs">
|
|
425
|
+
{MOCK_CURSO.codigo}
|
|
426
|
+
</Badge>
|
|
427
|
+
<Badge
|
|
428
|
+
variant={
|
|
429
|
+
STATUS_MAP[MOCK_CURSO.status]?.variant || 'default'
|
|
430
|
+
}
|
|
431
|
+
>
|
|
432
|
+
{STATUS_MAP[MOCK_CURSO.status]?.label || MOCK_CURSO.status}
|
|
433
|
+
</Badge>
|
|
434
|
+
{MOCK_CURSO.destaque && (
|
|
435
|
+
<Badge
|
|
436
|
+
variant="secondary"
|
|
437
|
+
className="border-amber-200 bg-amber-50 text-amber-700"
|
|
438
|
+
>
|
|
439
|
+
{t('badges.featured')}
|
|
440
|
+
</Badge>
|
|
441
|
+
)}
|
|
442
|
+
</div>
|
|
443
|
+
</div>
|
|
444
|
+
</motion.div>
|
|
445
|
+
|
|
446
|
+
{/* ── KPI Cards ──────────────────────────────────────────────────── */}
|
|
447
|
+
<motion.div
|
|
448
|
+
variants={fadeUp}
|
|
449
|
+
className="mb-8 grid grid-cols-2 gap-4 lg:grid-cols-5"
|
|
450
|
+
>
|
|
451
|
+
{[
|
|
452
|
+
{
|
|
453
|
+
label: 'Total Alunos',
|
|
454
|
+
value: MOCK_CURSO.totalAlunos.toLocaleString('pt-BR'),
|
|
455
|
+
icon: Users,
|
|
456
|
+
color: 'bg-foreground text-background',
|
|
457
|
+
},
|
|
458
|
+
{
|
|
459
|
+
label: 'Conclusao Media',
|
|
460
|
+
value: `${MOCK_CURSO.conclusaoMedia}%`,
|
|
461
|
+
icon: Percent,
|
|
462
|
+
color: 'bg-emerald-100 text-emerald-700',
|
|
463
|
+
},
|
|
464
|
+
{
|
|
465
|
+
label: 'Total de Aulas',
|
|
466
|
+
value: MOCK_CURSO.totalAulas.toString(),
|
|
467
|
+
icon: Video,
|
|
468
|
+
color: 'bg-blue-100 text-blue-700',
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
label: 'Sessoes',
|
|
472
|
+
value: MOCK_CURSO.totalSessoes.toString(),
|
|
473
|
+
icon: CalendarDays,
|
|
474
|
+
color: 'bg-violet-100 text-violet-700',
|
|
475
|
+
},
|
|
476
|
+
{
|
|
477
|
+
label: 'Certificados',
|
|
478
|
+
value:
|
|
479
|
+
MOCK_CURSO.certificadosEmitidos.toLocaleString('pt-BR'),
|
|
480
|
+
icon: Award,
|
|
481
|
+
color: 'bg-amber-100 text-amber-700',
|
|
482
|
+
},
|
|
483
|
+
].map((kpi) => (
|
|
484
|
+
<motion.div
|
|
485
|
+
key={kpi.label}
|
|
486
|
+
whileHover={{ y: -2 }}
|
|
487
|
+
transition={{ duration: 0.2 }}
|
|
488
|
+
>
|
|
489
|
+
<Card className="transition-shadow hover:shadow-md">
|
|
490
|
+
<CardContent className="flex items-center gap-3 p-4">
|
|
491
|
+
<div
|
|
492
|
+
className={`flex size-10 shrink-0 items-center justify-center rounded-lg ${kpi.color}`}
|
|
493
|
+
>
|
|
494
|
+
<kpi.icon className="size-5" />
|
|
495
|
+
</div>
|
|
496
|
+
<div className="min-w-0">
|
|
497
|
+
<p className="truncate text-xs text-muted-foreground">
|
|
498
|
+
{kpi.label}
|
|
499
|
+
</p>
|
|
500
|
+
<p className="text-xl font-bold tabular-nums">
|
|
501
|
+
{kpi.value}
|
|
502
|
+
</p>
|
|
503
|
+
</div>
|
|
504
|
+
</CardContent>
|
|
505
|
+
</Card>
|
|
506
|
+
</motion.div>
|
|
507
|
+
))}
|
|
508
|
+
</motion.div>
|
|
509
|
+
|
|
510
|
+
{/* ── Progress Chart ──────────────────────────────────────────────── */}
|
|
511
|
+
<motion.div variants={fadeUp} className="mb-8">
|
|
512
|
+
<Card>
|
|
513
|
+
<CardHeader>
|
|
514
|
+
<div className="flex items-center gap-2">
|
|
515
|
+
<TrendingUp className="size-4 text-muted-foreground" />
|
|
516
|
+
<CardTitle className="text-sm font-semibold">
|
|
517
|
+
{t('chart.title')}
|
|
518
|
+
</CardTitle>
|
|
519
|
+
</div>
|
|
520
|
+
<CardDescription>{t('chart.description')}</CardDescription>
|
|
521
|
+
</CardHeader>
|
|
522
|
+
<CardContent>
|
|
523
|
+
<div className="h-64">
|
|
524
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
525
|
+
<BarChart
|
|
526
|
+
data={progressData}
|
|
527
|
+
layout="vertical"
|
|
528
|
+
margin={{ top: 0, right: 20, left: 0, bottom: 0 }}
|
|
529
|
+
>
|
|
530
|
+
<CartesianGrid
|
|
531
|
+
strokeDasharray="3 3"
|
|
532
|
+
horizontal={false}
|
|
533
|
+
stroke="oklch(0.922 0 0)"
|
|
534
|
+
/>
|
|
535
|
+
<XAxis
|
|
536
|
+
type="number"
|
|
537
|
+
domain={[0, 100]}
|
|
538
|
+
tick={{ fontSize: 12, fill: 'oklch(0.556 0 0)' }}
|
|
539
|
+
tickFormatter={(v) => `${v}%`}
|
|
540
|
+
/>
|
|
541
|
+
<YAxis
|
|
542
|
+
dataKey="modulo"
|
|
543
|
+
type="category"
|
|
544
|
+
tick={{ fontSize: 12, fill: 'oklch(0.556 0 0)' }}
|
|
545
|
+
width={50}
|
|
546
|
+
/>
|
|
547
|
+
<Tooltip
|
|
548
|
+
formatter={(value: number) => [
|
|
549
|
+
`${value}%`,
|
|
550
|
+
t('chart.progress'),
|
|
551
|
+
]}
|
|
552
|
+
contentStyle={{
|
|
553
|
+
borderRadius: 8,
|
|
554
|
+
border: '1px solid oklch(0.922 0 0)',
|
|
555
|
+
boxShadow: '0 4px 12px rgba(0,0,0,0.08)',
|
|
556
|
+
fontSize: 12,
|
|
557
|
+
}}
|
|
558
|
+
/>
|
|
559
|
+
<defs>
|
|
560
|
+
<linearGradient
|
|
561
|
+
id="colorProgresso"
|
|
562
|
+
x1="0"
|
|
563
|
+
y1="0"
|
|
564
|
+
x2="1"
|
|
565
|
+
y2="0"
|
|
566
|
+
>
|
|
567
|
+
<stop offset="0%" stopColor="#3b82f6" />
|
|
568
|
+
<stop offset="100%" stopColor="#60a5fa" />
|
|
569
|
+
</linearGradient>
|
|
570
|
+
</defs>
|
|
571
|
+
<Bar
|
|
572
|
+
dataKey="progresso"
|
|
573
|
+
fill="url(#colorProgresso)"
|
|
574
|
+
radius={[0, 6, 6, 0]}
|
|
575
|
+
maxBarSize={32}
|
|
576
|
+
/>
|
|
577
|
+
</BarChart>
|
|
578
|
+
</ResponsiveContainer>
|
|
579
|
+
</div>
|
|
580
|
+
</CardContent>
|
|
581
|
+
</Card>
|
|
582
|
+
</motion.div>
|
|
583
|
+
|
|
584
|
+
{/* ── Form ───────────────────────────────────────────────────────── */}
|
|
585
|
+
<motion.div variants={fadeUp}>
|
|
586
|
+
<Card>
|
|
587
|
+
<CardHeader>
|
|
588
|
+
<CardTitle className="text-base font-semibold">
|
|
589
|
+
{t('form.title')}
|
|
590
|
+
</CardTitle>
|
|
591
|
+
<CardDescription>{t('form.description')}</CardDescription>
|
|
592
|
+
</CardHeader>
|
|
593
|
+
<CardContent>
|
|
594
|
+
<form
|
|
595
|
+
onSubmit={form.handleSubmit(onSubmit)}
|
|
596
|
+
className="flex flex-col gap-6"
|
|
597
|
+
>
|
|
598
|
+
{/* ── Row: Codigo + Nome Interno ────────────────────────── */}
|
|
599
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
600
|
+
<Field data-invalid={!!form.formState.errors.codigo}>
|
|
601
|
+
<FieldLabel htmlFor="codigo">
|
|
602
|
+
<Hash className="size-3.5 text-muted-foreground" />
|
|
603
|
+
{t('form.fields.code.label')}
|
|
604
|
+
</FieldLabel>
|
|
605
|
+
<Input
|
|
606
|
+
id="codigo"
|
|
607
|
+
placeholder={t('form.fields.code.placeholder')}
|
|
608
|
+
className="uppercase"
|
|
609
|
+
{...form.register('codigo')}
|
|
610
|
+
/>
|
|
611
|
+
{form.formState.errors.codigo && (
|
|
612
|
+
<FieldError>
|
|
613
|
+
{form.formState.errors.codigo.message}
|
|
614
|
+
</FieldError>
|
|
615
|
+
)}
|
|
616
|
+
<FieldDescription>
|
|
617
|
+
{t('form.fields.code.description')}
|
|
618
|
+
</FieldDescription>
|
|
619
|
+
</Field>
|
|
620
|
+
|
|
621
|
+
<Field data-invalid={!!form.formState.errors.nomeInterno}>
|
|
622
|
+
<FieldLabel htmlFor="nomeInterno">
|
|
623
|
+
{t('form.fields.internalName.label')}
|
|
624
|
+
</FieldLabel>
|
|
625
|
+
<Input
|
|
626
|
+
id="nomeInterno"
|
|
627
|
+
placeholder={t(
|
|
628
|
+
'form.fields.internalName.placeholder'
|
|
629
|
+
)}
|
|
630
|
+
{...form.register('nomeInterno')}
|
|
631
|
+
/>
|
|
632
|
+
{form.formState.errors.nomeInterno && (
|
|
633
|
+
<FieldError>
|
|
634
|
+
{form.formState.errors.nomeInterno.message}
|
|
635
|
+
</FieldError>
|
|
636
|
+
)}
|
|
637
|
+
<FieldDescription>
|
|
638
|
+
{t('form.fields.internalName.description')}
|
|
639
|
+
</FieldDescription>
|
|
640
|
+
</Field>
|
|
641
|
+
</div>
|
|
642
|
+
|
|
643
|
+
{/* ── Titulo Comercial ──────────────────────────────────── */}
|
|
644
|
+
<Field
|
|
645
|
+
data-invalid={!!form.formState.errors.tituloComercial}
|
|
646
|
+
>
|
|
647
|
+
<FieldLabel htmlFor="tituloComercial">
|
|
648
|
+
{t('form.fields.title.label')}
|
|
649
|
+
</FieldLabel>
|
|
650
|
+
<Input
|
|
651
|
+
id="tituloComercial"
|
|
652
|
+
placeholder={t('form.fields.title.placeholder')}
|
|
653
|
+
{...form.register('tituloComercial')}
|
|
654
|
+
/>
|
|
655
|
+
{form.formState.errors.tituloComercial && (
|
|
656
|
+
<FieldError>
|
|
657
|
+
{form.formState.errors.tituloComercial.message}
|
|
658
|
+
</FieldError>
|
|
659
|
+
)}
|
|
660
|
+
</Field>
|
|
661
|
+
|
|
662
|
+
{/* ── Descricao Publica ─────────────────────────────────── */}
|
|
663
|
+
<Field
|
|
664
|
+
data-invalid={!!form.formState.errors.descricaoPublica}
|
|
665
|
+
>
|
|
666
|
+
<FieldLabel htmlFor="descricaoPublica">
|
|
667
|
+
{t('form.fields.description.label')}
|
|
668
|
+
</FieldLabel>
|
|
669
|
+
<Textarea
|
|
670
|
+
id="descricaoPublica"
|
|
671
|
+
placeholder={t('form.fields.description.placeholder')}
|
|
672
|
+
rows={4}
|
|
673
|
+
{...form.register('descricaoPublica')}
|
|
674
|
+
/>
|
|
675
|
+
{form.formState.errors.descricaoPublica && (
|
|
676
|
+
<FieldError>
|
|
677
|
+
{form.formState.errors.descricaoPublica.message}
|
|
678
|
+
</FieldError>
|
|
679
|
+
)}
|
|
680
|
+
<FieldDescription>
|
|
681
|
+
{t('form.fields.description.description')}
|
|
682
|
+
</FieldDescription>
|
|
683
|
+
</Field>
|
|
684
|
+
|
|
685
|
+
{/* ── Row: Nivel + Status ───────────────────────────────── */}
|
|
686
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
687
|
+
<Field data-invalid={!!form.formState.errors.nivel}>
|
|
688
|
+
<FieldLabel>{t('form.fields.level.label')}</FieldLabel>
|
|
689
|
+
<Controller
|
|
690
|
+
control={form.control}
|
|
691
|
+
name="nivel"
|
|
692
|
+
render={({ field }) => (
|
|
693
|
+
<Select
|
|
694
|
+
value={field.value}
|
|
695
|
+
onValueChange={field.onChange}
|
|
696
|
+
>
|
|
697
|
+
<SelectTrigger>
|
|
698
|
+
<SelectValue
|
|
699
|
+
placeholder={t(
|
|
700
|
+
'form.fields.level.placeholder'
|
|
701
|
+
)}
|
|
702
|
+
/>
|
|
703
|
+
</SelectTrigger>
|
|
704
|
+
<SelectContent>
|
|
705
|
+
{NIVEIS.map((n) => (
|
|
706
|
+
<SelectItem key={n.value} value={n.value}>
|
|
707
|
+
{n.label}
|
|
708
|
+
</SelectItem>
|
|
709
|
+
))}
|
|
710
|
+
</SelectContent>
|
|
711
|
+
</Select>
|
|
712
|
+
)}
|
|
713
|
+
/>
|
|
714
|
+
{form.formState.errors.nivel && (
|
|
715
|
+
<FieldError>
|
|
716
|
+
{form.formState.errors.nivel.message}
|
|
717
|
+
</FieldError>
|
|
718
|
+
)}
|
|
719
|
+
</Field>
|
|
720
|
+
|
|
721
|
+
<Field data-invalid={!!form.formState.errors.status}>
|
|
722
|
+
<FieldLabel>{t('form.fields.status.label')}</FieldLabel>
|
|
723
|
+
<Controller
|
|
724
|
+
control={form.control}
|
|
725
|
+
name="status"
|
|
726
|
+
render={({ field }) => (
|
|
727
|
+
<Select
|
|
728
|
+
value={field.value}
|
|
729
|
+
onValueChange={field.onChange}
|
|
730
|
+
>
|
|
731
|
+
<SelectTrigger>
|
|
732
|
+
<SelectValue
|
|
733
|
+
placeholder={t(
|
|
734
|
+
'form.fields.status.placeholder'
|
|
735
|
+
)}
|
|
736
|
+
/>
|
|
737
|
+
</SelectTrigger>
|
|
738
|
+
<SelectContent>
|
|
739
|
+
{STATUS_OPTIONS.map((s) => (
|
|
740
|
+
<SelectItem key={s.value} value={s.value}>
|
|
741
|
+
{s.label}
|
|
742
|
+
</SelectItem>
|
|
743
|
+
))}
|
|
744
|
+
</SelectContent>
|
|
745
|
+
</Select>
|
|
746
|
+
)}
|
|
747
|
+
/>
|
|
748
|
+
{form.formState.errors.status && (
|
|
749
|
+
<FieldError>
|
|
750
|
+
{form.formState.errors.status.message}
|
|
751
|
+
</FieldError>
|
|
752
|
+
)}
|
|
753
|
+
</Field>
|
|
754
|
+
</div>
|
|
755
|
+
|
|
756
|
+
<Separator />
|
|
757
|
+
|
|
758
|
+
{/* ── Categorias ────────────────────────────────────────── */}
|
|
759
|
+
<Field data-invalid={!!form.formState.errors.categorias}>
|
|
760
|
+
<FieldLabel>
|
|
761
|
+
{t('form.fields.categories.label')}
|
|
762
|
+
</FieldLabel>
|
|
763
|
+
<FieldDescription>
|
|
764
|
+
{t('form.fields.categories.description')}
|
|
765
|
+
</FieldDescription>
|
|
766
|
+
<Controller
|
|
767
|
+
control={form.control}
|
|
768
|
+
name="categorias"
|
|
769
|
+
render={({ field }) => (
|
|
770
|
+
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-4">
|
|
771
|
+
{CATEGORIAS.map((cat) => {
|
|
772
|
+
const checked = field.value.includes(cat);
|
|
773
|
+
return (
|
|
774
|
+
<label
|
|
775
|
+
key={cat}
|
|
776
|
+
className="flex cursor-pointer items-center gap-2.5 rounded-md border px-3 py-2.5 text-sm transition-colors hover:bg-muted/50 has-[:checked]:border-foreground has-[:checked]:bg-muted/50"
|
|
777
|
+
>
|
|
778
|
+
<Checkbox
|
|
779
|
+
checked={checked}
|
|
780
|
+
onCheckedChange={(isChecked) => {
|
|
781
|
+
if (isChecked) {
|
|
782
|
+
field.onChange([...field.value, cat]);
|
|
783
|
+
} else {
|
|
784
|
+
field.onChange(
|
|
785
|
+
field.value.filter(
|
|
786
|
+
(v: string) => v !== cat
|
|
787
|
+
)
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
}}
|
|
791
|
+
/>
|
|
792
|
+
{cat}
|
|
793
|
+
</label>
|
|
794
|
+
);
|
|
795
|
+
})}
|
|
796
|
+
</div>
|
|
797
|
+
)}
|
|
798
|
+
/>
|
|
799
|
+
{form.formState.errors.categorias && (
|
|
800
|
+
<FieldError>
|
|
801
|
+
{form.formState.errors.categorias.message}
|
|
802
|
+
</FieldError>
|
|
803
|
+
)}
|
|
804
|
+
</Field>
|
|
805
|
+
|
|
806
|
+
<Separator />
|
|
807
|
+
|
|
808
|
+
{/* ── Instrutores ───────────────────────────────────────── */}
|
|
809
|
+
<Field>
|
|
810
|
+
<FieldLabel>
|
|
811
|
+
{t('form.fields.instructors.label')}
|
|
812
|
+
</FieldLabel>
|
|
813
|
+
<FieldDescription>
|
|
814
|
+
{t('form.fields.instructors.description')}
|
|
815
|
+
</FieldDescription>
|
|
816
|
+
<Controller
|
|
817
|
+
control={form.control}
|
|
818
|
+
name="instrutores"
|
|
819
|
+
render={({ field }) => (
|
|
820
|
+
<div className="flex flex-col gap-2">
|
|
821
|
+
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
|
822
|
+
{INSTRUTORES.map((inst) => {
|
|
823
|
+
const checked = field.value.includes(inst.id);
|
|
824
|
+
return (
|
|
825
|
+
<label
|
|
826
|
+
key={inst.id}
|
|
827
|
+
className="flex cursor-pointer items-center gap-2.5 rounded-md border px-3 py-2.5 text-sm transition-colors hover:bg-muted/50 has-[:checked]:border-foreground has-[:checked]:bg-muted/50"
|
|
828
|
+
>
|
|
829
|
+
<Checkbox
|
|
830
|
+
checked={checked}
|
|
831
|
+
onCheckedChange={(isChecked) => {
|
|
832
|
+
if (isChecked) {
|
|
833
|
+
field.onChange([
|
|
834
|
+
...field.value,
|
|
835
|
+
inst.id,
|
|
836
|
+
]);
|
|
837
|
+
} else {
|
|
838
|
+
field.onChange(
|
|
839
|
+
field.value.filter(
|
|
840
|
+
(v: string) => v !== inst.id
|
|
841
|
+
)
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
}}
|
|
845
|
+
/>
|
|
846
|
+
<UserCheck className="size-3.5 text-muted-foreground" />
|
|
847
|
+
{inst.nome}
|
|
848
|
+
</label>
|
|
849
|
+
);
|
|
850
|
+
})}
|
|
851
|
+
</div>
|
|
852
|
+
{field.value.length > 0 && (
|
|
853
|
+
<p className="text-xs text-muted-foreground">
|
|
854
|
+
{t('form.fields.instructors.selected', {
|
|
855
|
+
count: field.value.length,
|
|
856
|
+
})}
|
|
857
|
+
</p>
|
|
858
|
+
)}
|
|
859
|
+
</div>
|
|
860
|
+
)}
|
|
861
|
+
/>
|
|
862
|
+
</Field>
|
|
863
|
+
|
|
864
|
+
<Separator />
|
|
865
|
+
|
|
866
|
+
{/* ── Pre-requisitos ────────────────────────────────────── */}
|
|
867
|
+
<Field>
|
|
868
|
+
<FieldLabel htmlFor="preRequisitos">
|
|
869
|
+
{t('form.fields.prerequisites.label')}
|
|
870
|
+
</FieldLabel>
|
|
871
|
+
<Textarea
|
|
872
|
+
id="preRequisitos"
|
|
873
|
+
placeholder={t('form.fields.prerequisites.placeholder')}
|
|
874
|
+
rows={2}
|
|
875
|
+
{...form.register('preRequisitos')}
|
|
876
|
+
/>
|
|
877
|
+
<FieldDescription>
|
|
878
|
+
{t('form.fields.prerequisites.description')}
|
|
879
|
+
</FieldDescription>
|
|
880
|
+
</Field>
|
|
881
|
+
|
|
882
|
+
<Separator />
|
|
883
|
+
|
|
884
|
+
{/* ── Upload Logo + Banner ─────────────────────────────── */}
|
|
885
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
886
|
+
{/* Logo Upload */}
|
|
887
|
+
<Field>
|
|
888
|
+
<FieldLabel>{t('form.fields.logo.label')}</FieldLabel>
|
|
889
|
+
<FieldDescription>
|
|
890
|
+
{t('form.fields.logo.description')}
|
|
891
|
+
</FieldDescription>
|
|
892
|
+
<input
|
|
893
|
+
ref={logoInputRef}
|
|
894
|
+
type="file"
|
|
895
|
+
accept="image/*"
|
|
896
|
+
className="hidden"
|
|
897
|
+
onChange={(e) => handleFileSelect(e, setLogoPreview)}
|
|
898
|
+
/>
|
|
899
|
+
{logoPreview ? (
|
|
900
|
+
<div className="group relative overflow-hidden rounded-lg border">
|
|
901
|
+
<img
|
|
902
|
+
src={logoPreview}
|
|
903
|
+
alt="Logo preview"
|
|
904
|
+
className="aspect-square w-full object-cover"
|
|
905
|
+
/>
|
|
906
|
+
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-background/80 opacity-0 transition-opacity group-hover:opacity-100">
|
|
907
|
+
<Button
|
|
908
|
+
type="button"
|
|
909
|
+
variant="outline"
|
|
910
|
+
size="sm"
|
|
911
|
+
onClick={() => logoInputRef.current?.click()}
|
|
912
|
+
>
|
|
913
|
+
{t('form.fields.logo.change')}
|
|
914
|
+
</Button>
|
|
915
|
+
<Button
|
|
916
|
+
type="button"
|
|
917
|
+
variant="outline"
|
|
918
|
+
size="sm"
|
|
919
|
+
onClick={() => setLogoPreview(null)}
|
|
920
|
+
>
|
|
921
|
+
<XCircle className="size-4" />
|
|
922
|
+
</Button>
|
|
923
|
+
</div>
|
|
924
|
+
</div>
|
|
925
|
+
) : (
|
|
926
|
+
<button
|
|
927
|
+
type="button"
|
|
928
|
+
onClick={() => logoInputRef.current?.click()}
|
|
929
|
+
className="flex aspect-square w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed transition-colors hover:border-foreground/30 hover:bg-muted/50"
|
|
930
|
+
>
|
|
931
|
+
<Upload className="size-8 text-muted-foreground/50" />
|
|
932
|
+
<span className="text-xs text-muted-foreground">
|
|
933
|
+
{t('form.fields.logo.clickToUpload')}
|
|
934
|
+
</span>
|
|
935
|
+
</button>
|
|
936
|
+
)}
|
|
937
|
+
</Field>
|
|
938
|
+
|
|
939
|
+
{/* Banner Upload */}
|
|
940
|
+
<Field>
|
|
941
|
+
<FieldLabel>{t('form.fields.banner.label')}</FieldLabel>
|
|
942
|
+
<FieldDescription>
|
|
943
|
+
{t('form.fields.banner.description')}
|
|
944
|
+
</FieldDescription>
|
|
945
|
+
<input
|
|
946
|
+
ref={bannerInputRef}
|
|
947
|
+
type="file"
|
|
948
|
+
accept="image/*"
|
|
949
|
+
className="hidden"
|
|
950
|
+
onChange={(e) =>
|
|
951
|
+
handleFileSelect(e, setBannerPreview)
|
|
952
|
+
}
|
|
953
|
+
/>
|
|
954
|
+
{bannerPreview ? (
|
|
955
|
+
<div className="group relative overflow-hidden rounded-lg border">
|
|
956
|
+
<img
|
|
957
|
+
src={bannerPreview}
|
|
958
|
+
alt="Banner preview"
|
|
959
|
+
className="aspect-video w-full object-cover"
|
|
960
|
+
/>
|
|
961
|
+
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-background/80 opacity-0 transition-opacity group-hover:opacity-100">
|
|
962
|
+
<Button
|
|
963
|
+
type="button"
|
|
964
|
+
variant="outline"
|
|
965
|
+
size="sm"
|
|
966
|
+
onClick={() => bannerInputRef.current?.click()}
|
|
967
|
+
>
|
|
968
|
+
{t('form.fields.banner.change')}
|
|
969
|
+
</Button>
|
|
970
|
+
<Button
|
|
971
|
+
type="button"
|
|
972
|
+
variant="outline"
|
|
973
|
+
size="sm"
|
|
974
|
+
onClick={() => setBannerPreview(null)}
|
|
975
|
+
>
|
|
976
|
+
<XCircle className="size-4" />
|
|
977
|
+
</Button>
|
|
978
|
+
</div>
|
|
979
|
+
</div>
|
|
980
|
+
) : (
|
|
981
|
+
<button
|
|
982
|
+
type="button"
|
|
983
|
+
onClick={() => bannerInputRef.current?.click()}
|
|
984
|
+
className="flex aspect-video w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed transition-colors hover:border-foreground/30 hover:bg-muted/50"
|
|
985
|
+
>
|
|
986
|
+
<ImageIcon className="size-8 text-muted-foreground/50" />
|
|
987
|
+
<span className="text-xs text-muted-foreground">
|
|
988
|
+
{t('form.fields.banner.clickToUpload')}
|
|
989
|
+
</span>
|
|
990
|
+
</button>
|
|
991
|
+
)}
|
|
992
|
+
</Field>
|
|
993
|
+
</div>
|
|
994
|
+
|
|
995
|
+
<Separator />
|
|
996
|
+
|
|
997
|
+
{/* ── Modelo de Certificado ─────────────────────────────── */}
|
|
998
|
+
<Field>
|
|
999
|
+
<FieldLabel>
|
|
1000
|
+
{t('form.fields.certificateModel.label')}
|
|
1001
|
+
</FieldLabel>
|
|
1002
|
+
<FieldDescription>
|
|
1003
|
+
{t('form.fields.certificateModel.description')}
|
|
1004
|
+
</FieldDescription>
|
|
1005
|
+
<Controller
|
|
1006
|
+
control={form.control}
|
|
1007
|
+
name="modeloCertificado"
|
|
1008
|
+
render={({ field }) => (
|
|
1009
|
+
<Select
|
|
1010
|
+
value={field.value}
|
|
1011
|
+
onValueChange={field.onChange}
|
|
1012
|
+
>
|
|
1013
|
+
<SelectTrigger>
|
|
1014
|
+
<SelectValue
|
|
1015
|
+
placeholder={t(
|
|
1016
|
+
'form.fields.certificateModel.placeholder'
|
|
1017
|
+
)}
|
|
1018
|
+
/>
|
|
1019
|
+
</SelectTrigger>
|
|
1020
|
+
<SelectContent>
|
|
1021
|
+
{MODELOS_CERTIFICADO.map((m) => (
|
|
1022
|
+
<SelectItem key={m.value} value={m.value}>
|
|
1023
|
+
<div className="flex items-center gap-2">
|
|
1024
|
+
<Award className="size-3.5 text-muted-foreground" />
|
|
1025
|
+
{m.label}
|
|
1026
|
+
</div>
|
|
1027
|
+
</SelectItem>
|
|
1028
|
+
))}
|
|
1029
|
+
</SelectContent>
|
|
1030
|
+
</Select>
|
|
1031
|
+
)}
|
|
1032
|
+
/>
|
|
1033
|
+
</Field>
|
|
1034
|
+
|
|
1035
|
+
<Separator />
|
|
1036
|
+
|
|
1037
|
+
{/* ── Flags ─────────────────────────────────────────────── */}
|
|
1038
|
+
<div className="flex flex-col gap-3">
|
|
1039
|
+
<p className="text-sm font-medium">
|
|
1040
|
+
{t('form.flags.title')}
|
|
1041
|
+
</p>
|
|
1042
|
+
<div className="grid gap-3 sm:grid-cols-3">
|
|
1043
|
+
<Controller
|
|
1044
|
+
control={form.control}
|
|
1045
|
+
name="destaque"
|
|
1046
|
+
render={({ field }) => (
|
|
1047
|
+
<div className="flex items-center justify-between rounded-md border px-4 py-3">
|
|
1048
|
+
<div className="flex flex-col gap-0.5">
|
|
1049
|
+
<span className="text-sm font-medium">
|
|
1050
|
+
{t('form.flags.featured.label')}
|
|
1051
|
+
</span>
|
|
1052
|
+
<span className="text-xs text-muted-foreground">
|
|
1053
|
+
{t('form.flags.featured.description')}
|
|
1054
|
+
</span>
|
|
1055
|
+
</div>
|
|
1056
|
+
<Switch
|
|
1057
|
+
checked={field.value}
|
|
1058
|
+
onCheckedChange={field.onChange}
|
|
1059
|
+
/>
|
|
1060
|
+
</div>
|
|
1061
|
+
)}
|
|
1062
|
+
/>
|
|
1063
|
+
<Controller
|
|
1064
|
+
control={form.control}
|
|
1065
|
+
name="certificado"
|
|
1066
|
+
render={({ field }) => (
|
|
1067
|
+
<div className="flex items-center justify-between rounded-md border px-4 py-3">
|
|
1068
|
+
<div className="flex flex-col gap-0.5">
|
|
1069
|
+
<span className="text-sm font-medium">
|
|
1070
|
+
{t('form.flags.certificate.label')}
|
|
1071
|
+
</span>
|
|
1072
|
+
<span className="text-xs text-muted-foreground">
|
|
1073
|
+
{t('form.flags.certificate.description')}
|
|
1074
|
+
</span>
|
|
1075
|
+
</div>
|
|
1076
|
+
<Switch
|
|
1077
|
+
checked={field.value}
|
|
1078
|
+
onCheckedChange={field.onChange}
|
|
1079
|
+
/>
|
|
1080
|
+
</div>
|
|
1081
|
+
)}
|
|
1082
|
+
/>
|
|
1083
|
+
<Controller
|
|
1084
|
+
control={form.control}
|
|
1085
|
+
name="listado"
|
|
1086
|
+
render={({ field }) => (
|
|
1087
|
+
<div className="flex items-center justify-between rounded-md border px-4 py-3">
|
|
1088
|
+
<div className="flex flex-col gap-0.5">
|
|
1089
|
+
<span className="text-sm font-medium">
|
|
1090
|
+
{t('form.flags.listed.label')}
|
|
1091
|
+
</span>
|
|
1092
|
+
<span className="text-xs text-muted-foreground">
|
|
1093
|
+
{t('form.flags.listed.description')}
|
|
1094
|
+
</span>
|
|
1095
|
+
</div>
|
|
1096
|
+
<Switch
|
|
1097
|
+
checked={field.value}
|
|
1098
|
+
onCheckedChange={field.onChange}
|
|
1099
|
+
/>
|
|
1100
|
+
</div>
|
|
1101
|
+
)}
|
|
1102
|
+
/>
|
|
1103
|
+
</div>
|
|
1104
|
+
</div>
|
|
1105
|
+
|
|
1106
|
+
<Separator />
|
|
1107
|
+
|
|
1108
|
+
{/* ── Form Actions ──────────────────────────────────────── */}
|
|
1109
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
1110
|
+
<Button
|
|
1111
|
+
type="button"
|
|
1112
|
+
variant="outline"
|
|
1113
|
+
className="gap-2 text-destructive hover:text-destructive"
|
|
1114
|
+
onClick={() => setDeleteDialogOpen(true)}
|
|
1115
|
+
>
|
|
1116
|
+
<Trash2 className="size-4" />
|
|
1117
|
+
{t('form.actions.deleteCourse')}
|
|
1118
|
+
</Button>
|
|
1119
|
+
<div className="flex items-center gap-2">
|
|
1120
|
+
<Button
|
|
1121
|
+
type="button"
|
|
1122
|
+
variant="outline"
|
|
1123
|
+
onClick={() => router.push('/cursos')}
|
|
1124
|
+
>
|
|
1125
|
+
{t('form.actions.cancel')}
|
|
1126
|
+
</Button>
|
|
1127
|
+
<Button
|
|
1128
|
+
type="submit"
|
|
1129
|
+
disabled={saving}
|
|
1130
|
+
className="gap-2"
|
|
1131
|
+
>
|
|
1132
|
+
{saving ? (
|
|
1133
|
+
<Loader2 className="size-4 animate-spin" />
|
|
1134
|
+
) : (
|
|
1135
|
+
<Save className="size-4" />
|
|
1136
|
+
)}
|
|
1137
|
+
{t('form.actions.saveChanges')}
|
|
1138
|
+
</Button>
|
|
1139
|
+
</div>
|
|
1140
|
+
</div>
|
|
1141
|
+
</form>
|
|
1142
|
+
</CardContent>
|
|
1143
|
+
</Card>
|
|
1144
|
+
</motion.div>
|
|
1145
|
+
</motion.div>
|
|
1146
|
+
)}
|
|
1147
|
+
</div>
|
|
1148
|
+
|
|
1149
|
+
{/* ── Dialog: Confirmar Exclusao ────────────────────────────────────── */}
|
|
1150
|
+
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
|
1151
|
+
<DialogContent className="max-w-3xl">
|
|
1152
|
+
<DialogHeader>
|
|
1153
|
+
<DialogTitle className="flex items-center gap-2">
|
|
1154
|
+
<AlertTriangle className="size-5 text-destructive" />
|
|
1155
|
+
{t('deleteDialog.title')}
|
|
1156
|
+
</DialogTitle>
|
|
1157
|
+
<DialogDescription asChild>
|
|
1158
|
+
<div className="flex flex-col gap-3">
|
|
1159
|
+
<p>
|
|
1160
|
+
{t('deleteDialog.description')}{' '}
|
|
1161
|
+
<strong className="text-foreground">
|
|
1162
|
+
{MOCK_CURSO.tituloComercial}
|
|
1163
|
+
</strong>
|
|
1164
|
+
?
|
|
1165
|
+
</p>
|
|
1166
|
+
{MOCK_CURSO.totalAlunos > 0 && (
|
|
1167
|
+
<div className="flex items-center gap-1.5 rounded-md bg-amber-50 px-3 py-2.5 text-xs font-medium text-amber-700">
|
|
1168
|
+
<AlertTriangle className="size-3.5 shrink-0" />
|
|
1169
|
+
<span>
|
|
1170
|
+
{t('deleteDialog.warning', {
|
|
1171
|
+
students: MOCK_CURSO.totalAlunos,
|
|
1172
|
+
certificates: MOCK_CURSO.certificadosEmitidos,
|
|
1173
|
+
})}
|
|
1174
|
+
</span>
|
|
1175
|
+
</div>
|
|
1176
|
+
)}
|
|
1177
|
+
</div>
|
|
1178
|
+
</DialogDescription>
|
|
1179
|
+
</DialogHeader>
|
|
1180
|
+
<DialogFooter className="gap-2">
|
|
1181
|
+
<Button
|
|
1182
|
+
variant="outline"
|
|
1183
|
+
onClick={() => setDeleteDialogOpen(false)}
|
|
1184
|
+
>
|
|
1185
|
+
{t('deleteDialog.actions.cancel')}
|
|
1186
|
+
</Button>
|
|
1187
|
+
<Button
|
|
1188
|
+
variant="destructive"
|
|
1189
|
+
onClick={handleDelete}
|
|
1190
|
+
disabled={deleting}
|
|
1191
|
+
className="gap-2"
|
|
1192
|
+
>
|
|
1193
|
+
{deleting ? (
|
|
1194
|
+
<Loader2 className="size-4 animate-spin" />
|
|
1195
|
+
) : (
|
|
1196
|
+
<Trash2 className="size-4" />
|
|
1197
|
+
)}
|
|
1198
|
+
{t('deleteDialog.actions.delete')}
|
|
1199
|
+
</Button>
|
|
1200
|
+
</DialogFooter>
|
|
1201
|
+
</DialogContent>
|
|
1202
|
+
</Dialog>
|
|
1203
|
+
</Page>
|
|
1204
|
+
);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
// ── Loading Skeleton ──────────────────────────────────────────────────────────
|
|
1208
|
+
|
|
1209
|
+
function LoadingSkeleton() {
|
|
1210
|
+
return (
|
|
1211
|
+
<div className="flex flex-col gap-6">
|
|
1212
|
+
<Skeleton className="h-4 w-32" />
|
|
1213
|
+
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
|
1214
|
+
<div className="flex flex-col gap-2">
|
|
1215
|
+
<div className="flex items-center gap-2">
|
|
1216
|
+
<Skeleton className="h-5 w-24 rounded-full" />
|
|
1217
|
+
<Skeleton className="h-5 w-16 rounded-full" />
|
|
1218
|
+
</div>
|
|
1219
|
+
<Skeleton className="h-8 w-64" />
|
|
1220
|
+
<Skeleton className="h-4 w-40" />
|
|
1221
|
+
</div>
|
|
1222
|
+
<div className="flex items-center gap-2">
|
|
1223
|
+
<Skeleton className="h-9 w-36 rounded-md" />
|
|
1224
|
+
<Skeleton className="h-9 w-36 rounded-md" />
|
|
1225
|
+
<Skeleton className="h-9 w-32 rounded-md" />
|
|
1226
|
+
</div>
|
|
1227
|
+
</div>
|
|
1228
|
+
<div className="grid grid-cols-2 gap-4 lg:grid-cols-5">
|
|
1229
|
+
{Array.from({ length: 5 }).map((_, i) => (
|
|
1230
|
+
<Skeleton key={i} className="h-20 rounded-xl" />
|
|
1231
|
+
))}
|
|
1232
|
+
</div>
|
|
1233
|
+
<Skeleton className="h-80 rounded-xl" />
|
|
1234
|
+
<Skeleton className="h-[600px] rounded-xl" />
|
|
1235
|
+
</div>
|
|
1236
|
+
);
|
|
1237
|
+
}
|