@hed-hog/lms 0.0.314 → 0.0.316

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/enterprise/dto/enterprise-profile.dto.d.ts +13 -0
  2. package/dist/enterprise/dto/enterprise-profile.dto.d.ts.map +1 -0
  3. package/dist/enterprise/dto/enterprise-profile.dto.js +3 -0
  4. package/dist/enterprise/dto/enterprise-profile.dto.js.map +1 -0
  5. package/dist/enterprise/enterprise.controller.d.ts +3 -0
  6. package/dist/enterprise/enterprise.controller.d.ts.map +1 -1
  7. package/dist/enterprise/enterprise.controller.js +14 -0
  8. package/dist/enterprise/enterprise.controller.js.map +1 -1
  9. package/dist/enterprise/enterprise.service.d.ts +3 -0
  10. package/dist/enterprise/enterprise.service.d.ts.map +1 -1
  11. package/dist/enterprise/enterprise.service.js +128 -1
  12. package/dist/enterprise/enterprise.service.js.map +1 -1
  13. package/dist/instructor/instructor.controller.d.ts +23 -0
  14. package/dist/instructor/instructor.controller.d.ts.map +1 -1
  15. package/dist/instructor/instructor.controller.js +41 -0
  16. package/dist/instructor/instructor.controller.js.map +1 -1
  17. package/dist/instructor/instructor.service.d.ts +25 -0
  18. package/dist/instructor/instructor.service.d.ts.map +1 -1
  19. package/dist/instructor/instructor.service.js +126 -8
  20. package/dist/instructor/instructor.service.js.map +1 -1
  21. package/hedhog/data/menu.yaml +23 -7
  22. package/hedhog/data/role.yaml +17 -1
  23. package/hedhog/data/route.yaml +48 -0
  24. package/hedhog/frontend/app/_components/class-form-sheet.tsx.ejs +2 -1
  25. package/hedhog/frontend/app/courses/[id]/structure/_components/confirm-dialog.tsx.ejs +44 -44
  26. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-dnd.tsx.ejs +362 -362
  27. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-panel.tsx.ejs +111 -111
  28. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree.tsx.ejs +134 -134
  29. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-course.tsx.ejs +113 -113
  30. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +314 -314
  31. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-panel.tsx.ejs +62 -62
  32. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-session.tsx.ejs +173 -173
  33. package/hedhog/frontend/app/courses/[id]/structure/_components/drag-handle.tsx.ejs +58 -58
  34. package/hedhog/frontend/app/courses/[id]/structure/_components/drag-overlay.tsx.ejs +51 -51
  35. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-bulk.tsx.ejs +276 -276
  36. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +1216 -1216
  37. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +1824 -1824
  38. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-session.tsx.ejs +443 -443
  39. package/hedhog/frontend/app/courses/[id]/structure/_components/highlighted-text.tsx.ejs +40 -40
  40. package/hedhog/frontend/app/courses/[id]/structure/_components/mock-data.ts.ejs +185 -185
  41. package/hedhog/frontend/app/courses/[id]/structure/_components/multi-select-bar.tsx.ejs +264 -264
  42. package/hedhog/frontend/app/courses/[id]/structure/_components/search-filter.tsx.ejs +95 -95
  43. package/hedhog/frontend/app/courses/[id]/structure/_components/session-picker-dialog.tsx.ejs +73 -73
  44. package/hedhog/frontend/app/courses/[id]/structure/_components/shortcuts-help.tsx.ejs +136 -136
  45. package/hedhog/frontend/app/courses/[id]/structure/_components/sortable-tree-row.tsx.ejs +80 -80
  46. package/hedhog/frontend/app/courses/[id]/structure/_components/store.ts.ejs +949 -949
  47. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-context-menu.tsx.ejs +525 -525
  48. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-helpers.ts.ejs +181 -181
  49. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-course.tsx.ejs +51 -51
  50. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +271 -271
  51. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-session.tsx.ejs +167 -167
  52. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row.tsx.ejs +108 -108
  53. package/hedhog/frontend/app/courses/[id]/structure/_components/use-course-structure-shortcuts.ts.ejs +318 -318
  54. package/hedhog/frontend/app/courses/[id]/structure/page.tsx.ejs +10 -10
  55. package/hedhog/frontend/app/enterprise/_components/enterprise-course-create-sheet.tsx.ejs +2 -1
  56. package/hedhog/frontend/app/instructors/_components/instructor-form-sheet.tsx.ejs +438 -0
  57. package/hedhog/frontend/app/instructors/_components/instructor-types.ts.ejs +40 -0
  58. package/hedhog/frontend/app/instructors/page.tsx.ejs +696 -0
  59. package/hedhog/frontend/app/training/page.tsx.ejs +2339 -0
  60. package/hedhog/query/add_route_role.sql +15 -0
  61. package/hedhog/table/enterprise_user.yaml +1 -1
  62. package/package.json +6 -6
  63. package/src/enterprise/dto/enterprise-profile.dto.ts +17 -0
  64. package/src/enterprise/enterprise.controller.ts +9 -1
  65. package/src/enterprise/enterprise.service.ts +147 -4
  66. package/src/instructor/instructor.controller.ts +36 -9
  67. package/src/instructor/instructor.service.ts +140 -10
@@ -0,0 +1,696 @@
1
+ 'use client';
2
+
3
+ import {
4
+ EmptyState,
5
+ Page,
6
+ PageHeader,
7
+ PaginationFooter,
8
+ SearchBar,
9
+ type SearchBarControl,
10
+ } from '@/components/entity-list';
11
+ import {
12
+ AlertDialog,
13
+ AlertDialogAction,
14
+ AlertDialogCancel,
15
+ AlertDialogContent,
16
+ AlertDialogDescription,
17
+ AlertDialogHeader,
18
+ AlertDialogTitle,
19
+ } from '@/components/ui/alert-dialog';
20
+ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
21
+ import { Badge } from '@/components/ui/badge';
22
+ import { Button } from '@/components/ui/button';
23
+ import { Card, CardContent } from '@/components/ui/card';
24
+ import {
25
+ DropdownMenu,
26
+ DropdownMenuContent,
27
+ DropdownMenuItem,
28
+ DropdownMenuSeparator,
29
+ DropdownMenuTrigger,
30
+ } from '@/components/ui/dropdown-menu';
31
+ import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
32
+ import { Skeleton } from '@/components/ui/skeleton';
33
+ import {
34
+ Table,
35
+ TableBody,
36
+ TableCell,
37
+ TableHead,
38
+ TableHeader,
39
+ TableRow,
40
+ } from '@/components/ui/table';
41
+ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
42
+ import { cn } from '@/lib/utils';
43
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
44
+ import {
45
+ LayoutGrid,
46
+ List,
47
+ Mail,
48
+ MoreHorizontal,
49
+ Pencil,
50
+ Phone,
51
+ Plus,
52
+ Trash2,
53
+ UserRoundPen,
54
+ Users,
55
+ } from 'lucide-react';
56
+ import { useEffect, useState } from 'react';
57
+ import { toast } from 'sonner';
58
+ import { InstructorFormSheet } from './_components/instructor-form-sheet';
59
+ import type {
60
+ InstructorPaginatedResult,
61
+ InstructorRow,
62
+ InstructorStats,
63
+ } from './_components/instructor-types';
64
+
65
+ const VIEW_STORAGE_KEY = 'lms:instructors:view-mode';
66
+
67
+ type ViewMode = 'table' | 'cards';
68
+
69
+ const QUALIFICATION_LABELS: Record<string, string> = {
70
+ 'course-lessons': 'Aulas de curso',
71
+ 'class-sessions': 'Sessões de turma',
72
+ };
73
+
74
+ function getInstructorInitials(name: string) {
75
+ return name
76
+ .split(' ')
77
+ .filter(Boolean)
78
+ .slice(0, 2)
79
+ .map((part) => part[0]?.toUpperCase() || '')
80
+ .join('');
81
+ }
82
+
83
+ function getAvatarUrl(avatarId?: number | null) {
84
+ return typeof avatarId === 'number' && avatarId > 0
85
+ ? `${process.env.NEXT_PUBLIC_API_BASE_URL}/person/avatar/${avatarId}`
86
+ : undefined;
87
+ }
88
+
89
+ export default function InstructorsPage() {
90
+ const { request } = useApp();
91
+
92
+ const [page, setPage] = useState(1);
93
+ const [pageSize, setPageSize] = useState(12);
94
+ const [searchInput, setSearchInput] = useState('');
95
+ const [debouncedSearch, setDebouncedSearch] = useState('');
96
+ const [statusFilter, setStatusFilter] = useState('all');
97
+ const [qualificationFilter, setQualificationFilter] = useState('all');
98
+ const [viewMode, setViewMode] = useState<ViewMode>('table');
99
+ const [sheetOpen, setSheetOpen] = useState(false);
100
+ const [instructorToEdit, setInstructorToEdit] =
101
+ useState<InstructorRow | null>(null);
102
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
103
+ const [instructorToDelete, setInstructorToDelete] =
104
+ useState<InstructorRow | null>(null);
105
+ const [isDeleting, setIsDeleting] = useState(false);
106
+
107
+ // Debounce search
108
+ useEffect(() => {
109
+ const timeout = setTimeout(() => {
110
+ setDebouncedSearch(searchInput.trim());
111
+ setPage(1);
112
+ }, 300);
113
+ return () => clearTimeout(timeout);
114
+ }, [searchInput]);
115
+
116
+ // Restore view mode from localStorage
117
+ useEffect(() => {
118
+ try {
119
+ const saved = window.localStorage.getItem(VIEW_STORAGE_KEY);
120
+ if (saved === 'table' || saved === 'cards') {
121
+ setViewMode(saved);
122
+ }
123
+ } catch {
124
+ // Ignore
125
+ }
126
+ }, []);
127
+
128
+ const handleViewModeChange = (value: string) => {
129
+ if (value !== 'table' && value !== 'cards') return;
130
+ setViewMode(value);
131
+ try {
132
+ window.localStorage.setItem(VIEW_STORAGE_KEY, value);
133
+ } catch {
134
+ // Ignore
135
+ }
136
+ };
137
+
138
+ // Stats
139
+ const {
140
+ data: stats = { total: 0, active: 0, inactive: 0 },
141
+ refetch: refetchStats,
142
+ } = useQuery<InstructorStats>({
143
+ queryKey: ['lms-instructor-stats'],
144
+ queryFn: async () => {
145
+ const response = await request<InstructorStats>({
146
+ url: '/lms/instructors/stats',
147
+ method: 'GET',
148
+ });
149
+ return response.data;
150
+ },
151
+ placeholderData: (prev) => prev ?? { total: 0, active: 0, inactive: 0 },
152
+ });
153
+
154
+ // List
155
+ const {
156
+ data: paginate = {
157
+ data: [],
158
+ total: 0,
159
+ page: 1,
160
+ pageSize,
161
+ lastPage: 1,
162
+ },
163
+ isLoading,
164
+ refetch: refetchList,
165
+ } = useQuery<InstructorPaginatedResult>({
166
+ queryKey: [
167
+ 'lms-instructors',
168
+ page,
169
+ pageSize,
170
+ debouncedSearch,
171
+ statusFilter,
172
+ qualificationFilter,
173
+ ],
174
+ queryFn: async () => {
175
+ const params = new URLSearchParams({
176
+ page: String(page),
177
+ pageSize: String(pageSize),
178
+ });
179
+ if (debouncedSearch) params.set('search', debouncedSearch);
180
+ if (statusFilter !== 'all') params.set('status', statusFilter);
181
+ if (qualificationFilter !== 'all')
182
+ params.set('qualificationSlugs', qualificationFilter);
183
+
184
+ const response = await request<InstructorPaginatedResult>({
185
+ url: `/lms/instructors?${params.toString()}`,
186
+ method: 'GET',
187
+ });
188
+ return response.data;
189
+ },
190
+ placeholderData: (prev) =>
191
+ prev ?? { data: [], total: 0, page: 1, pageSize, lastPage: 1 },
192
+ });
193
+
194
+ const totalPages = Math.max(
195
+ 1,
196
+ (paginate.lastPage ?? Math.ceil((paginate.total || 0) / pageSize)) || 1
197
+ );
198
+
199
+ useEffect(() => {
200
+ if (page > totalPages) setPage(totalPages);
201
+ }, [page, totalPages]);
202
+
203
+ const openCreateSheet = () => {
204
+ setInstructorToEdit(null);
205
+ setSheetOpen(true);
206
+ };
207
+
208
+ const openEditSheet = (instructor: InstructorRow) => {
209
+ setInstructorToEdit(instructor);
210
+ setSheetOpen(true);
211
+ };
212
+
213
+ const handleSaved = async () => {
214
+ await Promise.all([refetchList(), refetchStats()]);
215
+ };
216
+
217
+ const handleDeleteConfirm = async () => {
218
+ if (!instructorToDelete) return;
219
+ try {
220
+ setIsDeleting(true);
221
+ await request({
222
+ url: `/lms/instructors/${instructorToDelete.id}`,
223
+ method: 'DELETE',
224
+ });
225
+ toast.success('Instrutor removido com sucesso.');
226
+ setDeleteDialogOpen(false);
227
+ setInstructorToDelete(null);
228
+ await Promise.all([refetchList(), refetchStats()]);
229
+ } catch {
230
+ toast.error('Erro ao remover instrutor. Tente novamente.');
231
+ } finally {
232
+ setIsDeleting(false);
233
+ }
234
+ };
235
+
236
+ const statsCards = [
237
+ {
238
+ key: 'total',
239
+ title: 'Total de instrutores',
240
+ value: stats.total,
241
+ icon: Users,
242
+ accentClassName: 'from-slate-500/20 via-slate-400/10 to-transparent',
243
+ iconContainerClassName: 'bg-slate-100 text-slate-700',
244
+ },
245
+ {
246
+ key: 'active',
247
+ title: 'Ativos',
248
+ value: stats.active,
249
+ icon: UserRoundPen,
250
+ accentClassName: 'from-green-500/20 via-emerald-500/10 to-transparent',
251
+ iconContainerClassName: 'bg-green-50 text-green-600',
252
+ },
253
+ {
254
+ key: 'inactive',
255
+ title: 'Inativos',
256
+ value: stats.inactive,
257
+ icon: UserRoundPen,
258
+ accentClassName: 'from-gray-500/20 via-gray-400/10 to-transparent',
259
+ iconContainerClassName: 'bg-gray-100 text-gray-600',
260
+ },
261
+ ];
262
+
263
+ const searchControls: SearchBarControl[] = [
264
+ {
265
+ id: 'status-filter',
266
+ type: 'select',
267
+ value: statusFilter,
268
+ onChange: (value: string) => {
269
+ setStatusFilter(value);
270
+ setPage(1);
271
+ },
272
+ placeholder: 'Filtrar por status',
273
+ options: [
274
+ { value: 'all', label: 'Todos os status' },
275
+ { value: 'active', label: 'Ativo' },
276
+ { value: 'inactive', label: 'Inativo' },
277
+ ],
278
+ },
279
+ {
280
+ id: 'qualification-filter',
281
+ type: 'select',
282
+ value: qualificationFilter,
283
+ onChange: (value: string) => {
284
+ setQualificationFilter(value);
285
+ setPage(1);
286
+ },
287
+ placeholder: 'Filtrar por qualificação',
288
+ options: [
289
+ { value: 'all', label: 'Todas as qualificações' },
290
+ { value: 'course-lessons', label: 'Aulas de curso' },
291
+ { value: 'class-sessions', label: 'Sessões de turma' },
292
+ ],
293
+ },
294
+ ];
295
+
296
+ return (
297
+ <Page>
298
+ <PageHeader
299
+ breadcrumbs={[
300
+ { label: 'Home', href: '/' },
301
+ { label: 'LMS', href: '/lms' },
302
+ { label: 'Instrutores' },
303
+ ]}
304
+ title="Instrutores"
305
+ description="Gerencie os perfis de instrutores da plataforma."
306
+ actions={[
307
+ {
308
+ label: 'Novo Instrutor',
309
+ onClick: openCreateSheet,
310
+ icon: <Plus className="h-4 w-4" />,
311
+ },
312
+ ]}
313
+ />
314
+
315
+ <KpiCardsGrid items={statsCards} />
316
+
317
+ <div className="flex flex-col gap-4 xl:flex-row xl:items-center">
318
+ <div className="flex-1">
319
+ <SearchBar
320
+ searchQuery={searchInput}
321
+ onSearchChange={(value) => {
322
+ setSearchInput(value);
323
+ }}
324
+ onSearch={() => setPage(1)}
325
+ placeholder="Buscar por nome ou e-mail..."
326
+ controls={searchControls}
327
+ />
328
+ </div>
329
+
330
+ <div className="flex items-center justify-between gap-3 sm:justify-start xl:justify-end">
331
+ <span className="text-xs font-medium text-muted-foreground">
332
+ Visualização
333
+ </span>
334
+ <ToggleGroup
335
+ type="single"
336
+ value={viewMode}
337
+ onValueChange={handleViewModeChange}
338
+ variant="outline"
339
+ size="sm"
340
+ aria-label="Modo de visualização"
341
+ >
342
+ <ToggleGroupItem
343
+ value="table"
344
+ className="gap-1.5 px-2.5"
345
+ aria-label="Tabela"
346
+ >
347
+ <List className="h-4 w-4" />
348
+ <span className="hidden sm:inline">Tabela</span>
349
+ </ToggleGroupItem>
350
+ <ToggleGroupItem
351
+ value="cards"
352
+ className="gap-1.5 px-2.5"
353
+ aria-label="Cards"
354
+ >
355
+ <LayoutGrid className="h-4 w-4" />
356
+ <span className="hidden sm:inline">Cards</span>
357
+ </ToggleGroupItem>
358
+ </ToggleGroup>
359
+ </div>
360
+ </div>
361
+
362
+ {isLoading ? (
363
+ viewMode === 'cards' ? (
364
+ <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
365
+ {Array.from({ length: 6 }).map((_, i) => (
366
+ <Card key={i} className="overflow-hidden py-0">
367
+ <CardContent className="space-y-3 p-4">
368
+ <div className="flex items-center gap-2.5">
369
+ <Skeleton className="h-10 w-10 rounded-full" />
370
+ <div className="flex-1 space-y-2">
371
+ <Skeleton className="h-4 w-2/3" />
372
+ <Skeleton className="h-3 w-1/2" />
373
+ </div>
374
+ </div>
375
+ <div className="flex gap-2">
376
+ <Skeleton className="h-5 w-24 rounded-full" />
377
+ <Skeleton className="h-5 w-16 rounded-full" />
378
+ </div>
379
+ </CardContent>
380
+ </Card>
381
+ ))}
382
+ </div>
383
+ ) : (
384
+ <div className="space-y-3 p-4">
385
+ {Array.from({ length: 5 }).map((_, i) => (
386
+ <Skeleton key={i} className="h-14 w-full" />
387
+ ))}
388
+ </div>
389
+ )
390
+ ) : paginate.data.length === 0 ? (
391
+ <EmptyState
392
+ icon={<UserRoundPen className="h-12 w-12" />}
393
+ title="Nenhum instrutor encontrado"
394
+ description="Crie um novo instrutor ou ajuste os filtros de busca."
395
+ actionLabel="Novo Instrutor"
396
+ actionIcon={<Plus className="mr-2 h-4 w-4" />}
397
+ onAction={openCreateSheet}
398
+ />
399
+ ) : (
400
+ <>
401
+ {viewMode === 'table' ? (
402
+ <div className="overflow-x-auto">
403
+ <Table>
404
+ <TableHeader>
405
+ <TableRow>
406
+ <TableHead>Instrutor</TableHead>
407
+ <TableHead>E-mail</TableHead>
408
+ <TableHead>Telefone</TableHead>
409
+ <TableHead>Qualificações</TableHead>
410
+ <TableHead>Training</TableHead>
411
+ <TableHead>Status</TableHead>
412
+ <TableHead className="w-10" />
413
+ </TableRow>
414
+ </TableHeader>
415
+ <TableBody>
416
+ {paginate.data.map((instructor) => (
417
+ <TableRow
418
+ key={instructor.id}
419
+ className="cursor-pointer"
420
+ onDoubleClick={() => openEditSheet(instructor)}
421
+ >
422
+ <TableCell>
423
+ <div className="flex items-center gap-3">
424
+ <Avatar className="h-9 w-9 rounded-full">
425
+ {instructor.avatarId ? (
426
+ <AvatarImage
427
+ src={getAvatarUrl(instructor.avatarId)}
428
+ alt={instructor.name}
429
+ className="object-cover"
430
+ />
431
+ ) : null}
432
+ <AvatarFallback className="bg-slate-100 text-xs font-semibold uppercase text-slate-700">
433
+ {getInstructorInitials(instructor.name)}
434
+ </AvatarFallback>
435
+ </Avatar>
436
+ <span className="font-medium">{instructor.name}</span>
437
+ </div>
438
+ </TableCell>
439
+ <TableCell>
440
+ <span className="text-sm text-muted-foreground">
441
+ {instructor.email ? (
442
+ <span className="flex items-center gap-1">
443
+ <Mail className="h-3 w-3" />
444
+ {instructor.email}
445
+ </span>
446
+ ) : (
447
+ '-'
448
+ )}
449
+ </span>
450
+ </TableCell>
451
+ <TableCell>
452
+ <span className="text-sm text-muted-foreground">
453
+ {instructor.phone ? (
454
+ <span className="flex items-center gap-1">
455
+ <Phone className="h-3 w-3" />
456
+ {instructor.phone}
457
+ </span>
458
+ ) : (
459
+ '-'
460
+ )}
461
+ </span>
462
+ </TableCell>
463
+ <TableCell>
464
+ <div className="flex flex-wrap gap-1">
465
+ {instructor.qualificationSlugs.map((slug) => (
466
+ <Badge
467
+ key={slug}
468
+ variant="outline"
469
+ className="border-blue-500/20 bg-blue-500/10 px-2 py-0.5 text-[11px] font-medium text-blue-600"
470
+ >
471
+ {QUALIFICATION_LABELS[slug] ?? slug}
472
+ </Badge>
473
+ ))}
474
+ </div>
475
+ </TableCell>
476
+ <TableCell>
477
+ {instructor.hasTrainingAccess ? (
478
+ <Badge
479
+ variant="outline"
480
+ className="border-amber-500/20 bg-amber-500/10 px-2.5 py-1 text-xs font-medium text-amber-600"
481
+ >
482
+ Ativo
483
+ </Badge>
484
+ ) : instructor.userId ? (
485
+ <Badge
486
+ variant="outline"
487
+ className="border-gray-500/20 bg-gray-500/10 px-2.5 py-1 text-xs font-medium text-gray-500"
488
+ >
489
+ Desabilitado
490
+ </Badge>
491
+ ) : (
492
+ <span className="text-xs text-muted-foreground">
493
+
494
+ </span>
495
+ )}
496
+ </TableCell>
497
+ <TableCell>
498
+ <Badge
499
+ variant="outline"
500
+ className={cn(
501
+ 'border px-2.5 py-1 text-xs font-medium',
502
+ instructor.status === 'active'
503
+ ? 'border-green-500/20 bg-green-500/10 text-green-600'
504
+ : 'border-gray-500/20 bg-gray-500/10 text-gray-600'
505
+ )}
506
+ >
507
+ {instructor.status === 'active' ? 'Ativo' : 'Inativo'}
508
+ </Badge>
509
+ </TableCell>
510
+ <TableCell>
511
+ <DropdownMenu>
512
+ <DropdownMenuTrigger asChild>
513
+ <Button
514
+ variant="ghost"
515
+ size="icon"
516
+ className="h-8 w-8"
517
+ >
518
+ <MoreHorizontal className="h-4 w-4" />
519
+ </Button>
520
+ </DropdownMenuTrigger>
521
+ <DropdownMenuContent align="end">
522
+ <DropdownMenuItem
523
+ onClick={() => openEditSheet(instructor)}
524
+ >
525
+ <Pencil className="mr-2 h-4 w-4" />
526
+ Editar
527
+ </DropdownMenuItem>
528
+ <DropdownMenuSeparator />
529
+ <DropdownMenuItem
530
+ className="text-red-600"
531
+ onClick={() => {
532
+ setInstructorToDelete(instructor);
533
+ setDeleteDialogOpen(true);
534
+ }}
535
+ >
536
+ <Trash2 className="mr-2 h-4 w-4" />
537
+ Excluir
538
+ </DropdownMenuItem>
539
+ </DropdownMenuContent>
540
+ </DropdownMenu>
541
+ </TableCell>
542
+ </TableRow>
543
+ ))}
544
+ </TableBody>
545
+ </Table>
546
+ </div>
547
+ ) : (
548
+ <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
549
+ {paginate.data.map((instructor) => (
550
+ <Card
551
+ key={instructor.id}
552
+ className="group cursor-pointer overflow-hidden border-border/70 py-0 transition-colors hover:border-border hover:shadow-md"
553
+ onDoubleClick={() => openEditSheet(instructor)}
554
+ >
555
+ <CardContent className="flex flex-col gap-3 p-4">
556
+ <div className="flex items-start justify-between gap-3">
557
+ <div className="flex min-w-0 items-center gap-3">
558
+ <Avatar className="h-10 w-10 shrink-0 rounded-full border border-slate-500/20">
559
+ {instructor.avatarId ? (
560
+ <AvatarImage
561
+ src={getAvatarUrl(instructor.avatarId)}
562
+ alt={instructor.name}
563
+ className="object-cover"
564
+ />
565
+ ) : null}
566
+ <AvatarFallback className="bg-slate-500/8 text-sm font-semibold uppercase text-slate-700 dark:text-slate-200">
567
+ {getInstructorInitials(instructor.name)}
568
+ </AvatarFallback>
569
+ </Avatar>
570
+
571
+ <div className="min-w-0">
572
+ <p className="truncate font-medium">
573
+ {instructor.name}
574
+ </p>
575
+ {instructor.email && (
576
+ <p className="flex items-center gap-1 truncate text-xs text-muted-foreground">
577
+ <Mail className="h-3 w-3 shrink-0" />
578
+ {instructor.email}
579
+ </p>
580
+ )}
581
+ {instructor.phone && (
582
+ <p className="flex items-center gap-1 truncate text-xs text-muted-foreground">
583
+ <Phone className="h-3 w-3 shrink-0" />
584
+ {instructor.phone}
585
+ </p>
586
+ )}
587
+ </div>
588
+ </div>
589
+
590
+ <DropdownMenu>
591
+ <DropdownMenuTrigger asChild>
592
+ <Button
593
+ variant="ghost"
594
+ size="icon"
595
+ className="h-8 w-8 shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
596
+ >
597
+ <MoreHorizontal className="h-4 w-4" />
598
+ </Button>
599
+ </DropdownMenuTrigger>
600
+ <DropdownMenuContent align="end">
601
+ <DropdownMenuItem
602
+ onClick={() => openEditSheet(instructor)}
603
+ >
604
+ <Pencil className="mr-2 h-4 w-4" />
605
+ Editar
606
+ </DropdownMenuItem>
607
+ <DropdownMenuSeparator />
608
+ <DropdownMenuItem
609
+ className="text-red-600"
610
+ onClick={() => {
611
+ setInstructorToDelete(instructor);
612
+ setDeleteDialogOpen(true);
613
+ }}
614
+ >
615
+ <Trash2 className="mr-2 h-4 w-4" />
616
+ Excluir
617
+ </DropdownMenuItem>
618
+ </DropdownMenuContent>
619
+ </DropdownMenu>
620
+ </div>
621
+
622
+ <div className="flex flex-wrap gap-1.5">
623
+ <Badge
624
+ variant="outline"
625
+ className={cn(
626
+ 'border px-2 py-0.5 text-[11px] font-medium',
627
+ instructor.status === 'active'
628
+ ? 'border-green-500/20 bg-green-500/10 text-green-600'
629
+ : 'border-gray-500/20 bg-gray-500/10 text-gray-600'
630
+ )}
631
+ >
632
+ {instructor.status === 'active' ? 'Ativo' : 'Inativo'}
633
+ </Badge>
634
+ {instructor.qualificationSlugs.map((slug) => (
635
+ <Badge
636
+ key={slug}
637
+ variant="outline"
638
+ className="border-blue-500/20 bg-blue-500/10 px-2 py-0.5 text-[11px] font-medium text-blue-600"
639
+ >
640
+ {QUALIFICATION_LABELS[slug] ?? slug}
641
+ </Badge>
642
+ ))}
643
+ </div>
644
+ </CardContent>
645
+ </Card>
646
+ ))}
647
+ </div>
648
+ )}
649
+ </>
650
+ )}
651
+
652
+ <PaginationFooter
653
+ currentPage={page}
654
+ pageSize={pageSize}
655
+ totalItems={paginate.total}
656
+ onPageChange={setPage}
657
+ onPageSizeChange={(nextPageSize) => {
658
+ setPageSize(nextPageSize);
659
+ setPage(1);
660
+ }}
661
+ />
662
+
663
+ <InstructorFormSheet
664
+ open={sheetOpen}
665
+ onOpenChange={setSheetOpen}
666
+ instructorId={instructorToEdit?.id ?? null}
667
+ onSaved={handleSaved}
668
+ />
669
+
670
+ <AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
671
+ <AlertDialogContent>
672
+ <AlertDialogHeader>
673
+ <AlertDialogTitle>Excluir instrutor</AlertDialogTitle>
674
+ <AlertDialogDescription>
675
+ Tem certeza que deseja excluir o instrutor{' '}
676
+ <strong>{instructorToDelete?.name}</strong>? Esta ação não pode
677
+ ser desfeita.
678
+ </AlertDialogDescription>
679
+ </AlertDialogHeader>
680
+ <div className="flex justify-end gap-2">
681
+ <AlertDialogCancel disabled={isDeleting}>
682
+ Cancelar
683
+ </AlertDialogCancel>
684
+ <AlertDialogAction
685
+ onClick={handleDeleteConfirm}
686
+ disabled={isDeleting}
687
+ className="bg-red-600 hover:bg-red-700"
688
+ >
689
+ {isDeleting ? 'Excluindo...' : 'Excluir'}
690
+ </AlertDialogAction>
691
+ </div>
692
+ </AlertDialogContent>
693
+ </AlertDialog>
694
+ </Page>
695
+ );
696
+ }