@hed-hog/lms 0.0.266 → 0.0.268
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 +32 -0
- package/hedhog/frontend/app/classes/page.tsx.ejs +1267 -0
- package/hedhog/frontend/app/reports/page.tsx.ejs +910 -0
- package/hedhog/frontend/messages/en.json +310 -0
- package/hedhog/frontend/messages/pt.json +310 -0
- package/hedhog/table/classes.yaml +3 -0
- package/hedhog/table/reports.yaml +3 -0
- package/package.json +4 -4
|
@@ -0,0 +1,1267 @@
|
|
|
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 { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
|
23
|
+
import { Input } from '@/components/ui/input';
|
|
24
|
+
import {
|
|
25
|
+
Select,
|
|
26
|
+
SelectContent,
|
|
27
|
+
SelectItem,
|
|
28
|
+
SelectTrigger,
|
|
29
|
+
SelectValue,
|
|
30
|
+
} from '@/components/ui/select';
|
|
31
|
+
import {
|
|
32
|
+
Sheet,
|
|
33
|
+
SheetContent,
|
|
34
|
+
SheetDescription,
|
|
35
|
+
SheetFooter,
|
|
36
|
+
SheetHeader,
|
|
37
|
+
SheetTitle,
|
|
38
|
+
} from '@/components/ui/sheet';
|
|
39
|
+
import { Skeleton } from '@/components/ui/skeleton';
|
|
40
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
41
|
+
import { motion } from 'framer-motion';
|
|
42
|
+
import {
|
|
43
|
+
AlertTriangle,
|
|
44
|
+
BarChart3,
|
|
45
|
+
Calendar,
|
|
46
|
+
ChevronLeft,
|
|
47
|
+
ChevronRight,
|
|
48
|
+
ChevronsLeft,
|
|
49
|
+
ChevronsRight,
|
|
50
|
+
Clock,
|
|
51
|
+
Eye,
|
|
52
|
+
Laptop,
|
|
53
|
+
Loader2,
|
|
54
|
+
MapPin,
|
|
55
|
+
Monitor,
|
|
56
|
+
MoreHorizontal,
|
|
57
|
+
Pencil,
|
|
58
|
+
Plus,
|
|
59
|
+
Search,
|
|
60
|
+
Trash2,
|
|
61
|
+
Users,
|
|
62
|
+
Users2,
|
|
63
|
+
X,
|
|
64
|
+
} from 'lucide-react';
|
|
65
|
+
import { useTranslations } from 'next-intl';
|
|
66
|
+
import { usePathname, useRouter } from 'next/navigation';
|
|
67
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
68
|
+
import { Controller, useForm } from 'react-hook-form';
|
|
69
|
+
import { toast } from 'sonner';
|
|
70
|
+
import { z } from 'zod';
|
|
71
|
+
|
|
72
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
interface Turma {
|
|
75
|
+
id: number;
|
|
76
|
+
codigo: string;
|
|
77
|
+
curso: string;
|
|
78
|
+
cursoId: number;
|
|
79
|
+
tipo: 'presencial' | 'online' | 'hibrida';
|
|
80
|
+
dataInicio: string;
|
|
81
|
+
dataFim: string;
|
|
82
|
+
horario: string;
|
|
83
|
+
status: 'aberta' | 'em_andamento' | 'concluida' | 'cancelada';
|
|
84
|
+
vagas: number;
|
|
85
|
+
matriculados: number;
|
|
86
|
+
professor: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Schema ────────────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
function getTurmaSchema(t: (key: string) => string) {
|
|
92
|
+
return z.object({
|
|
93
|
+
codigo: z.string().min(3, t('form.validation.codigoMinLength')),
|
|
94
|
+
curso: z.string().min(1, t('form.validation.cursoRequired')),
|
|
95
|
+
tipo: z.string().min(1, t('form.validation.tipoRequired')),
|
|
96
|
+
professor: z.string().min(3, t('form.validation.professorMinLength')),
|
|
97
|
+
vagas: z.coerce.number().min(1, t('form.validation.vagasMin')),
|
|
98
|
+
dataInicio: z.string().min(1, t('form.validation.dataInicioRequired')),
|
|
99
|
+
dataFim: z.string().min(1, t('form.validation.dataFimRequired')),
|
|
100
|
+
horario: z.string().min(1, t('form.validation.horarioRequired')),
|
|
101
|
+
status: z.string().min(1, t('form.validation.statusRequired')),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
type TurmaForm = {
|
|
106
|
+
codigo: string;
|
|
107
|
+
curso: string;
|
|
108
|
+
tipo: string;
|
|
109
|
+
professor: string;
|
|
110
|
+
vagas: number;
|
|
111
|
+
dataInicio: string;
|
|
112
|
+
dataFim: string;
|
|
113
|
+
horario: string;
|
|
114
|
+
status: string;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
const CURSOS = [
|
|
120
|
+
'React Avancado',
|
|
121
|
+
'UX Design Fundamentals',
|
|
122
|
+
'Python para Data Science',
|
|
123
|
+
'Gestao de Projetos Ageis',
|
|
124
|
+
'Node.js Completo',
|
|
125
|
+
'Marketing Digital',
|
|
126
|
+
'TypeScript na Pratica',
|
|
127
|
+
'Design System',
|
|
128
|
+
'Excel para Negocios',
|
|
129
|
+
'Lideranca e Comunicacao',
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
const STATUS_VARIANT: Record<
|
|
133
|
+
string,
|
|
134
|
+
'default' | 'secondary' | 'outline' | 'destructive'
|
|
135
|
+
> = {
|
|
136
|
+
aberta: 'secondary',
|
|
137
|
+
em_andamento: 'default',
|
|
138
|
+
concluida: 'outline',
|
|
139
|
+
cancelada: 'destructive',
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const TIPO_ICON: Record<string, React.FC<{ className?: string }>> = {
|
|
143
|
+
presencial: MapPin,
|
|
144
|
+
online: Monitor,
|
|
145
|
+
hibrida: Laptop,
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const PAGE_SIZES = [6, 12, 24];
|
|
149
|
+
|
|
150
|
+
function formatDate(d: string) {
|
|
151
|
+
const [y, m, day] = d.split('-');
|
|
152
|
+
return `${day}/${m}/${y}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── Seed Data ─────────────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
const initialTurmas: Turma[] = [
|
|
158
|
+
{
|
|
159
|
+
id: 1,
|
|
160
|
+
codigo: 'T-2024-001',
|
|
161
|
+
curso: 'React Avancado',
|
|
162
|
+
cursoId: 1,
|
|
163
|
+
tipo: 'online',
|
|
164
|
+
dataInicio: '2024-02-01',
|
|
165
|
+
dataFim: '2024-04-30',
|
|
166
|
+
horario: '19:00 - 22:00',
|
|
167
|
+
status: 'em_andamento',
|
|
168
|
+
vagas: 30,
|
|
169
|
+
matriculados: 28,
|
|
170
|
+
professor: 'Carlos Silva',
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
id: 2,
|
|
174
|
+
codigo: 'T-2024-002',
|
|
175
|
+
curso: 'UX Design Fundamentals',
|
|
176
|
+
cursoId: 2,
|
|
177
|
+
tipo: 'presencial',
|
|
178
|
+
dataInicio: '2024-03-01',
|
|
179
|
+
dataFim: '2024-05-15',
|
|
180
|
+
horario: '14:00 - 17:00',
|
|
181
|
+
status: 'em_andamento',
|
|
182
|
+
vagas: 25,
|
|
183
|
+
matriculados: 25,
|
|
184
|
+
professor: 'Ana Oliveira',
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
id: 3,
|
|
188
|
+
codigo: 'T-2024-003',
|
|
189
|
+
curso: 'Python para Data Science',
|
|
190
|
+
cursoId: 3,
|
|
191
|
+
tipo: 'online',
|
|
192
|
+
dataInicio: '2024-04-01',
|
|
193
|
+
dataFim: '2024-07-30',
|
|
194
|
+
horario: '19:00 - 21:00',
|
|
195
|
+
status: 'aberta',
|
|
196
|
+
vagas: 35,
|
|
197
|
+
matriculados: 20,
|
|
198
|
+
professor: 'Roberto Santos',
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
id: 4,
|
|
202
|
+
codigo: 'T-2024-004',
|
|
203
|
+
curso: 'Gestao de Projetos Ageis',
|
|
204
|
+
cursoId: 4,
|
|
205
|
+
tipo: 'hibrida',
|
|
206
|
+
dataInicio: '2024-01-15',
|
|
207
|
+
dataFim: '2024-03-15',
|
|
208
|
+
horario: '08:00 - 11:00',
|
|
209
|
+
status: 'concluida',
|
|
210
|
+
vagas: 20,
|
|
211
|
+
matriculados: 18,
|
|
212
|
+
professor: 'Maria Costa',
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
id: 5,
|
|
216
|
+
codigo: 'T-2024-005',
|
|
217
|
+
curso: 'Node.js Completo',
|
|
218
|
+
cursoId: 5,
|
|
219
|
+
tipo: 'online',
|
|
220
|
+
dataInicio: '2024-05-01',
|
|
221
|
+
dataFim: '2024-08-30',
|
|
222
|
+
horario: '19:00 - 22:00',
|
|
223
|
+
status: 'aberta',
|
|
224
|
+
vagas: 30,
|
|
225
|
+
matriculados: 12,
|
|
226
|
+
professor: 'Pedro Almeida',
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
id: 6,
|
|
230
|
+
codigo: 'T-2024-006',
|
|
231
|
+
curso: 'Marketing Digital',
|
|
232
|
+
cursoId: 6,
|
|
233
|
+
tipo: 'presencial',
|
|
234
|
+
dataInicio: '2024-06-01',
|
|
235
|
+
dataFim: '2024-08-30',
|
|
236
|
+
horario: '10:00 - 12:00',
|
|
237
|
+
status: 'cancelada',
|
|
238
|
+
vagas: 40,
|
|
239
|
+
matriculados: 0,
|
|
240
|
+
professor: 'Julia Ferreira',
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
id: 7,
|
|
244
|
+
codigo: 'T-2024-007',
|
|
245
|
+
curso: 'TypeScript na Pratica',
|
|
246
|
+
cursoId: 7,
|
|
247
|
+
tipo: 'online',
|
|
248
|
+
dataInicio: '2024-03-15',
|
|
249
|
+
dataFim: '2024-06-15',
|
|
250
|
+
horario: '19:00 - 21:30',
|
|
251
|
+
status: 'em_andamento',
|
|
252
|
+
vagas: 25,
|
|
253
|
+
matriculados: 22,
|
|
254
|
+
professor: 'Carlos Silva',
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
id: 8,
|
|
258
|
+
codigo: 'T-2024-008',
|
|
259
|
+
curso: 'Design System',
|
|
260
|
+
cursoId: 8,
|
|
261
|
+
tipo: 'hibrida',
|
|
262
|
+
dataInicio: '2024-04-15',
|
|
263
|
+
dataFim: '2024-06-30',
|
|
264
|
+
horario: '14:00 - 16:00',
|
|
265
|
+
status: 'aberta',
|
|
266
|
+
vagas: 20,
|
|
267
|
+
matriculados: 15,
|
|
268
|
+
professor: 'Ana Oliveira',
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
id: 9,
|
|
272
|
+
codigo: 'T-2024-009',
|
|
273
|
+
curso: 'Excel para Negocios',
|
|
274
|
+
cursoId: 9,
|
|
275
|
+
tipo: 'presencial',
|
|
276
|
+
dataInicio: '2024-01-10',
|
|
277
|
+
dataFim: '2024-02-28',
|
|
278
|
+
horario: '08:00 - 10:00',
|
|
279
|
+
status: 'concluida',
|
|
280
|
+
vagas: 50,
|
|
281
|
+
matriculados: 48,
|
|
282
|
+
professor: 'Maria Costa',
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
id: 10,
|
|
286
|
+
codigo: 'T-2024-010',
|
|
287
|
+
curso: 'Lideranca e Comunicacao',
|
|
288
|
+
cursoId: 10,
|
|
289
|
+
tipo: 'presencial',
|
|
290
|
+
dataInicio: '2024-05-15',
|
|
291
|
+
dataFim: '2024-07-15',
|
|
292
|
+
horario: '18:00 - 20:00',
|
|
293
|
+
status: 'aberta',
|
|
294
|
+
vagas: 30,
|
|
295
|
+
matriculados: 8,
|
|
296
|
+
professor: 'Roberto Santos',
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
id: 11,
|
|
300
|
+
codigo: 'T-2024-011',
|
|
301
|
+
curso: 'React Avancado',
|
|
302
|
+
cursoId: 1,
|
|
303
|
+
tipo: 'hibrida',
|
|
304
|
+
dataInicio: '2024-06-01',
|
|
305
|
+
dataFim: '2024-09-30',
|
|
306
|
+
horario: '19:00 - 21:00',
|
|
307
|
+
status: 'aberta',
|
|
308
|
+
vagas: 25,
|
|
309
|
+
matriculados: 5,
|
|
310
|
+
professor: 'Carlos Silva',
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
id: 12,
|
|
314
|
+
codigo: 'T-2024-012',
|
|
315
|
+
curso: 'Python para Data Science',
|
|
316
|
+
cursoId: 3,
|
|
317
|
+
tipo: 'presencial',
|
|
318
|
+
dataInicio: '2024-01-05',
|
|
319
|
+
dataFim: '2024-03-28',
|
|
320
|
+
horario: '14:00 - 17:00',
|
|
321
|
+
status: 'concluida',
|
|
322
|
+
vagas: 30,
|
|
323
|
+
matriculados: 29,
|
|
324
|
+
professor: 'Roberto Santos',
|
|
325
|
+
},
|
|
326
|
+
];
|
|
327
|
+
|
|
328
|
+
// ── Animations ────────────────────────────────────────────────────────────────
|
|
329
|
+
|
|
330
|
+
const fadeUp = {
|
|
331
|
+
hidden: { opacity: 0, y: 16 },
|
|
332
|
+
show: { opacity: 1, y: 0, transition: { duration: 0.3 } },
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const stagger = {
|
|
336
|
+
hidden: {},
|
|
337
|
+
show: { transition: { staggerChildren: 0.05 } },
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
// ── Page ──────────────────────────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
export default function TurmasPage() {
|
|
343
|
+
const t = useTranslations('lms.ClassesPage');
|
|
344
|
+
const pathname = usePathname();
|
|
345
|
+
const router = useRouter();
|
|
346
|
+
|
|
347
|
+
const [loading, setLoading] = useState(true);
|
|
348
|
+
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
|
349
|
+
const [turmas, setTurmas] = useState<Turma[]>(initialTurmas);
|
|
350
|
+
const [sheetOpen, setSheetOpen] = useState(false);
|
|
351
|
+
const [editingTurma, setEditingTurma] = useState<Turma | null>(null);
|
|
352
|
+
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
353
|
+
const [turmaToDelete, setTurmaToDelete] = useState<Turma | null>(null);
|
|
354
|
+
const [saving, setSaving] = useState(false);
|
|
355
|
+
|
|
356
|
+
// Search inputs (uncommitted)
|
|
357
|
+
const [buscaInput, setBuscaInput] = useState('');
|
|
358
|
+
const [filtroStatusInput, setFiltroStatusInput] = useState('todos');
|
|
359
|
+
const [filtroTipoInput, setFiltroTipoInput] = useState('todos');
|
|
360
|
+
const [filtroCursoInput, setFiltroCursoInput] = useState('todos');
|
|
361
|
+
|
|
362
|
+
// Applied filters
|
|
363
|
+
const [buscaApplied, setBuscaApplied] = useState('');
|
|
364
|
+
const [filtroStatusApplied, setFiltroStatusApplied] = useState('todos');
|
|
365
|
+
const [filtroTipoApplied, setFiltroTipoApplied] = useState('todos');
|
|
366
|
+
const [filtroCursoApplied, setFiltroCursoApplied] = useState('todos');
|
|
367
|
+
|
|
368
|
+
// Pagination
|
|
369
|
+
const [currentPage, setCurrentPage] = useState(1);
|
|
370
|
+
const [pageSize, setPageSize] = useState(12);
|
|
371
|
+
|
|
372
|
+
// Double-click tracking
|
|
373
|
+
const clickTimers = useRef<Map<number, ReturnType<typeof setTimeout>>>(
|
|
374
|
+
new Map()
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
const form = useForm<TurmaForm>({
|
|
378
|
+
resolver: zodResolver(getTurmaSchema(t)),
|
|
379
|
+
defaultValues: {
|
|
380
|
+
codigo: '',
|
|
381
|
+
curso: '',
|
|
382
|
+
tipo: 'online',
|
|
383
|
+
professor: '',
|
|
384
|
+
vagas: 30,
|
|
385
|
+
dataInicio: '',
|
|
386
|
+
dataFim: '',
|
|
387
|
+
horario: '',
|
|
388
|
+
status: 'aberta',
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
useEffect(() => {
|
|
393
|
+
const t = setTimeout(() => setLoading(false), 700);
|
|
394
|
+
return () => clearTimeout(t);
|
|
395
|
+
}, []);
|
|
396
|
+
|
|
397
|
+
const uniqueCursos = useMemo(
|
|
398
|
+
() => [...new Set(turmas.map((t) => t.curso))].sort(),
|
|
399
|
+
[turmas]
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
// ── Filtering ────────────────────────────────────────────────────────────
|
|
403
|
+
|
|
404
|
+
const filteredTurmas = useMemo(
|
|
405
|
+
() =>
|
|
406
|
+
turmas.filter((t) => {
|
|
407
|
+
const q = buscaApplied.toLowerCase();
|
|
408
|
+
const matchBusca =
|
|
409
|
+
!q ||
|
|
410
|
+
t.codigo.toLowerCase().includes(q) ||
|
|
411
|
+
t.curso.toLowerCase().includes(q) ||
|
|
412
|
+
t.professor.toLowerCase().includes(q);
|
|
413
|
+
const matchStatus =
|
|
414
|
+
filtroStatusApplied === 'todos' || t.status === filtroStatusApplied;
|
|
415
|
+
const matchTipo =
|
|
416
|
+
filtroTipoApplied === 'todos' || t.tipo === filtroTipoApplied;
|
|
417
|
+
const matchCurso =
|
|
418
|
+
filtroCursoApplied === 'todos' || t.curso === filtroCursoApplied;
|
|
419
|
+
return matchBusca && matchStatus && matchTipo && matchCurso;
|
|
420
|
+
}),
|
|
421
|
+
[
|
|
422
|
+
turmas,
|
|
423
|
+
buscaApplied,
|
|
424
|
+
filtroStatusApplied,
|
|
425
|
+
filtroTipoApplied,
|
|
426
|
+
filtroCursoApplied,
|
|
427
|
+
]
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
const totalPages = Math.max(1, Math.ceil(filteredTurmas.length / pageSize));
|
|
431
|
+
const safePage = Math.min(currentPage, totalPages);
|
|
432
|
+
const paginatedTurmas = filteredTurmas.slice(
|
|
433
|
+
(safePage - 1) * pageSize,
|
|
434
|
+
safePage * pageSize
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
function handleSearch(e: React.FormEvent) {
|
|
438
|
+
e.preventDefault();
|
|
439
|
+
setBuscaApplied(buscaInput);
|
|
440
|
+
setFiltroStatusApplied(filtroStatusInput);
|
|
441
|
+
setFiltroTipoApplied(filtroTipoInput);
|
|
442
|
+
setFiltroCursoApplied(filtroCursoInput);
|
|
443
|
+
setCurrentPage(1);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function clearFilters() {
|
|
447
|
+
setBuscaInput('');
|
|
448
|
+
setFiltroStatusInput('todos');
|
|
449
|
+
setFiltroTipoInput('todos');
|
|
450
|
+
setFiltroCursoInput('todos');
|
|
451
|
+
setBuscaApplied('');
|
|
452
|
+
setFiltroStatusApplied('todos');
|
|
453
|
+
setFiltroTipoApplied('todos');
|
|
454
|
+
setFiltroCursoApplied('todos');
|
|
455
|
+
setCurrentPage(1);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const hasActiveFilters =
|
|
459
|
+
buscaApplied ||
|
|
460
|
+
filtroStatusApplied !== 'todos' ||
|
|
461
|
+
filtroTipoApplied !== 'todos' ||
|
|
462
|
+
filtroCursoApplied !== 'todos';
|
|
463
|
+
|
|
464
|
+
// ── Double-click ──────────────────────────────────────────────────────────
|
|
465
|
+
|
|
466
|
+
function handleCardClick(turma: Turma) {
|
|
467
|
+
const existing = clickTimers.current.get(turma.id);
|
|
468
|
+
if (existing) {
|
|
469
|
+
clearTimeout(existing);
|
|
470
|
+
clickTimers.current.delete(turma.id);
|
|
471
|
+
router.push(`/turmas/${turma.id}`);
|
|
472
|
+
} else {
|
|
473
|
+
const t = setTimeout(() => clickTimers.current.delete(turma.id), 300);
|
|
474
|
+
clickTimers.current.set(turma.id, t);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ── CRUD ──────────────────────────────────────────────────────────────────
|
|
479
|
+
|
|
480
|
+
function openCreateSheet() {
|
|
481
|
+
setEditingTurma(null);
|
|
482
|
+
form.reset({
|
|
483
|
+
codigo: '',
|
|
484
|
+
curso: '',
|
|
485
|
+
tipo: 'online',
|
|
486
|
+
professor: '',
|
|
487
|
+
vagas: 30,
|
|
488
|
+
dataInicio: '',
|
|
489
|
+
dataFim: '',
|
|
490
|
+
horario: '',
|
|
491
|
+
status: 'aberta',
|
|
492
|
+
});
|
|
493
|
+
setSheetOpen(true);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function openEditSheet(turma: Turma, e?: React.MouseEvent) {
|
|
497
|
+
e?.stopPropagation();
|
|
498
|
+
setEditingTurma(turma);
|
|
499
|
+
form.reset({
|
|
500
|
+
codigo: turma.codigo,
|
|
501
|
+
curso: turma.curso,
|
|
502
|
+
tipo: turma.tipo,
|
|
503
|
+
professor: turma.professor,
|
|
504
|
+
vagas: turma.vagas,
|
|
505
|
+
dataInicio: turma.dataInicio,
|
|
506
|
+
dataFim: turma.dataFim,
|
|
507
|
+
horario: turma.horario,
|
|
508
|
+
status: turma.status,
|
|
509
|
+
});
|
|
510
|
+
setSheetOpen(true);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async function onSubmit(data: TurmaForm) {
|
|
514
|
+
setSaving(true);
|
|
515
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
516
|
+
if (editingTurma) {
|
|
517
|
+
setTurmas((prev) =>
|
|
518
|
+
prev.map((t) =>
|
|
519
|
+
t.id === editingTurma.id
|
|
520
|
+
? {
|
|
521
|
+
...t,
|
|
522
|
+
...data,
|
|
523
|
+
tipo: data.tipo as Turma['tipo'],
|
|
524
|
+
status: data.status as Turma['status'],
|
|
525
|
+
}
|
|
526
|
+
: t
|
|
527
|
+
)
|
|
528
|
+
);
|
|
529
|
+
toast.success(t('toasts.turmaUpdated'));
|
|
530
|
+
} else {
|
|
531
|
+
const newTurma: Turma = {
|
|
532
|
+
id: Date.now(),
|
|
533
|
+
...data,
|
|
534
|
+
cursoId: CURSOS.indexOf(data.curso) + 1,
|
|
535
|
+
tipo: data.tipo as Turma['tipo'],
|
|
536
|
+
status: data.status as Turma['status'],
|
|
537
|
+
matriculados: 0,
|
|
538
|
+
};
|
|
539
|
+
setTurmas((prev) => [newTurma, ...prev]);
|
|
540
|
+
toast.success(t('toasts.turmaCreated'));
|
|
541
|
+
setSaving(false);
|
|
542
|
+
setSheetOpen(false);
|
|
543
|
+
setTimeout(() => router.push(`/turmas/${newTurma.id}`), 400);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
setSaving(false);
|
|
547
|
+
setSheetOpen(false);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function confirmDelete() {
|
|
551
|
+
if (!turmaToDelete) return;
|
|
552
|
+
setTurmas((prev) => prev.filter((t) => t.id !== turmaToDelete.id));
|
|
553
|
+
toast.success(t('toasts.turmaRemoved'));
|
|
554
|
+
setTurmaToDelete(null);
|
|
555
|
+
setDeleteDialogOpen(false);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// ── KPIs ────────────────────────────────────────────────���─────────────────
|
|
559
|
+
|
|
560
|
+
const kpis = [
|
|
561
|
+
{
|
|
562
|
+
label: t('kpis.totalClasses.label'),
|
|
563
|
+
valor: turmas.length,
|
|
564
|
+
sub: t('kpis.totalClasses.sub'),
|
|
565
|
+
icon: Users2,
|
|
566
|
+
iconBg: 'bg-orange-100',
|
|
567
|
+
iconColor: 'text-orange-600',
|
|
568
|
+
},
|
|
569
|
+
{
|
|
570
|
+
label: t('kpis.inProgress.label'),
|
|
571
|
+
valor: turmas.filter((t) => t.status === 'em_andamento').length,
|
|
572
|
+
sub: t('kpis.inProgress.sub'),
|
|
573
|
+
icon: Clock,
|
|
574
|
+
iconBg: 'bg-muted',
|
|
575
|
+
iconColor: 'text-foreground',
|
|
576
|
+
},
|
|
577
|
+
{
|
|
578
|
+
label: t('kpis.openVacancies.label'),
|
|
579
|
+
valor: turmas
|
|
580
|
+
.filter((t) => t.status === 'aberta')
|
|
581
|
+
.reduce((a, t) => a + (t.vagas - t.matriculados), 0),
|
|
582
|
+
sub: t('kpis.openVacancies.sub'),
|
|
583
|
+
icon: Users,
|
|
584
|
+
iconBg: 'bg-muted',
|
|
585
|
+
iconColor: 'text-foreground',
|
|
586
|
+
},
|
|
587
|
+
{
|
|
588
|
+
label: t('kpis.occupancyRate.label'),
|
|
589
|
+
valor: `${Math.round((turmas.reduce((a, t) => a + t.matriculados / Math.max(t.vagas, 1), 0) / Math.max(turmas.length, 1)) * 100)}%`,
|
|
590
|
+
sub: t('kpis.occupancyRate.sub'),
|
|
591
|
+
icon: BarChart3,
|
|
592
|
+
iconBg: 'bg-muted',
|
|
593
|
+
iconColor: 'text-foreground',
|
|
594
|
+
},
|
|
595
|
+
];
|
|
596
|
+
|
|
597
|
+
// ── Render ────────────────────────────────────────────────────────────────
|
|
598
|
+
|
|
599
|
+
return (
|
|
600
|
+
<Page>
|
|
601
|
+
<PageHeader
|
|
602
|
+
title={t('title')}
|
|
603
|
+
description={t('description')}
|
|
604
|
+
breadcrumbs={[
|
|
605
|
+
{
|
|
606
|
+
label: t('breadcrumbs.home'),
|
|
607
|
+
href: '/',
|
|
608
|
+
},
|
|
609
|
+
{
|
|
610
|
+
label: t('breadcrumbs.classes'),
|
|
611
|
+
},
|
|
612
|
+
]}
|
|
613
|
+
actions={
|
|
614
|
+
<Button onClick={openCreateSheet} className="gap-2">
|
|
615
|
+
<Plus className="size-4" />
|
|
616
|
+
{t('actions.createClass')}
|
|
617
|
+
</Button>
|
|
618
|
+
}
|
|
619
|
+
/>
|
|
620
|
+
|
|
621
|
+
{/* KPIs */}
|
|
622
|
+
<div className="mb-6 grid grid-cols-2 gap-4 lg:grid-cols-4">
|
|
623
|
+
{loading
|
|
624
|
+
? Array.from({ length: 4 }).map((_, i) => (
|
|
625
|
+
<Card key={i}>
|
|
626
|
+
<CardContent className="p-4">
|
|
627
|
+
<Skeleton className="mb-2 h-8 w-16" />
|
|
628
|
+
<Skeleton className="h-4 w-28" />
|
|
629
|
+
</CardContent>
|
|
630
|
+
</Card>
|
|
631
|
+
))
|
|
632
|
+
: kpis.map((kpi, i) => (
|
|
633
|
+
<motion.div
|
|
634
|
+
key={kpi.label}
|
|
635
|
+
initial={{ opacity: 0, y: 12 }}
|
|
636
|
+
animate={{ opacity: 1, y: 0 }}
|
|
637
|
+
transition={{ delay: i * 0.07 }}
|
|
638
|
+
>
|
|
639
|
+
<Card className="overflow-hidden">
|
|
640
|
+
<CardContent className="flex items-start justify-between p-5">
|
|
641
|
+
<div>
|
|
642
|
+
<p className="text-sm text-muted-foreground">
|
|
643
|
+
{kpi.label}
|
|
644
|
+
</p>
|
|
645
|
+
<p className="mt-1 text-3xl font-bold tracking-tight">
|
|
646
|
+
{kpi.valor}
|
|
647
|
+
</p>
|
|
648
|
+
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
649
|
+
{kpi.sub}
|
|
650
|
+
</p>
|
|
651
|
+
</div>
|
|
652
|
+
<div
|
|
653
|
+
className={`flex size-10 shrink-0 items-center justify-center rounded-lg ${kpi.iconBg}`}
|
|
654
|
+
>
|
|
655
|
+
<kpi.icon className={`size-5 ${kpi.iconColor}`} />
|
|
656
|
+
</div>
|
|
657
|
+
</CardContent>
|
|
658
|
+
</Card>
|
|
659
|
+
</motion.div>
|
|
660
|
+
))}
|
|
661
|
+
</div>
|
|
662
|
+
|
|
663
|
+
{/* Search bar */}
|
|
664
|
+
<form onSubmit={handleSearch} className="mb-6">
|
|
665
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
|
666
|
+
<div className="relative flex-1">
|
|
667
|
+
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
668
|
+
<Input
|
|
669
|
+
placeholder={t('filters.searchPlaceholder')}
|
|
670
|
+
value={buscaInput}
|
|
671
|
+
onChange={(e) => setBuscaInput(e.target.value)}
|
|
672
|
+
className="pl-9"
|
|
673
|
+
/>
|
|
674
|
+
</div>
|
|
675
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
676
|
+
<Select
|
|
677
|
+
value={filtroStatusInput}
|
|
678
|
+
onValueChange={setFiltroStatusInput}
|
|
679
|
+
>
|
|
680
|
+
<SelectTrigger className="h-9 w-[140px] text-sm">
|
|
681
|
+
<SelectValue placeholder={t('filters.status')} />
|
|
682
|
+
</SelectTrigger>
|
|
683
|
+
<SelectContent>
|
|
684
|
+
<SelectItem value="todos">
|
|
685
|
+
{t('filters.allStatuses')}
|
|
686
|
+
</SelectItem>
|
|
687
|
+
<SelectItem value="aberta">{t('status.open')}</SelectItem>
|
|
688
|
+
<SelectItem value="em_andamento">
|
|
689
|
+
{t('status.inProgress')}
|
|
690
|
+
</SelectItem>
|
|
691
|
+
<SelectItem value="concluida">
|
|
692
|
+
{t('status.completed')}
|
|
693
|
+
</SelectItem>
|
|
694
|
+
<SelectItem value="cancelada">
|
|
695
|
+
{t('status.cancelled')}
|
|
696
|
+
</SelectItem>
|
|
697
|
+
</SelectContent>
|
|
698
|
+
</Select>
|
|
699
|
+
<Select value={filtroTipoInput} onValueChange={setFiltroTipoInput}>
|
|
700
|
+
<SelectTrigger className="h-9 w-[120px] text-sm">
|
|
701
|
+
<SelectValue placeholder={t('filters.type')} />
|
|
702
|
+
</SelectTrigger>
|
|
703
|
+
<SelectContent>
|
|
704
|
+
<SelectItem value="todos">{t('filters.allTypes')}</SelectItem>
|
|
705
|
+
<SelectItem value="presencial">{t('type.inPerson')}</SelectItem>
|
|
706
|
+
<SelectItem value="online">{t('type.online')}</SelectItem>
|
|
707
|
+
<SelectItem value="hibrida">{t('type.hybrid')}</SelectItem>
|
|
708
|
+
</SelectContent>
|
|
709
|
+
</Select>
|
|
710
|
+
<Select
|
|
711
|
+
value={filtroCursoInput}
|
|
712
|
+
onValueChange={setFiltroCursoInput}
|
|
713
|
+
>
|
|
714
|
+
<SelectTrigger className="h-9 w-[160px] text-sm">
|
|
715
|
+
<SelectValue placeholder={t('filters.course')} />
|
|
716
|
+
</SelectTrigger>
|
|
717
|
+
<SelectContent>
|
|
718
|
+
<SelectItem value="todos">{t('filters.allCourses')}</SelectItem>
|
|
719
|
+
{uniqueCursos.map((c) => (
|
|
720
|
+
<SelectItem key={c} value={c}>
|
|
721
|
+
{c}
|
|
722
|
+
</SelectItem>
|
|
723
|
+
))}
|
|
724
|
+
</SelectContent>
|
|
725
|
+
</Select>
|
|
726
|
+
{hasActiveFilters && (
|
|
727
|
+
<Button
|
|
728
|
+
type="button"
|
|
729
|
+
variant="ghost"
|
|
730
|
+
size="sm"
|
|
731
|
+
onClick={clearFilters}
|
|
732
|
+
className="h-9 text-muted-foreground"
|
|
733
|
+
>
|
|
734
|
+
<X className="mr-1 size-3.5" /> {t('filters.clear')}
|
|
735
|
+
</Button>
|
|
736
|
+
)}
|
|
737
|
+
<Button type="submit" size="sm" className="h-9 gap-2">
|
|
738
|
+
<Search className="size-3.5" /> {t('filters.search')}
|
|
739
|
+
</Button>
|
|
740
|
+
</div>
|
|
741
|
+
</div>
|
|
742
|
+
</form>
|
|
743
|
+
|
|
744
|
+
{/* Cards grid */}
|
|
745
|
+
{loading ? (
|
|
746
|
+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
747
|
+
{Array.from({ length: 6 }).map((_, i) => (
|
|
748
|
+
<Card key={i} className="overflow-hidden">
|
|
749
|
+
<CardContent className="p-5">
|
|
750
|
+
<Skeleton className="mb-3 h-5 w-3/4" />
|
|
751
|
+
<Skeleton className="mb-2 h-4 w-1/2" />
|
|
752
|
+
<Skeleton className="mb-4 h-2 w-full rounded-full" />
|
|
753
|
+
<div className="flex gap-2">
|
|
754
|
+
<Skeleton className="h-6 w-20 rounded-full" />
|
|
755
|
+
<Skeleton className="h-6 w-16 rounded-full" />
|
|
756
|
+
</div>
|
|
757
|
+
</CardContent>
|
|
758
|
+
</Card>
|
|
759
|
+
))}
|
|
760
|
+
</div>
|
|
761
|
+
) : filteredTurmas.length === 0 ? (
|
|
762
|
+
<div className="flex flex-col items-center justify-center py-20 text-center">
|
|
763
|
+
<Users className="mb-4 size-12 text-muted-foreground/40" />
|
|
764
|
+
<p className="text-lg font-medium">{t('empty.title')}</p>
|
|
765
|
+
<p className="mt-1 text-sm text-muted-foreground">
|
|
766
|
+
{t('empty.description')}
|
|
767
|
+
</p>
|
|
768
|
+
<Button className="mt-6 gap-2" onClick={openCreateSheet}>
|
|
769
|
+
<Plus className="size-4" />
|
|
770
|
+
{t('empty.action')}
|
|
771
|
+
</Button>
|
|
772
|
+
</div>
|
|
773
|
+
) : (
|
|
774
|
+
<motion.div
|
|
775
|
+
className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"
|
|
776
|
+
variants={stagger}
|
|
777
|
+
initial="hidden"
|
|
778
|
+
animate="show"
|
|
779
|
+
>
|
|
780
|
+
{paginatedTurmas.map((turma) => {
|
|
781
|
+
const ocupacao = Math.round(
|
|
782
|
+
(turma.matriculados / Math.max(turma.vagas, 1)) * 100
|
|
783
|
+
);
|
|
784
|
+
const TipoIcon = (TIPO_ICON[turma.tipo] || Monitor) as React.FC<{
|
|
785
|
+
className?: string;
|
|
786
|
+
}>;
|
|
787
|
+
|
|
788
|
+
return (
|
|
789
|
+
<motion.div key={turma.id} variants={fadeUp}>
|
|
790
|
+
<Card
|
|
791
|
+
className="group relative cursor-pointer overflow-hidden border-0 shadow-sm transition-all duration-300 hover:shadow-lg hover:-translate-y-1"
|
|
792
|
+
onClick={() => handleCardClick(turma)}
|
|
793
|
+
title={t('cards.tooltip')}
|
|
794
|
+
>
|
|
795
|
+
{/* Top accent */}
|
|
796
|
+
<div className="h-1 w-full bg-foreground" />
|
|
797
|
+
|
|
798
|
+
<CardContent className="p-5">
|
|
799
|
+
{/* Header with Icon + Title + Actions */}
|
|
800
|
+
<div className="mb-4 flex items-start gap-3">
|
|
801
|
+
{/* Type icon */}
|
|
802
|
+
<div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-muted border">
|
|
803
|
+
<TipoIcon className="size-6 text-foreground" />
|
|
804
|
+
</div>
|
|
805
|
+
<div className="min-w-0 flex-1">
|
|
806
|
+
<div className="mb-1 flex items-start justify-between gap-2">
|
|
807
|
+
<h3 className="line-clamp-2 font-semibold leading-snug text-foreground">
|
|
808
|
+
{turma.curso}
|
|
809
|
+
</h3>
|
|
810
|
+
<DropdownMenu>
|
|
811
|
+
<DropdownMenuTrigger asChild>
|
|
812
|
+
<Button
|
|
813
|
+
variant="ghost"
|
|
814
|
+
size="icon"
|
|
815
|
+
className="size-8 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 -mr-2 -mt-1"
|
|
816
|
+
onClick={(e) => e.stopPropagation()}
|
|
817
|
+
aria-label={t('cards.actions.label')}
|
|
818
|
+
>
|
|
819
|
+
<MoreHorizontal className="size-4" />
|
|
820
|
+
</Button>
|
|
821
|
+
</DropdownMenuTrigger>
|
|
822
|
+
<DropdownMenuContent align="end" className="w-48">
|
|
823
|
+
<DropdownMenuItem
|
|
824
|
+
onClick={(e) => {
|
|
825
|
+
e.stopPropagation();
|
|
826
|
+
router.push(`/turmas/${turma.id}`);
|
|
827
|
+
}}
|
|
828
|
+
>
|
|
829
|
+
<Eye className="mr-2 size-4" />{' '}
|
|
830
|
+
{t('cards.actions.viewDetails')}
|
|
831
|
+
</DropdownMenuItem>
|
|
832
|
+
<DropdownMenuItem
|
|
833
|
+
onClick={(e) => openEditSheet(turma, e)}
|
|
834
|
+
>
|
|
835
|
+
<Pencil className="mr-2 size-4" />{' '}
|
|
836
|
+
{t('cards.actions.edit')}
|
|
837
|
+
</DropdownMenuItem>
|
|
838
|
+
<DropdownMenuSeparator />
|
|
839
|
+
<DropdownMenuItem
|
|
840
|
+
className="text-destructive focus:text-destructive"
|
|
841
|
+
onClick={(e) => {
|
|
842
|
+
e.stopPropagation();
|
|
843
|
+
setTurmaToDelete(turma);
|
|
844
|
+
setDeleteDialogOpen(true);
|
|
845
|
+
}}
|
|
846
|
+
>
|
|
847
|
+
<Trash2 className="mr-2 size-4" />{' '}
|
|
848
|
+
{t('cards.actions.delete')}
|
|
849
|
+
</DropdownMenuItem>
|
|
850
|
+
</DropdownMenuContent>
|
|
851
|
+
</DropdownMenu>
|
|
852
|
+
</div>
|
|
853
|
+
<p className="text-xs text-muted-foreground">
|
|
854
|
+
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px]">
|
|
855
|
+
{turma.codigo}
|
|
856
|
+
</code>
|
|
857
|
+
<span className="mx-1.5 text-muted-foreground/50">
|
|
858
|
+
|
|
|
859
|
+
</span>
|
|
860
|
+
<span>{turma.professor}</span>
|
|
861
|
+
</p>
|
|
862
|
+
</div>
|
|
863
|
+
</div>
|
|
864
|
+
|
|
865
|
+
{/* Badges */}
|
|
866
|
+
<div className="mb-4 flex flex-wrap items-center gap-1.5">
|
|
867
|
+
<Badge
|
|
868
|
+
variant={STATUS_VARIANT[turma.status]}
|
|
869
|
+
className="text-[11px]"
|
|
870
|
+
>
|
|
871
|
+
{t(`status.${turma.status}`)}
|
|
872
|
+
</Badge>
|
|
873
|
+
<span className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[11px] font-medium border bg-muted text-foreground">
|
|
874
|
+
<TipoIcon className="size-3" />
|
|
875
|
+
{t(`type.${turma.tipo}`)}
|
|
876
|
+
</span>
|
|
877
|
+
</div>
|
|
878
|
+
|
|
879
|
+
{/* Occupancy progress */}
|
|
880
|
+
<div className="mb-4 rounded-lg bg-muted/40 p-3">
|
|
881
|
+
<div className="mb-2 flex items-center justify-between text-sm">
|
|
882
|
+
<span className="text-muted-foreground">
|
|
883
|
+
{t('cards.occupancy')}
|
|
884
|
+
</span>
|
|
885
|
+
<span className="font-semibold">
|
|
886
|
+
{turma.matriculados}/{turma.vagas}
|
|
887
|
+
</span>
|
|
888
|
+
</div>
|
|
889
|
+
<div className="relative h-2 w-full overflow-hidden rounded-full bg-muted">
|
|
890
|
+
<div
|
|
891
|
+
className="h-full rounded-full transition-all duration-500 bg-foreground"
|
|
892
|
+
style={{ width: `${Math.min(ocupacao, 100)}%` }}
|
|
893
|
+
/>
|
|
894
|
+
</div>
|
|
895
|
+
<div className="mt-1.5 flex justify-between text-[11px]">
|
|
896
|
+
<span className="text-muted-foreground">
|
|
897
|
+
{ocupacao}% {t('cards.occupied')}
|
|
898
|
+
</span>
|
|
899
|
+
<span className="font-medium text-foreground">
|
|
900
|
+
{Math.max(turma.vagas - turma.matriculados, 0)}{' '}
|
|
901
|
+
{t('cards.freeVacancies')}
|
|
902
|
+
</span>
|
|
903
|
+
</div>
|
|
904
|
+
</div>
|
|
905
|
+
|
|
906
|
+
{/* Date & time cards */}
|
|
907
|
+
<div className="mb-4 grid grid-cols-2 gap-2">
|
|
908
|
+
<div className="rounded-lg border bg-background p-2.5">
|
|
909
|
+
<div className="mb-1 flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
|
910
|
+
<Calendar className="size-3" /> {t('cards.period')}
|
|
911
|
+
</div>
|
|
912
|
+
<p className="text-xs font-medium">
|
|
913
|
+
{formatDate(turma.dataInicio)}
|
|
914
|
+
</p>
|
|
915
|
+
<p className="text-[11px] text-muted-foreground">
|
|
916
|
+
{t('cards.until')} {formatDate(turma.dataFim)}
|
|
917
|
+
</p>
|
|
918
|
+
</div>
|
|
919
|
+
<div className="rounded-lg border bg-background p-2.5">
|
|
920
|
+
<div className="mb-1 flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
|
921
|
+
<Clock className="size-3" /> {t('cards.schedule')}
|
|
922
|
+
</div>
|
|
923
|
+
<p className="text-xs font-medium">{turma.horario}</p>
|
|
924
|
+
</div>
|
|
925
|
+
</div>
|
|
926
|
+
|
|
927
|
+
{/* Footer stats */}
|
|
928
|
+
<div className="flex items-center justify-between rounded-lg bg-muted/40 px-3 py-2.5">
|
|
929
|
+
<div className="flex items-center gap-1.5">
|
|
930
|
+
<Users className="size-4 text-muted-foreground" />
|
|
931
|
+
<span className="text-sm font-medium">
|
|
932
|
+
{turma.matriculados}
|
|
933
|
+
</span>
|
|
934
|
+
<span className="text-xs text-muted-foreground">
|
|
935
|
+
{t('cards.enrolled')}
|
|
936
|
+
</span>
|
|
937
|
+
</div>
|
|
938
|
+
</div>
|
|
939
|
+
</CardContent>
|
|
940
|
+
</Card>
|
|
941
|
+
</motion.div>
|
|
942
|
+
);
|
|
943
|
+
})}
|
|
944
|
+
</motion.div>
|
|
945
|
+
)}
|
|
946
|
+
|
|
947
|
+
{/* Pagination footer */}
|
|
948
|
+
{!loading && filteredTurmas.length > 0 && (
|
|
949
|
+
<div className="mt-6 flex flex-col items-center justify-between gap-4 sm:flex-row">
|
|
950
|
+
<p className="text-sm text-muted-foreground">
|
|
951
|
+
{filteredTurmas.length}{' '}
|
|
952
|
+
{filteredTurmas.length === 1
|
|
953
|
+
? t('pagination.class')
|
|
954
|
+
: t('pagination.classes')}{' '}
|
|
955
|
+
{filteredTurmas.length === 1
|
|
956
|
+
? t('pagination.found')
|
|
957
|
+
: t('pagination.foundPlural')}
|
|
958
|
+
</p>
|
|
959
|
+
<div className="flex items-center gap-1">
|
|
960
|
+
<Button
|
|
961
|
+
variant="outline"
|
|
962
|
+
size="icon"
|
|
963
|
+
className="size-8"
|
|
964
|
+
onClick={() => setCurrentPage(1)}
|
|
965
|
+
disabled={safePage === 1}
|
|
966
|
+
aria-label={t('pagination.firstPage')}
|
|
967
|
+
>
|
|
968
|
+
<ChevronsLeft className="size-4" />
|
|
969
|
+
</Button>
|
|
970
|
+
<Button
|
|
971
|
+
variant="outline"
|
|
972
|
+
size="icon"
|
|
973
|
+
className="size-8"
|
|
974
|
+
onClick={() => setCurrentPage((p) => p - 1)}
|
|
975
|
+
disabled={safePage === 1}
|
|
976
|
+
aria-label={t('pagination.previousPage')}
|
|
977
|
+
>
|
|
978
|
+
<ChevronLeft className="size-4" />
|
|
979
|
+
</Button>
|
|
980
|
+
<span className="px-3 text-sm">
|
|
981
|
+
{t('pagination.page')}{' '}
|
|
982
|
+
<span className="font-semibold">{safePage}</span>{' '}
|
|
983
|
+
{t('pagination.of')}{' '}
|
|
984
|
+
<span className="font-semibold">{totalPages}</span>
|
|
985
|
+
</span>
|
|
986
|
+
<Button
|
|
987
|
+
variant="outline"
|
|
988
|
+
size="icon"
|
|
989
|
+
className="size-8"
|
|
990
|
+
onClick={() => setCurrentPage((p) => p + 1)}
|
|
991
|
+
disabled={safePage === totalPages}
|
|
992
|
+
aria-label={t('pagination.nextPage')}
|
|
993
|
+
>
|
|
994
|
+
<ChevronRight className="size-4" />
|
|
995
|
+
</Button>
|
|
996
|
+
<Button
|
|
997
|
+
variant="outline"
|
|
998
|
+
size="icon"
|
|
999
|
+
className="size-8"
|
|
1000
|
+
onClick={() => setCurrentPage(totalPages)}
|
|
1001
|
+
disabled={safePage === totalPages}
|
|
1002
|
+
aria-label={t('pagination.lastPage')}
|
|
1003
|
+
>
|
|
1004
|
+
<ChevronsRight className="size-4" />
|
|
1005
|
+
</Button>
|
|
1006
|
+
</div>
|
|
1007
|
+
<div className="flex items-center gap-2 text-sm">
|
|
1008
|
+
<span className="text-muted-foreground">
|
|
1009
|
+
{t('pagination.itemsPerPage')}
|
|
1010
|
+
</span>
|
|
1011
|
+
<Select
|
|
1012
|
+
value={String(pageSize)}
|
|
1013
|
+
onValueChange={(v) => {
|
|
1014
|
+
setPageSize(Number(v));
|
|
1015
|
+
setCurrentPage(1);
|
|
1016
|
+
}}
|
|
1017
|
+
>
|
|
1018
|
+
<SelectTrigger className="h-8 w-16 text-sm">
|
|
1019
|
+
<SelectValue />
|
|
1020
|
+
</SelectTrigger>
|
|
1021
|
+
<SelectContent>
|
|
1022
|
+
{PAGE_SIZES.map((s) => (
|
|
1023
|
+
<SelectItem key={s} value={String(s)}>
|
|
1024
|
+
{s}
|
|
1025
|
+
</SelectItem>
|
|
1026
|
+
))}
|
|
1027
|
+
</SelectContent>
|
|
1028
|
+
</Select>
|
|
1029
|
+
</div>
|
|
1030
|
+
</div>
|
|
1031
|
+
)}
|
|
1032
|
+
|
|
1033
|
+
{/* Sheet */}
|
|
1034
|
+
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
|
1035
|
+
<SheetContent
|
|
1036
|
+
side="right"
|
|
1037
|
+
className="flex w-full flex-col sm:max-w-lg overflow-y-auto"
|
|
1038
|
+
>
|
|
1039
|
+
<SheetHeader className="shrink-0">
|
|
1040
|
+
<SheetTitle>
|
|
1041
|
+
{editingTurma ? t('form.title.edit') : t('form.title.create')}
|
|
1042
|
+
</SheetTitle>
|
|
1043
|
+
<SheetDescription>{t('form.description')}</SheetDescription>
|
|
1044
|
+
</SheetHeader>
|
|
1045
|
+
<form
|
|
1046
|
+
onSubmit={form.handleSubmit(onSubmit)}
|
|
1047
|
+
className="flex flex-1 flex-col px-4 gap-4 py-6"
|
|
1048
|
+
>
|
|
1049
|
+
<Field>
|
|
1050
|
+
<FieldLabel htmlFor="codigo">
|
|
1051
|
+
{t('form.fields.code.label')}{' '}
|
|
1052
|
+
<span className="text-destructive">*</span>
|
|
1053
|
+
</FieldLabel>
|
|
1054
|
+
<Input
|
|
1055
|
+
id="codigo"
|
|
1056
|
+
placeholder={t('form.fields.code.placeholder')}
|
|
1057
|
+
{...form.register('codigo')}
|
|
1058
|
+
/>
|
|
1059
|
+
<FieldError>{form.formState.errors.codigo?.message}</FieldError>
|
|
1060
|
+
</Field>
|
|
1061
|
+
<Field>
|
|
1062
|
+
<FieldLabel>
|
|
1063
|
+
{t('form.fields.course.label')}{' '}
|
|
1064
|
+
<span className="text-destructive">*</span>
|
|
1065
|
+
</FieldLabel>
|
|
1066
|
+
<Controller
|
|
1067
|
+
name="curso"
|
|
1068
|
+
control={form.control}
|
|
1069
|
+
render={({ field }) => (
|
|
1070
|
+
<Select onValueChange={field.onChange} value={field.value}>
|
|
1071
|
+
<SelectTrigger>
|
|
1072
|
+
<SelectValue
|
|
1073
|
+
placeholder={t('form.fields.course.placeholder')}
|
|
1074
|
+
/>
|
|
1075
|
+
</SelectTrigger>
|
|
1076
|
+
<SelectContent>
|
|
1077
|
+
{CURSOS.map((c) => (
|
|
1078
|
+
<SelectItem key={c} value={c}>
|
|
1079
|
+
{c}
|
|
1080
|
+
</SelectItem>
|
|
1081
|
+
))}
|
|
1082
|
+
</SelectContent>
|
|
1083
|
+
</Select>
|
|
1084
|
+
)}
|
|
1085
|
+
/>
|
|
1086
|
+
<FieldError>{form.formState.errors.curso?.message}</FieldError>
|
|
1087
|
+
</Field>
|
|
1088
|
+
<div className="grid grid-cols-2 gap-4">
|
|
1089
|
+
<Field>
|
|
1090
|
+
<FieldLabel>
|
|
1091
|
+
{t('form.fields.type.label')}{' '}
|
|
1092
|
+
<span className="text-destructive">*</span>
|
|
1093
|
+
</FieldLabel>
|
|
1094
|
+
<Controller
|
|
1095
|
+
name="tipo"
|
|
1096
|
+
control={form.control}
|
|
1097
|
+
render={({ field }) => (
|
|
1098
|
+
<Select onValueChange={field.onChange} value={field.value}>
|
|
1099
|
+
<SelectTrigger>
|
|
1100
|
+
<SelectValue />
|
|
1101
|
+
</SelectTrigger>
|
|
1102
|
+
<SelectContent>
|
|
1103
|
+
<SelectItem value="online">
|
|
1104
|
+
{t('type.online')}
|
|
1105
|
+
</SelectItem>
|
|
1106
|
+
<SelectItem value="presencial">
|
|
1107
|
+
{t('type.inPerson')}
|
|
1108
|
+
</SelectItem>
|
|
1109
|
+
<SelectItem value="hibrida">
|
|
1110
|
+
{t('type.hybrid')}
|
|
1111
|
+
</SelectItem>
|
|
1112
|
+
</SelectContent>
|
|
1113
|
+
</Select>
|
|
1114
|
+
)}
|
|
1115
|
+
/>
|
|
1116
|
+
<FieldError>{form.formState.errors.tipo?.message}</FieldError>
|
|
1117
|
+
</Field>
|
|
1118
|
+
<Field>
|
|
1119
|
+
<FieldLabel>
|
|
1120
|
+
{t('form.fields.status.label')}{' '}
|
|
1121
|
+
<span className="text-destructive">*</span>
|
|
1122
|
+
</FieldLabel>
|
|
1123
|
+
<Controller
|
|
1124
|
+
name="status"
|
|
1125
|
+
control={form.control}
|
|
1126
|
+
render={({ field }) => (
|
|
1127
|
+
<Select onValueChange={field.onChange} value={field.value}>
|
|
1128
|
+
<SelectTrigger>
|
|
1129
|
+
<SelectValue />
|
|
1130
|
+
</SelectTrigger>
|
|
1131
|
+
<SelectContent>
|
|
1132
|
+
<SelectItem value="aberta">
|
|
1133
|
+
{t('status.open')}
|
|
1134
|
+
</SelectItem>
|
|
1135
|
+
<SelectItem value="em_andamento">
|
|
1136
|
+
{t('status.inProgress')}
|
|
1137
|
+
</SelectItem>
|
|
1138
|
+
<SelectItem value="concluida">
|
|
1139
|
+
{t('status.completed')}
|
|
1140
|
+
</SelectItem>
|
|
1141
|
+
<SelectItem value="cancelada">
|
|
1142
|
+
{t('status.cancelled')}
|
|
1143
|
+
</SelectItem>
|
|
1144
|
+
</SelectContent>
|
|
1145
|
+
</Select>
|
|
1146
|
+
)}
|
|
1147
|
+
/>
|
|
1148
|
+
<FieldError>{form.formState.errors.status?.message}</FieldError>
|
|
1149
|
+
</Field>
|
|
1150
|
+
</div>
|
|
1151
|
+
<Field>
|
|
1152
|
+
<FieldLabel htmlFor="professor">
|
|
1153
|
+
{t('form.fields.professor.label')}{' '}
|
|
1154
|
+
<span className="text-destructive">*</span>
|
|
1155
|
+
</FieldLabel>
|
|
1156
|
+
<Input
|
|
1157
|
+
id="professor"
|
|
1158
|
+
placeholder={t('form.fields.professor.placeholder')}
|
|
1159
|
+
{...form.register('professor')}
|
|
1160
|
+
/>
|
|
1161
|
+
<FieldError>
|
|
1162
|
+
{form.formState.errors.professor?.message}
|
|
1163
|
+
</FieldError>
|
|
1164
|
+
</Field>
|
|
1165
|
+
<Field>
|
|
1166
|
+
<FieldLabel htmlFor="vagas">
|
|
1167
|
+
{t('form.fields.vacancies.label')}{' '}
|
|
1168
|
+
<span className="text-destructive">*</span>
|
|
1169
|
+
</FieldLabel>
|
|
1170
|
+
<Input
|
|
1171
|
+
id="vagas"
|
|
1172
|
+
type="number"
|
|
1173
|
+
min={1}
|
|
1174
|
+
{...form.register('vagas')}
|
|
1175
|
+
/>
|
|
1176
|
+
<FieldError>{form.formState.errors.vagas?.message}</FieldError>
|
|
1177
|
+
</Field>
|
|
1178
|
+
<div className="grid grid-cols-2 gap-4">
|
|
1179
|
+
<Field>
|
|
1180
|
+
<FieldLabel htmlFor="dataInicio">
|
|
1181
|
+
{t('form.fields.startDate.label')}{' '}
|
|
1182
|
+
<span className="text-destructive">*</span>
|
|
1183
|
+
</FieldLabel>
|
|
1184
|
+
<Input
|
|
1185
|
+
id="dataInicio"
|
|
1186
|
+
type="date"
|
|
1187
|
+
{...form.register('dataInicio')}
|
|
1188
|
+
/>
|
|
1189
|
+
<FieldError>
|
|
1190
|
+
{form.formState.errors.dataInicio?.message}
|
|
1191
|
+
</FieldError>
|
|
1192
|
+
</Field>
|
|
1193
|
+
<Field>
|
|
1194
|
+
<FieldLabel htmlFor="dataFim">
|
|
1195
|
+
{t('form.fields.endDate.label')}{' '}
|
|
1196
|
+
<span className="text-destructive">*</span>
|
|
1197
|
+
</FieldLabel>
|
|
1198
|
+
<Input id="dataFim" type="date" {...form.register('dataFim')} />
|
|
1199
|
+
<FieldError>
|
|
1200
|
+
{form.formState.errors.dataFim?.message}
|
|
1201
|
+
</FieldError>
|
|
1202
|
+
</Field>
|
|
1203
|
+
</div>
|
|
1204
|
+
<Field>
|
|
1205
|
+
<FieldLabel htmlFor="horario">
|
|
1206
|
+
{t('form.fields.schedule.label')}{' '}
|
|
1207
|
+
<span className="text-destructive">*</span>
|
|
1208
|
+
</FieldLabel>
|
|
1209
|
+
<Input
|
|
1210
|
+
id="horario"
|
|
1211
|
+
placeholder={t('form.fields.schedule.placeholder')}
|
|
1212
|
+
{...form.register('horario')}
|
|
1213
|
+
/>
|
|
1214
|
+
<FieldError>{form.formState.errors.horario?.message}</FieldError>
|
|
1215
|
+
</Field>
|
|
1216
|
+
<SheetFooter className="mt-auto shrink-0 gap-2 pt-4">
|
|
1217
|
+
<Button type="submit" disabled={saving} className="gap-2">
|
|
1218
|
+
{saving && <Loader2 className="size-4 animate-spin" />}
|
|
1219
|
+
{editingTurma
|
|
1220
|
+
? t('form.actions.save')
|
|
1221
|
+
: t('form.actions.create')}
|
|
1222
|
+
</Button>
|
|
1223
|
+
</SheetFooter>
|
|
1224
|
+
</form>
|
|
1225
|
+
</SheetContent>
|
|
1226
|
+
</Sheet>
|
|
1227
|
+
|
|
1228
|
+
{/* Delete Dialog */}
|
|
1229
|
+
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
|
1230
|
+
<DialogContent>
|
|
1231
|
+
<DialogHeader>
|
|
1232
|
+
<DialogTitle className="flex items-center gap-2">
|
|
1233
|
+
<AlertTriangle className="size-5 text-destructive" />{' '}
|
|
1234
|
+
{t('deleteDialog.title')}
|
|
1235
|
+
</DialogTitle>
|
|
1236
|
+
<DialogDescription>
|
|
1237
|
+
{t('deleteDialog.description')}{' '}
|
|
1238
|
+
<strong>{turmaToDelete?.codigo}</strong> — {turmaToDelete?.curso}?
|
|
1239
|
+
{(turmaToDelete?.matriculados ?? 0) > 0 && (
|
|
1240
|
+
<span className="mt-2 block rounded-md bg-destructive/10 p-2 text-sm text-destructive">
|
|
1241
|
+
{t('deleteDialog.warning', {
|
|
1242
|
+
count: turmaToDelete?.matriculados ?? 0,
|
|
1243
|
+
})}
|
|
1244
|
+
</span>
|
|
1245
|
+
)}
|
|
1246
|
+
</DialogDescription>
|
|
1247
|
+
</DialogHeader>
|
|
1248
|
+
<DialogFooter className="gap-2">
|
|
1249
|
+
<Button
|
|
1250
|
+
variant="outline"
|
|
1251
|
+
onClick={() => setDeleteDialogOpen(false)}
|
|
1252
|
+
>
|
|
1253
|
+
{t('deleteDialog.actions.cancel')}
|
|
1254
|
+
</Button>
|
|
1255
|
+
<Button
|
|
1256
|
+
variant="destructive"
|
|
1257
|
+
onClick={confirmDelete}
|
|
1258
|
+
className="gap-2"
|
|
1259
|
+
>
|
|
1260
|
+
<Trash2 className="size-4" /> {t('deleteDialog.actions.delete')}
|
|
1261
|
+
</Button>
|
|
1262
|
+
</DialogFooter>
|
|
1263
|
+
</DialogContent>
|
|
1264
|
+
</Dialog>
|
|
1265
|
+
</Page>
|
|
1266
|
+
);
|
|
1267
|
+
}
|