@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.
Files changed (52) hide show
  1. package/hedhog/data/menu.yaml +8 -1
  2. package/hedhog/frontend/app/classes/[id]/page.tsx.ejs +1387 -0
  3. package/hedhog/frontend/app/classes/page.tsx.ejs +4 -4
  4. package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +1237 -0
  5. package/hedhog/frontend/app/courses/[id]/structure/page.tsx.ejs +2642 -0
  6. package/hedhog/frontend/app/courses/page.tsx.ejs +825 -727
  7. package/hedhog/frontend/app/exams/[id]/attempt/page.tsx.ejs +976 -0
  8. package/hedhog/frontend/app/exams/[id]/questions/page.tsx.ejs +931 -0
  9. package/hedhog/frontend/app/exams/page.tsx.ejs +9 -7
  10. package/hedhog/frontend/app/training/page.tsx.ejs +3 -3
  11. package/hedhog/frontend/messages/en.json +703 -14
  12. package/hedhog/frontend/messages/pt.json +863 -174
  13. package/hedhog/query/triggers.sql +0 -0
  14. package/hedhog/table/certificate.yaml +89 -0
  15. package/hedhog/table/certificate_template.yaml +24 -0
  16. package/hedhog/table/course.yaml +67 -1
  17. package/hedhog/table/course_category.yaml +22 -0
  18. package/hedhog/table/course_class_attendance.yaml +34 -0
  19. package/hedhog/table/course_class_group.yaml +58 -0
  20. package/hedhog/table/course_class_session.yaml +38 -0
  21. package/hedhog/table/course_class_session_instructor.yaml +27 -0
  22. package/hedhog/table/course_enrollment.yaml +45 -0
  23. package/hedhog/table/course_image.yaml +33 -0
  24. package/hedhog/table/course_lesson.yaml +35 -0
  25. package/hedhog/table/course_lesson_file.yaml +23 -0
  26. package/hedhog/table/course_lesson_instructor.yaml +27 -0
  27. package/hedhog/table/course_lesson_progress.yaml +40 -0
  28. package/hedhog/table/course_lesson_question.yaml +24 -0
  29. package/hedhog/table/course_module.yaml +25 -0
  30. package/hedhog/table/course_prerequisite.yaml +48 -0
  31. package/hedhog/table/evaluation_rating.yaml +30 -0
  32. package/hedhog/table/evaluation_topic.yaml +68 -0
  33. package/hedhog/table/exam.yaml +91 -0
  34. package/hedhog/table/exam_answer.yaml +40 -0
  35. package/hedhog/table/exam_attempt.yaml +51 -0
  36. package/hedhog/table/exam_image.yaml +33 -0
  37. package/hedhog/table/exam_option.yaml +25 -0
  38. package/hedhog/table/exam_question.yaml +24 -0
  39. package/hedhog/table/image_type.yaml +28 -0
  40. package/hedhog/table/instructor.yaml +23 -0
  41. package/hedhog/table/learning_path.yaml +49 -0
  42. package/hedhog/table/learning_path_enrollment.yaml +33 -0
  43. package/hedhog/table/learning_path_image.yaml +33 -0
  44. package/hedhog/table/learning_path_step.yaml +43 -0
  45. package/hedhog/table/question.yaml +15 -0
  46. package/package.json +9 -6
  47. package/src/index.ts +1 -1
  48. package/src/lms.module.ts +15 -15
  49. package/hedhog/table/classes.yaml +0 -3
  50. package/hedhog/table/exams.yaml +0 -3
  51. package/hedhog/table/reports.yaml +0 -3
  52. 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
+ }