@hed-hog/lms 0.0.261 → 0.0.265

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.
@@ -0,0 +1,1130 @@
1
+ 'use client';
2
+
3
+ import { Page, PageHeader } from '@/components/entity-list';
4
+ import { Badge } from '@/components/ui/badge';
5
+ import { Button } from '@/components/ui/button';
6
+ import { Card, CardContent } from '@/components/ui/card';
7
+ import {
8
+ Dialog,
9
+ DialogContent,
10
+ DialogDescription,
11
+ DialogFooter,
12
+ DialogHeader,
13
+ DialogTitle,
14
+ } from '@/components/ui/dialog';
15
+ import {
16
+ DropdownMenu,
17
+ DropdownMenuContent,
18
+ DropdownMenuItem,
19
+ DropdownMenuSeparator,
20
+ DropdownMenuTrigger,
21
+ } from '@/components/ui/dropdown-menu';
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 {
37
+ Sheet,
38
+ SheetContent,
39
+ SheetDescription,
40
+ SheetFooter,
41
+ SheetHeader,
42
+ SheetTitle,
43
+ } from '@/components/ui/sheet';
44
+ import { Skeleton } from '@/components/ui/skeleton';
45
+ import { Switch } from '@/components/ui/switch';
46
+ import { zodResolver } from '@hookform/resolvers/zod';
47
+ import { motion } from 'framer-motion';
48
+ import {
49
+ AlertTriangle,
50
+ BarChart3,
51
+ BookOpen,
52
+ CheckCircle2,
53
+ ChevronLeft,
54
+ ChevronRight,
55
+ ChevronsLeft,
56
+ ChevronsRight,
57
+ FileCheck,
58
+ FileQuestion,
59
+ GraduationCap,
60
+ LayoutDashboard,
61
+ ListChecks,
62
+ Loader2,
63
+ MoreHorizontal,
64
+ Pencil,
65
+ Play,
66
+ Plus,
67
+ Search,
68
+ Shuffle,
69
+ Target,
70
+ Timer,
71
+ Trash2,
72
+ TrendingUp,
73
+ Users,
74
+ X,
75
+ } from 'lucide-react';
76
+ import { useTranslations } from 'next-intl';
77
+ import { usePathname, useRouter } from 'next/navigation';
78
+ import { useEffect, useMemo, useRef, useState } from 'react';
79
+ import { Controller, useForm } from 'react-hook-form';
80
+ import { toast } from 'sonner';
81
+ import { z } from 'zod';
82
+
83
+ // ── Nav ───────────────────────────────────────────────────────────────────────
84
+
85
+ const NAV_ITEMS = [
86
+ { label: 'Dashboard', href: '/', icon: LayoutDashboard },
87
+ { label: 'Cursos', href: '/cursos', icon: BookOpen },
88
+ { label: 'Turmas', href: '/turmas', icon: Users },
89
+ { label: 'Exames', href: '/exames', icon: FileCheck },
90
+ { label: 'Formacoes', href: '/formacoes', icon: GraduationCap },
91
+ { label: 'Relatorios', href: '/relatorios', icon: BarChart3 },
92
+ ];
93
+
94
+ // ── Types ─────────────────────────────────────────────────────────────────────
95
+
96
+ interface Exame {
97
+ id: number;
98
+ codigo: string;
99
+ titulo: string;
100
+ notaMinima: number;
101
+ limiteTempo: number;
102
+ shuffle: boolean;
103
+ status: 'publicado' | 'rascunho' | 'encerrado';
104
+ questoes: number;
105
+ realizacoes: number;
106
+ mediaNotas: number;
107
+ criadoEm: string;
108
+ }
109
+
110
+ // ── Schema ────────────────────────────────────────────────────────────────────
111
+
112
+ const createExameSchema = (t: (key: string) => string) =>
113
+ z.object({
114
+ codigo: z.string().min(2, t('form.validation.codeMinLength')),
115
+ titulo: z.string().min(3, t('form.validation.titleMinLength')),
116
+ notaMinima: z.coerce
117
+ .number()
118
+ .min(0, t('form.validation.minScoreMin'))
119
+ .max(10, t('form.validation.minScoreMax')),
120
+ limiteTempo: z.coerce.number().min(1, t('form.validation.timeLimitMin')),
121
+ shuffle: z.boolean().default(false),
122
+ status: z.string().min(1, t('form.validation.statusRequired')),
123
+ });
124
+
125
+ type ExameForm = z.infer<ReturnType<typeof createExameSchema>>;
126
+
127
+ // ── Constants ─────────────────────────────────────────────────────────────────
128
+
129
+ const STATUS_MAP: Record<
130
+ string,
131
+ { label: string; variant: 'default' | 'secondary' | 'outline' }
132
+ > = {
133
+ publicado: { label: 'Publicado', variant: 'default' },
134
+ rascunho: { label: 'Rascunho', variant: 'secondary' },
135
+ encerrado: { label: 'Encerrado', variant: 'outline' },
136
+ };
137
+
138
+ const PAGE_SIZES = [6, 12, 24];
139
+
140
+ function formatTempo(minutos: number) {
141
+ if (minutos < 60) return `${minutos}min`;
142
+ const h = Math.floor(minutos / 60);
143
+ const m = minutos % 60;
144
+ return m > 0 ? `${h}h ${m}min` : `${h}h`;
145
+ }
146
+
147
+ // ── Seed Data ─────────────────────────────────────────────────────────────────
148
+
149
+ const initialExames: Exame[] = [
150
+ {
151
+ id: 1,
152
+ codigo: 'EX-001',
153
+ titulo: 'Prova Final - React Avancado',
154
+ notaMinima: 7,
155
+ limiteTempo: 120,
156
+ shuffle: true,
157
+ status: 'publicado',
158
+ questoes: 40,
159
+ realizacoes: 218,
160
+ mediaNotas: 7.8,
161
+ criadoEm: '2024-04-01',
162
+ },
163
+ {
164
+ id: 2,
165
+ codigo: 'EX-002',
166
+ titulo: 'Quiz - Fundamentos de UX',
167
+ notaMinima: 6,
168
+ limiteTempo: 30,
169
+ shuffle: true,
170
+ status: 'publicado',
171
+ questoes: 15,
172
+ realizacoes: 156,
173
+ mediaNotas: 8.2,
174
+ criadoEm: '2024-04-05',
175
+ },
176
+ {
177
+ id: 3,
178
+ codigo: 'EX-003',
179
+ titulo: 'Simulado - Scrum Master',
180
+ notaMinima: 7.5,
181
+ limiteTempo: 90,
182
+ shuffle: true,
183
+ status: 'publicado',
184
+ questoes: 60,
185
+ realizacoes: 287,
186
+ mediaNotas: 6.9,
187
+ criadoEm: '2024-03-20',
188
+ },
189
+ {
190
+ id: 4,
191
+ codigo: 'EX-004',
192
+ titulo: 'Trabalho Pratico - Python',
193
+ notaMinima: 7,
194
+ limiteTempo: 480,
195
+ shuffle: false,
196
+ status: 'publicado',
197
+ questoes: 5,
198
+ realizacoes: 145,
199
+ mediaNotas: 7.5,
200
+ criadoEm: '2024-05-01',
201
+ },
202
+ {
203
+ id: 5,
204
+ codigo: 'EX-005',
205
+ titulo: 'Prova Intermediaria - Node.js',
206
+ notaMinima: 6,
207
+ limiteTempo: 90,
208
+ shuffle: true,
209
+ status: 'publicado',
210
+ questoes: 30,
211
+ realizacoes: 134,
212
+ mediaNotas: 7.1,
213
+ criadoEm: '2024-04-15',
214
+ },
215
+ {
216
+ id: 6,
217
+ codigo: 'EX-006',
218
+ titulo: 'Quiz - Marketing de Conteudo',
219
+ notaMinima: 5,
220
+ limiteTempo: 20,
221
+ shuffle: true,
222
+ status: 'rascunho',
223
+ questoes: 10,
224
+ realizacoes: 0,
225
+ mediaNotas: 0,
226
+ criadoEm: '2024-05-10',
227
+ },
228
+ {
229
+ id: 7,
230
+ codigo: 'EX-007',
231
+ titulo: 'Prova Final - TypeScript',
232
+ notaMinima: 7,
233
+ limiteTempo: 120,
234
+ shuffle: true,
235
+ status: 'publicado',
236
+ questoes: 45,
237
+ realizacoes: 178,
238
+ mediaNotas: 7.3,
239
+ criadoEm: '2024-06-01',
240
+ },
241
+ {
242
+ id: 8,
243
+ codigo: 'EX-008',
244
+ titulo: 'Trabalho - Design System',
245
+ notaMinima: 8,
246
+ limiteTempo: 960,
247
+ shuffle: false,
248
+ status: 'encerrado',
249
+ questoes: 3,
250
+ realizacoes: 82,
251
+ mediaNotas: 8.5,
252
+ criadoEm: '2024-02-15',
253
+ },
254
+ {
255
+ id: 9,
256
+ codigo: 'EX-009',
257
+ titulo: 'Simulado - Excel Avancado',
258
+ notaMinima: 6,
259
+ limiteTempo: 60,
260
+ shuffle: true,
261
+ status: 'encerrado',
262
+ questoes: 25,
263
+ realizacoes: 498,
264
+ mediaNotas: 7.7,
265
+ criadoEm: '2024-01-20',
266
+ },
267
+ {
268
+ id: 10,
269
+ codigo: 'EX-010',
270
+ titulo: 'Quiz - Lideranca Situacional',
271
+ notaMinima: 6,
272
+ limiteTempo: 25,
273
+ shuffle: true,
274
+ status: 'publicado',
275
+ questoes: 12,
276
+ realizacoes: 67,
277
+ mediaNotas: 8.0,
278
+ criadoEm: '2024-05-20',
279
+ },
280
+ ];
281
+
282
+ // ── Animations ────────────────────────────────────────────────────────────────
283
+
284
+ const fadeUp = {
285
+ hidden: { opacity: 0, y: 16 },
286
+ show: { opacity: 1, y: 0, transition: { duration: 0.3 } } as const,
287
+ };
288
+ const stagger = {
289
+ hidden: {},
290
+ show: { transition: { staggerChildren: 0.05 } },
291
+ };
292
+
293
+ // ── Page ──────────────────────────────────────────────────────────────────────
294
+
295
+ export default function ExamesPage() {
296
+ const t = useTranslations('lms.ExamsPage');
297
+ const pathname = usePathname();
298
+ const router = useRouter();
299
+
300
+ const exameSchema = useMemo(() => createExameSchema(t), [t]);
301
+
302
+ const [loading, setLoading] = useState(true);
303
+ const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
304
+ const [exames, setExames] = useState<Exame[]>(initialExames);
305
+ const [sheetOpen, setSheetOpen] = useState(false);
306
+ const [editingExame, setEditingExame] = useState<Exame | null>(null);
307
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
308
+ const [exameToDelete, setExameToDelete] = useState<Exame | null>(null);
309
+ const [saving, setSaving] = useState(false);
310
+
311
+ // Search/filter inputs
312
+ const [buscaInput, setBuscaInput] = useState('');
313
+ const [filtroStatusInput, setFiltroStatusInput] = useState('todos');
314
+
315
+ // Applied filters
316
+ const [buscaApplied, setBuscaApplied] = useState('');
317
+ const [filtroStatusApplied, setFiltroStatusApplied] = useState('todos');
318
+
319
+ // Pagination
320
+ const [currentPage, setCurrentPage] = useState(1);
321
+ const [pageSize, setPageSize] = useState(12);
322
+
323
+ // Double-click tracking
324
+ const clickTimers = useRef<Map<number, ReturnType<typeof setTimeout>>>(
325
+ new Map()
326
+ );
327
+
328
+ const form = useForm<ExameForm>({
329
+ resolver: zodResolver(exameSchema),
330
+ defaultValues: {
331
+ codigo: '',
332
+ titulo: '',
333
+ notaMinima: 7,
334
+ limiteTempo: 60,
335
+ shuffle: true,
336
+ status: 'rascunho',
337
+ },
338
+ });
339
+
340
+ useEffect(() => {
341
+ const t = setTimeout(() => setLoading(false), 700);
342
+ return () => clearTimeout(t);
343
+ }, []);
344
+
345
+ // ── Filtering ────────────────────────────────────────────────────────────
346
+
347
+ const filteredExames = useMemo(
348
+ () =>
349
+ exames.filter((e) => {
350
+ const q = buscaApplied.toLowerCase();
351
+ return (
352
+ (!q ||
353
+ e.titulo.toLowerCase().includes(q) ||
354
+ e.codigo.toLowerCase().includes(q)) &&
355
+ (filtroStatusApplied === 'todos' || e.status === filtroStatusApplied)
356
+ );
357
+ }),
358
+ [exames, buscaApplied, filtroStatusApplied]
359
+ );
360
+
361
+ const totalPages = Math.max(1, Math.ceil(filteredExames.length / pageSize));
362
+ const safePage = Math.min(currentPage, totalPages);
363
+ const paginatedExames = filteredExames.slice(
364
+ (safePage - 1) * pageSize,
365
+ safePage * pageSize
366
+ );
367
+
368
+ function handleSearch(e: React.FormEvent) {
369
+ e.preventDefault();
370
+ setBuscaApplied(buscaInput);
371
+ setFiltroStatusApplied(filtroStatusInput);
372
+ setCurrentPage(1);
373
+ }
374
+
375
+ function clearFilters() {
376
+ setBuscaInput('');
377
+ setFiltroStatusInput('todos');
378
+ setBuscaApplied('');
379
+ setFiltroStatusApplied('todos');
380
+ setCurrentPage(1);
381
+ }
382
+
383
+ const hasActiveFilters = buscaApplied || filtroStatusApplied !== 'todos';
384
+
385
+ // ── Double-click ──────────────────────────────────────────────────────────
386
+
387
+ function handleCardClick(exame: Exame) {
388
+ const existing = clickTimers.current.get(exame.id);
389
+ if (existing) {
390
+ clearTimeout(existing);
391
+ clickTimers.current.delete(exame.id);
392
+ router.push(`/exames/${exame.id}/questoes`);
393
+ } else {
394
+ const t = setTimeout(() => clickTimers.current.delete(exame.id), 300);
395
+ clickTimers.current.set(exame.id, t);
396
+ }
397
+ }
398
+
399
+ // ── CRUD ──────────────────────────────────────────────────────────────────
400
+
401
+ function openCreateSheet() {
402
+ setEditingExame(null);
403
+ form.reset({
404
+ codigo: '',
405
+ titulo: '',
406
+ notaMinima: 7,
407
+ limiteTempo: 60,
408
+ shuffle: true,
409
+ status: 'rascunho',
410
+ });
411
+ setSheetOpen(true);
412
+ }
413
+
414
+ function openEditSheet(exame: Exame, e?: React.MouseEvent) {
415
+ e?.stopPropagation();
416
+ setEditingExame(exame);
417
+ form.reset({
418
+ codigo: exame.codigo,
419
+ titulo: exame.titulo,
420
+ notaMinima: exame.notaMinima,
421
+ limiteTempo: exame.limiteTempo,
422
+ shuffle: exame.shuffle,
423
+ status: exame.status,
424
+ });
425
+ setSheetOpen(true);
426
+ }
427
+
428
+ async function onSubmit(data: ExameForm) {
429
+ setSaving(true);
430
+ await new Promise((r) => setTimeout(r, 500));
431
+ if (editingExame) {
432
+ setExames((prev) =>
433
+ prev.map((e) =>
434
+ e.id === editingExame.id
435
+ ? { ...e, ...data, status: data.status as Exame['status'] }
436
+ : e
437
+ )
438
+ );
439
+ toast.success(t('toasts.examUpdated'));
440
+ } else {
441
+ const newExame: Exame = {
442
+ id: Date.now(),
443
+ ...data,
444
+ status: data.status as Exame['status'],
445
+ questoes: 0,
446
+ realizacoes: 0,
447
+ mediaNotas: 0,
448
+ criadoEm: new Date().toISOString().split('T')[0] || '',
449
+ };
450
+ setExames((prev) => [newExame, ...prev]);
451
+ toast.success(t('toasts.examCreated'));
452
+ setSaving(false);
453
+ setSheetOpen(false);
454
+ setTimeout(() => router.push(`/exames/${newExame.id}/questoes`), 400);
455
+ return;
456
+ }
457
+ setSaving(false);
458
+ setSheetOpen(false);
459
+ }
460
+
461
+ function confirmDelete() {
462
+ if (!exameToDelete) return;
463
+ setExames((prev) => prev.filter((e) => e.id !== exameToDelete.id));
464
+ toast.success(t('toasts.examRemoved'));
465
+ setExameToDelete(null);
466
+ setDeleteDialogOpen(false);
467
+ }
468
+
469
+ // ── KPIs ──────────────────────────────────────────────────────────────────
470
+
471
+ const STATUS_MAP: Record<
472
+ string,
473
+ { label: string; variant: 'default' | 'secondary' | 'outline' }
474
+ > = {
475
+ publicado: { label: t('status.published'), variant: 'default' },
476
+ rascunho: { label: t('status.draft'), variant: 'secondary' },
477
+ encerrado: { label: t('status.closed'), variant: 'outline' },
478
+ };
479
+
480
+ const totalQuestoes = exames.reduce((a, e) => a + e.questoes, 0);
481
+ const mediaGeral =
482
+ exames
483
+ .filter((e) => e.mediaNotas > 0)
484
+ .reduce((a, e) => a + e.mediaNotas, 0) /
485
+ Math.max(exames.filter((e) => e.mediaNotas > 0).length, 1);
486
+
487
+ const kpis = [
488
+ {
489
+ label: t('kpis.totalExams.label'),
490
+ valor: exames.length,
491
+ sub: t('kpis.totalExams.sub'),
492
+ icon: FileCheck,
493
+ iconBg: 'bg-orange-100',
494
+ iconColor: 'text-orange-600',
495
+ },
496
+ {
497
+ label: t('kpis.published.label'),
498
+ valor: exames.filter((e) => e.status === 'publicado').length,
499
+ sub: t('kpis.published.sub'),
500
+ icon: CheckCircle2,
501
+ iconBg: 'bg-muted',
502
+ iconColor: 'text-foreground',
503
+ },
504
+ {
505
+ label: t('kpis.totalQuestions.label'),
506
+ valor: totalQuestoes.toLocaleString('pt-BR'),
507
+ sub: t('kpis.totalQuestions.sub'),
508
+ icon: FileQuestion,
509
+ iconBg: 'bg-muted',
510
+ iconColor: 'text-foreground',
511
+ },
512
+ {
513
+ label: t('kpis.avgScore.label'),
514
+ valor: mediaGeral.toFixed(1),
515
+ sub: t('kpis.avgScore.sub'),
516
+ icon: TrendingUp,
517
+ iconBg: 'bg-muted',
518
+ iconColor: 'text-foreground',
519
+ },
520
+ ];
521
+
522
+ // ── Render ────────────────────────────────────────────────────────────────
523
+
524
+ return (
525
+ <Page>
526
+ <PageHeader
527
+ title={t('title')}
528
+ description={t('description')}
529
+ breadcrumbs={[
530
+ {
531
+ label: t('breadcrumbs.home'),
532
+ href: '/',
533
+ },
534
+ {
535
+ label: t('breadcrumbs.exams'),
536
+ },
537
+ ]}
538
+ actions={
539
+ <Button onClick={openCreateSheet} className="shrink-0 gap-2">
540
+ <Plus className="size-4" /> {t('actions.createExam')}
541
+ </Button>
542
+ }
543
+ />
544
+
545
+ {/* KPIs */}
546
+ <div className="mb-6 grid grid-cols-2 gap-4 lg:grid-cols-4">
547
+ {loading
548
+ ? Array.from({ length: 4 }).map((_, i) => (
549
+ <Card key={i}>
550
+ <CardContent className="p-4">
551
+ <Skeleton className="mb-2 h-8 w-16" />
552
+ <Skeleton className="h-4 w-28" />
553
+ </CardContent>
554
+ </Card>
555
+ ))
556
+ : kpis.map((kpi, i) => (
557
+ <motion.div
558
+ key={kpi.label}
559
+ initial={{ opacity: 0, y: 12 }}
560
+ animate={{ opacity: 1, y: 0 }}
561
+ transition={{ delay: i * 0.07 }}
562
+ >
563
+ <Card className="overflow-hidden">
564
+ <CardContent className="flex items-start justify-between p-5">
565
+ <div>
566
+ <p className="text-sm text-muted-foreground">
567
+ {kpi.label}
568
+ </p>
569
+ <p className="mt-1 text-3xl font-bold tracking-tight">
570
+ {kpi.valor}
571
+ </p>
572
+ <p className="mt-0.5 text-xs text-muted-foreground">
573
+ {kpi.sub}
574
+ </p>
575
+ </div>
576
+ <div
577
+ className={`flex size-10 shrink-0 items-center justify-center rounded-lg ${kpi.iconBg}`}
578
+ >
579
+ <kpi.icon className={`size-5 ${kpi.iconColor}`} />
580
+ </div>
581
+ </CardContent>
582
+ </Card>
583
+ </motion.div>
584
+ ))}
585
+ </div>
586
+
587
+ {/* Search bar */}
588
+ <form onSubmit={handleSearch} className="mb-6">
589
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
590
+ <div className="relative flex-1">
591
+ <Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
592
+ <Input
593
+ placeholder={t('filters.searchPlaceholder')}
594
+ value={buscaInput}
595
+ onChange={(e) => setBuscaInput(e.target.value)}
596
+ className="pl-9"
597
+ />
598
+ </div>
599
+ <div className="flex flex-wrap items-center gap-2">
600
+ <Select
601
+ value={filtroStatusInput}
602
+ onValueChange={setFiltroStatusInput}
603
+ >
604
+ <SelectTrigger className="h-9 w-[140px] text-sm">
605
+ <SelectValue placeholder="Status" />
606
+ </SelectTrigger>
607
+ <SelectContent>
608
+ <SelectItem value="todos">
609
+ {t('filters.allStatuses')}
610
+ </SelectItem>
611
+ <SelectItem value="publicado">
612
+ {t('status.published')}
613
+ </SelectItem>
614
+ <SelectItem value="rascunho">{t('status.draft')}</SelectItem>
615
+ <SelectItem value="encerrado">{t('status.closed')}</SelectItem>
616
+ </SelectContent>
617
+ </Select>
618
+ {hasActiveFilters && (
619
+ <Button
620
+ type="button"
621
+ variant="ghost"
622
+ size="sm"
623
+ onClick={clearFilters}
624
+ className="h-9 text-muted-foreground"
625
+ >
626
+ <X className="mr-1 size-3.5" /> {t('filters.clear')}
627
+ </Button>
628
+ )}
629
+ <Button type="submit" size="sm" className="h-9 gap-2">
630
+ <Search className="size-3.5" /> {t('filters.search')}
631
+ </Button>
632
+ </div>
633
+ </div>
634
+ </form>
635
+
636
+ {/* Cards grid */}
637
+ {loading ? (
638
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
639
+ {Array.from({ length: 6 }).map((_, i) => (
640
+ <Card key={i} className="overflow-hidden">
641
+ <CardContent className="p-5">
642
+ <Skeleton className="mb-3 h-5 w-20 rounded-full" />
643
+ <Skeleton className="mb-1.5 h-5 w-3/4" />
644
+ <Skeleton className="mb-4 h-4 w-1/2" />
645
+ <div className="flex gap-4">
646
+ <Skeleton className="h-4 w-16" />
647
+ <Skeleton className="h-4 w-16" />
648
+ </div>
649
+ </CardContent>
650
+ </Card>
651
+ ))}
652
+ </div>
653
+ ) : filteredExames.length === 0 ? (
654
+ <div className="flex flex-col items-center justify-center py-20 text-center">
655
+ <FileCheck className="mb-4 size-12 text-muted-foreground/40" />
656
+ <p className="text-lg font-medium">{t('empty.title')}</p>
657
+ <p className="mt-1 text-sm text-muted-foreground">
658
+ {t('empty.description')}
659
+ </p>
660
+ <Button className="mt-6 gap-2" onClick={openCreateSheet}>
661
+ <Plus className="size-4" />
662
+ {t('empty.action')}
663
+ </Button>
664
+ </div>
665
+ ) : (
666
+ <motion.div
667
+ className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"
668
+ variants={stagger}
669
+ initial="hidden"
670
+ animate="show"
671
+ >
672
+ {paginatedExames.map((exame) => {
673
+ const notaColor =
674
+ exame.mediaNotas >= exame.notaMinima
675
+ ? 'text-emerald-600'
676
+ : exame.mediaNotas > 0
677
+ ? 'text-red-600'
678
+ : 'text-muted-foreground';
679
+
680
+ return (
681
+ <motion.div key={exame.id} variants={fadeUp}>
682
+ <Card
683
+ className="group relative cursor-pointer overflow-hidden border-0 shadow-sm transition-all duration-300 hover:shadow-lg hover:-translate-y-1"
684
+ onClick={() => handleCardClick(exame)}
685
+ title={t('cards.tooltip')}
686
+ >
687
+ {/* Top accent */}
688
+ <div className="h-1 w-full bg-foreground" />
689
+
690
+ <CardContent className="p-5">
691
+ {/* Header with Icon + Title + Actions */}
692
+ <div className="mb-4 flex items-start gap-3">
693
+ {/* Exam icon */}
694
+ <div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-muted border">
695
+ <FileCheck className="size-6 text-foreground" />
696
+ </div>
697
+ <div className="min-w-0 flex-1">
698
+ <div className="mb-1 flex items-start justify-between gap-2">
699
+ <h3 className="line-clamp-2 font-semibold leading-snug text-foreground">
700
+ {exame.titulo}
701
+ </h3>
702
+ <DropdownMenu>
703
+ <DropdownMenuTrigger asChild>
704
+ <Button
705
+ variant="ghost"
706
+ size="icon"
707
+ className="size-8 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 -mr-2 -mt-1"
708
+ onClick={(e) => e.stopPropagation()}
709
+ aria-label={t('cards.actions.label')}
710
+ >
711
+ <MoreHorizontal className="size-4" />
712
+ </Button>
713
+ </DropdownMenuTrigger>
714
+ <DropdownMenuContent align="end" className="w-52">
715
+ <DropdownMenuItem
716
+ onClick={(e) => {
717
+ e.stopPropagation();
718
+ router.push(`/exames/${exame.id}/questoes`);
719
+ }}
720
+ >
721
+ <ListChecks className="mr-2 size-4" />{' '}
722
+ {t('cards.actions.manageQuestions')}
723
+ </DropdownMenuItem>
724
+ <DropdownMenuItem
725
+ onClick={(e) => {
726
+ e.stopPropagation();
727
+ router.push(`/exames/${exame.id}/tentativa`);
728
+ }}
729
+ >
730
+ <Play className="mr-2 size-4" />{' '}
731
+ {t('cards.actions.testExam')}
732
+ </DropdownMenuItem>
733
+ <DropdownMenuSeparator />
734
+ <DropdownMenuItem
735
+ onClick={(e) => openEditSheet(exame, e)}
736
+ >
737
+ <Pencil className="mr-2 size-4" />{' '}
738
+ {t('cards.actions.edit')}
739
+ </DropdownMenuItem>
740
+ <DropdownMenuSeparator />
741
+ <DropdownMenuItem
742
+ className="text-destructive focus:text-destructive"
743
+ onClick={(e) => {
744
+ e.stopPropagation();
745
+ setExameToDelete(exame);
746
+ setDeleteDialogOpen(true);
747
+ }}
748
+ >
749
+ <Trash2 className="mr-2 size-4" />{' '}
750
+ {t('cards.actions.delete')}
751
+ </DropdownMenuItem>
752
+ </DropdownMenuContent>
753
+ </DropdownMenu>
754
+ </div>
755
+ <p className="text-xs text-muted-foreground">
756
+ <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px]">
757
+ {exame.codigo}
758
+ </code>
759
+ </p>
760
+ </div>
761
+ </div>
762
+
763
+ {/* Badges */}
764
+ <div className="mb-4 flex flex-wrap items-center gap-1.5">
765
+ <Badge
766
+ variant={STATUS_MAP[exame.status]?.variant || 'default'}
767
+ className="text-[11px]"
768
+ >
769
+ {STATUS_MAP[exame.status]?.label}
770
+ </Badge>
771
+ {exame.shuffle && (
772
+ <span className="inline-flex items-center gap-1 rounded-full bg-muted border px-2.5 py-0.5 text-[11px] font-medium text-foreground">
773
+ <Shuffle className="size-3" />{' '}
774
+ {t('cards.randomLabel')}
775
+ </span>
776
+ )}
777
+ </div>
778
+
779
+ {/* Stats grid */}
780
+ <div className="mb-4 grid grid-cols-2 gap-2">
781
+ <div className="rounded-xl border bg-muted/40 p-3 text-center">
782
+ <div className="mb-1 flex items-center justify-center gap-1 text-[11px] text-muted-foreground">
783
+ <FileQuestion className="size-3" />{' '}
784
+ {t('cards.questionsLabel')}
785
+ </div>
786
+ <p className="text-2xl font-bold text-foreground">
787
+ {exame.questoes}
788
+ </p>
789
+ </div>
790
+ <div className="rounded-xl border bg-muted/40 p-3 text-center">
791
+ <div className="mb-1 flex items-center justify-center gap-1 text-[11px] text-muted-foreground">
792
+ <Users className="size-3" />{' '}
793
+ {t('cards.completionsLabel')}
794
+ </div>
795
+ <p className="text-2xl font-bold text-foreground">
796
+ {exame.realizacoes.toLocaleString('pt-BR')}
797
+ </p>
798
+ </div>
799
+ </div>
800
+
801
+ {/* Time and score info */}
802
+ <div className="mb-4 grid grid-cols-2 gap-2">
803
+ <div className="flex items-center gap-2 rounded-lg bg-muted/40 px-3 py-2">
804
+ <Timer className="size-4 text-muted-foreground" />
805
+ <div>
806
+ <p className="text-[11px] text-muted-foreground">
807
+ {t('cards.durationLabel')}
808
+ </p>
809
+ <p className="text-xs font-semibold">
810
+ {formatTempo(exame.limiteTempo)}
811
+ </p>
812
+ </div>
813
+ </div>
814
+ <div className="flex items-center gap-2 rounded-lg bg-muted/40 px-3 py-2">
815
+ <Target className="size-4 text-muted-foreground" />
816
+ <div>
817
+ <p className="text-[11px] text-muted-foreground">
818
+ {t('cards.minScoreLabel')}
819
+ </p>
820
+ <p className="text-xs font-semibold">
821
+ {exame.notaMinima}
822
+ </p>
823
+ </div>
824
+ </div>
825
+ </div>
826
+
827
+ {/* Average score footer */}
828
+ {exame.realizacoes > 0 && (
829
+ <div className="flex items-center justify-between rounded-lg bg-muted/40 px-3 py-2.5 border">
830
+ <span className="text-xs text-muted-foreground">
831
+ {t('cards.avgScoreLabel')}
832
+ </span>
833
+ <span
834
+ className={`text-lg font-bold ${
835
+ exame.mediaNotas >= exame.notaMinima
836
+ ? 'text-foreground'
837
+ : 'text-muted-foreground'
838
+ }`}
839
+ >
840
+ {exame.mediaNotas.toFixed(1)}
841
+ </span>
842
+ </div>
843
+ )}
844
+ </CardContent>
845
+ </Card>
846
+ </motion.div>
847
+ );
848
+ })}
849
+ </motion.div>
850
+ )}
851
+
852
+ {/* Pagination footer */}
853
+ {!loading && filteredExames.length > 0 && (
854
+ <div className="mt-6 flex flex-col items-center justify-between gap-4 sm:flex-row">
855
+ <p className="text-sm text-muted-foreground">
856
+ {filteredExames.length}{' '}
857
+ {filteredExames.length !== 1
858
+ ? t('pagination.examsPlural')
859
+ : t('pagination.exams')}{' '}
860
+ {filteredExames.length !== 1
861
+ ? t('pagination.foundPlural')
862
+ : t('pagination.found')}
863
+ </p>
864
+ <div className="flex items-center gap-1">
865
+ <Button
866
+ variant="outline"
867
+ size="icon"
868
+ className="size-8"
869
+ onClick={() => setCurrentPage(1)}
870
+ disabled={safePage === 1}
871
+ aria-label={t('pagination.firstPage')}
872
+ >
873
+ <ChevronsLeft className="size-4" />
874
+ </Button>
875
+ <Button
876
+ variant="outline"
877
+ size="icon"
878
+ className="size-8"
879
+ onClick={() => setCurrentPage((p) => p - 1)}
880
+ disabled={safePage === 1}
881
+ aria-label={t('pagination.previousPage')}
882
+ >
883
+ <ChevronLeft className="size-4" />
884
+ </Button>
885
+ <span className="px-3 text-sm">
886
+ {t('pagination.page')}{' '}
887
+ <span className="font-semibold">{safePage}</span>{' '}
888
+ {t('pagination.of')}{' '}
889
+ <span className="font-semibold">{totalPages}</span>
890
+ </span>
891
+ <Button
892
+ variant="outline"
893
+ size="icon"
894
+ className="size-8"
895
+ onClick={() => setCurrentPage((p) => p + 1)}
896
+ disabled={safePage === totalPages}
897
+ aria-label={t('pagination.nextPage')}
898
+ >
899
+ <ChevronRight className="size-4" />
900
+ </Button>
901
+ <Button
902
+ variant="outline"
903
+ size="icon"
904
+ className="size-8"
905
+ onClick={() => setCurrentPage(totalPages)}
906
+ disabled={safePage === totalPages}
907
+ aria-label={t('pagination.lastPage')}
908
+ >
909
+ <ChevronsRight className="size-4" />
910
+ </Button>
911
+ </div>
912
+ <div className="flex items-center gap-2 text-sm">
913
+ <span className="text-muted-foreground">
914
+ {t('pagination.itemsPerPage')}
915
+ </span>
916
+ <Select
917
+ value={String(pageSize)}
918
+ onValueChange={(v) => {
919
+ setPageSize(Number(v));
920
+ setCurrentPage(1);
921
+ }}
922
+ >
923
+ <SelectTrigger className="h-8 w-16 text-sm">
924
+ <SelectValue />
925
+ </SelectTrigger>
926
+ <SelectContent>
927
+ {PAGE_SIZES.map((s) => (
928
+ <SelectItem key={s} value={String(s)}>
929
+ {s}
930
+ </SelectItem>
931
+ ))}
932
+ </SelectContent>
933
+ </Select>
934
+ </div>
935
+ </div>
936
+ )}
937
+
938
+ {/* Sheet */}
939
+ <Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
940
+ <SheetContent
941
+ side="right"
942
+ className="flex w-full flex-col sm:max-w-lg overflow-y-auto"
943
+ >
944
+ <SheetHeader className="shrink-0">
945
+ <SheetTitle>
946
+ {editingExame ? t('form.title.edit') : t('form.title.create')}
947
+ </SheetTitle>
948
+ <SheetDescription>
949
+ {editingExame
950
+ ? t('form.description.edit')
951
+ : t('form.description.create')}
952
+ </SheetDescription>
953
+ </SheetHeader>
954
+ <form
955
+ onSubmit={form.handleSubmit(onSubmit)}
956
+ className="flex flex-1 flex-col gap-4 py-6"
957
+ >
958
+ <Field>
959
+ <FieldLabel htmlFor="codigo">
960
+ {t('form.fields.code.label')}{' '}
961
+ <span className="text-destructive">*</span>
962
+ </FieldLabel>
963
+ <Input
964
+ id="codigo"
965
+ placeholder={t('form.fields.code.placeholder')}
966
+ {...form.register('codigo')}
967
+ />
968
+ <FieldError>{form.formState.errors.codigo?.message}</FieldError>
969
+ </Field>
970
+ <Field>
971
+ <FieldLabel htmlFor="titulo">
972
+ {t('form.fields.title.label')}{' '}
973
+ <span className="text-destructive">*</span>
974
+ </FieldLabel>
975
+ <Input
976
+ id="titulo"
977
+ placeholder={t('form.fields.title.placeholder')}
978
+ {...form.register('titulo')}
979
+ />
980
+ <FieldError>{form.formState.errors.titulo?.message}</FieldError>
981
+ </Field>
982
+ <div className="grid grid-cols-2 gap-4">
983
+ <Field>
984
+ <FieldLabel htmlFor="notaMinima">
985
+ {t('form.fields.minScore.label')}{' '}
986
+ <span className="text-destructive">*</span>
987
+ </FieldLabel>
988
+ <Input
989
+ id="notaMinima"
990
+ type="number"
991
+ min={0}
992
+ max={10}
993
+ step={0.5}
994
+ {...form.register('notaMinima')}
995
+ />
996
+ <FieldDescription>
997
+ {t('form.fields.minScore.description')}
998
+ </FieldDescription>
999
+ <FieldError>
1000
+ {form.formState.errors.notaMinima?.message}
1001
+ </FieldError>
1002
+ </Field>
1003
+ <Field>
1004
+ <FieldLabel htmlFor="limiteTempo">
1005
+ {t('form.fields.timeLimit.label')}{' '}
1006
+ <span className="text-destructive">*</span>
1007
+ </FieldLabel>
1008
+ <Input
1009
+ id="limiteTempo"
1010
+ type="number"
1011
+ min={1}
1012
+ {...form.register('limiteTempo')}
1013
+ />
1014
+ <FieldDescription>
1015
+ {t('form.fields.timeLimit.description')}
1016
+ </FieldDescription>
1017
+ <FieldError>
1018
+ {form.formState.errors.limiteTempo?.message}
1019
+ </FieldError>
1020
+ </Field>
1021
+ </div>
1022
+ <Field>
1023
+ <FieldLabel>
1024
+ {t('form.fields.status.label')}{' '}
1025
+ <span className="text-destructive">*</span>
1026
+ </FieldLabel>
1027
+ <Controller
1028
+ name="status"
1029
+ control={form.control}
1030
+ render={({ field }) => (
1031
+ <Select onValueChange={field.onChange} value={field.value}>
1032
+ <SelectTrigger>
1033
+ <SelectValue />
1034
+ </SelectTrigger>
1035
+ <SelectContent>
1036
+ <SelectItem value="rascunho">
1037
+ {t('status.draft')}
1038
+ </SelectItem>
1039
+ <SelectItem value="publicado">
1040
+ {t('status.published')}
1041
+ </SelectItem>
1042
+ <SelectItem value="encerrado">
1043
+ {t('status.closed')}
1044
+ </SelectItem>
1045
+ </SelectContent>
1046
+ </Select>
1047
+ )}
1048
+ />
1049
+ <FieldError>{form.formState.errors.status?.message}</FieldError>
1050
+ </Field>
1051
+ <label className="flex cursor-pointer items-center justify-between rounded-lg border p-3 hover:bg-muted">
1052
+ <div>
1053
+ <p className="text-sm font-medium">
1054
+ {t('form.fields.shuffle.label')}
1055
+ </p>
1056
+ <p className="text-xs text-muted-foreground">
1057
+ {t('form.fields.shuffle.description')}
1058
+ </p>
1059
+ </div>
1060
+ <Controller
1061
+ name="shuffle"
1062
+ control={form.control}
1063
+ render={({ field }) => (
1064
+ <Switch
1065
+ checked={field.value}
1066
+ onCheckedChange={field.onChange}
1067
+ />
1068
+ )}
1069
+ />
1070
+ </label>
1071
+ <SheetFooter className="mt-auto shrink-0 gap-2 pt-4">
1072
+ <Button
1073
+ type="button"
1074
+ variant="outline"
1075
+ onClick={() => setSheetOpen(false)}
1076
+ disabled={saving}
1077
+ >
1078
+ {t('form.actions.cancel')}
1079
+ </Button>
1080
+ <Button type="submit" disabled={saving} className="gap-2">
1081
+ {saving && <Loader2 className="size-4 animate-spin" />}
1082
+ {editingExame
1083
+ ? t('form.actions.save')
1084
+ : t('form.actions.create')}
1085
+ </Button>
1086
+ </SheetFooter>
1087
+ </form>
1088
+ </SheetContent>
1089
+ </Sheet>
1090
+
1091
+ {/* Delete Dialog */}
1092
+ <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
1093
+ <DialogContent>
1094
+ <DialogHeader>
1095
+ <DialogTitle className="flex items-center gap-2">
1096
+ <AlertTriangle className="size-5 text-destructive" />{' '}
1097
+ {t('deleteDialog.title')}
1098
+ </DialogTitle>
1099
+ <DialogDescription>
1100
+ {t('deleteDialog.description')}{' '}
1101
+ <strong>{exameToDelete?.titulo}</strong>?
1102
+ {(exameToDelete?.realizacoes ?? 0) > 0 && (
1103
+ <span className="mt-2 block rounded-md bg-destructive/10 p-2 text-sm text-destructive">
1104
+ {t('deleteDialog.warning', {
1105
+ count: exameToDelete?.realizacoes ?? 0,
1106
+ })}
1107
+ </span>
1108
+ )}
1109
+ </DialogDescription>
1110
+ </DialogHeader>
1111
+ <DialogFooter className="gap-2">
1112
+ <Button
1113
+ variant="outline"
1114
+ onClick={() => setDeleteDialogOpen(false)}
1115
+ >
1116
+ {t('deleteDialog.actions.cancel')}
1117
+ </Button>
1118
+ <Button
1119
+ variant="destructive"
1120
+ onClick={confirmDelete}
1121
+ className="gap-2"
1122
+ >
1123
+ <Trash2 className="size-4" /> {t('deleteDialog.actions.delete')}
1124
+ </Button>
1125
+ </DialogFooter>
1126
+ </DialogContent>
1127
+ </Dialog>
1128
+ </Page>
1129
+ );
1130
+ }