@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.
- package/hedhog/data/menu.yaml +16 -0
- package/hedhog/frontend/app/courses/page.tsx.ejs +220 -176
- package/hedhog/frontend/app/exams/page.tsx.ejs +1130 -0
- package/hedhog/frontend/messages/en.json +382 -1
- package/hedhog/frontend/messages/pt.json +382 -1
- package/hedhog/table/exams.yaml +3 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|