@hed-hog/lms 0.0.331 → 0.0.338

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 (110) hide show
  1. package/dist/class-group/class-group.controller.d.ts +3 -3
  2. package/dist/class-group/class-group.service.d.ts +3 -3
  3. package/dist/course/course.service.d.ts.map +1 -1
  4. package/dist/course/course.service.js +12 -20
  5. package/dist/course/course.service.js.map +1 -1
  6. package/dist/enterprise/enterprise.controller.d.ts +72 -0
  7. package/dist/enterprise/enterprise.controller.d.ts.map +1 -1
  8. package/dist/enterprise/enterprise.controller.js +10 -0
  9. package/dist/enterprise/enterprise.controller.js.map +1 -1
  10. package/dist/enterprise/enterprise.service.d.ts +78 -0
  11. package/dist/enterprise/enterprise.service.d.ts.map +1 -1
  12. package/dist/enterprise/enterprise.service.js +413 -40
  13. package/dist/enterprise/enterprise.service.js.map +1 -1
  14. package/dist/enterprise/training/training-admin.controller.d.ts +6 -3
  15. package/dist/enterprise/training/training-admin.controller.d.ts.map +1 -1
  16. package/dist/enterprise/training/training-admin.controller.js +10 -6
  17. package/dist/enterprise/training/training-admin.controller.js.map +1 -1
  18. package/dist/enterprise/training/training-admin.service.d.ts +8 -2
  19. package/dist/enterprise/training/training-admin.service.d.ts.map +1 -1
  20. package/dist/enterprise/training/training-admin.service.js +108 -52
  21. package/dist/enterprise/training/training-admin.service.js.map +1 -1
  22. package/dist/enterprise/training/training-viewer.controller.d.ts +3 -0
  23. package/dist/enterprise/training/training-viewer.controller.d.ts.map +1 -1
  24. package/dist/evaluation/evaluation.controller.d.ts +4 -4
  25. package/dist/evaluation/evaluation.service.d.ts +4 -4
  26. package/dist/instructor/dto/create-instructor-skill.dto.d.ts +0 -4
  27. package/dist/instructor/dto/create-instructor-skill.dto.d.ts.map +1 -1
  28. package/dist/instructor/dto/create-instructor-skill.dto.js +0 -21
  29. package/dist/instructor/dto/create-instructor-skill.dto.js.map +1 -1
  30. package/dist/instructor/dto/update-instructor-skill.dto.d.ts +0 -4
  31. package/dist/instructor/dto/update-instructor-skill.dto.d.ts.map +1 -1
  32. package/dist/instructor/dto/update-instructor-skill.dto.js +0 -22
  33. package/dist/instructor/dto/update-instructor-skill.dto.js.map +1 -1
  34. package/dist/instructor/instructor-skill.controller.d.ts +4 -4
  35. package/dist/instructor/instructor-skill.service.d.ts +4 -7
  36. package/dist/instructor/instructor-skill.service.d.ts.map +1 -1
  37. package/dist/instructor/instructor-skill.service.js +2 -89
  38. package/dist/instructor/instructor-skill.service.js.map +1 -1
  39. package/dist/instructor/instructor.controller.d.ts +20 -0
  40. package/dist/instructor/instructor.controller.d.ts.map +1 -1
  41. package/dist/instructor/instructor.controller.js +19 -0
  42. package/dist/instructor/instructor.controller.js.map +1 -1
  43. package/dist/instructor/instructor.service.d.ts +25 -0
  44. package/dist/instructor/instructor.service.d.ts.map +1 -1
  45. package/dist/instructor/instructor.service.js +70 -18
  46. package/dist/instructor/instructor.service.js.map +1 -1
  47. package/dist/lms.module.d.ts.map +1 -1
  48. package/dist/lms.module.js.map +1 -1
  49. package/hedhog/data/route.yaml +23 -1
  50. package/hedhog/frontend/app/_components/class-form-sheet.tsx.ejs +42 -24
  51. package/hedhog/frontend/app/_components/course-form-sheet.tsx.ejs +3 -3
  52. package/hedhog/frontend/app/_components/create-lms-instructor-sheet.tsx.ejs +591 -0
  53. package/hedhog/frontend/app/certificates/issued/page.tsx.ejs +6 -1
  54. package/hedhog/frontend/app/certificates/models/page.tsx.ejs +6 -1
  55. package/hedhog/frontend/app/classes/page.tsx.ejs +6 -1
  56. package/hedhog/frontend/app/courses/[id]/_components/CourseClassificationCard.tsx.ejs +3 -33
  57. package/hedhog/frontend/app/courses/[id]/_components/CourseContentCard.tsx.ejs +9 -9
  58. package/hedhog/frontend/app/courses/[id]/_components/CourseMainInfoCard.tsx.ejs +109 -0
  59. package/hedhog/frontend/app/courses/[id]/_components/CourseMultiEntityPicker.tsx.ejs +40 -13
  60. package/hedhog/frontend/app/courses/[id]/_components/CourseRelationsCard.tsx.ejs +76 -81
  61. package/hedhog/frontend/app/courses/[id]/_components/CourseSummaryCard.tsx.ejs +60 -0
  62. package/hedhog/frontend/app/courses/[id]/structure/_components/course-scheduled-classes-tab.tsx.ejs +406 -0
  63. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree.tsx.ejs +134 -0
  64. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-course.tsx.ejs +113 -0
  65. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +314 -0
  66. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-session.tsx.ejs +174 -0
  67. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +242 -33
  68. package/hedhog/frontend/app/courses/[id]/structure/_components/mock-data.ts.ejs +185 -0
  69. package/hedhog/frontend/app/courses/page.tsx.ejs +6 -1
  70. package/hedhog/frontend/app/enterprise/_components/enterprise-activity-timeline.tsx.ejs +87 -0
  71. package/hedhog/frontend/app/enterprise/_components/enterprise-admin-create-sheet.tsx.ejs +4 -0
  72. package/hedhog/frontend/app/enterprise/_components/enterprise-administrators-tab.tsx.ejs +31 -5
  73. package/hedhog/frontend/app/enterprise/_components/enterprise-classes-tab.tsx.ejs +79 -20
  74. package/hedhog/frontend/app/enterprise/_components/enterprise-company-identity-card.tsx.ejs +11 -2
  75. package/hedhog/frontend/app/enterprise/_components/enterprise-course-edit-sheet.tsx.ejs +201 -0
  76. package/hedhog/frontend/app/enterprise/_components/enterprise-courses-tab.tsx.ejs +55 -24
  77. package/hedhog/frontend/app/enterprise/_components/enterprise-detail-sheet.tsx.ejs +430 -296
  78. package/hedhog/frontend/app/enterprise/_components/enterprise-mocks.ts.ejs +277 -0
  79. package/hedhog/frontend/app/enterprise/_components/enterprise-overview-analytics.tsx.ejs +205 -0
  80. package/hedhog/frontend/app/enterprise/_components/enterprise-person-edit-sheet.tsx.ejs +97 -0
  81. package/hedhog/frontend/app/enterprise/_components/enterprise-sheet.tsx.ejs +82 -57
  82. package/hedhog/frontend/app/enterprise/_components/enterprise-student-create-sheet.tsx.ejs +4 -0
  83. package/hedhog/frontend/app/enterprise/_components/enterprise-students-tab.tsx.ejs +60 -22
  84. package/hedhog/frontend/app/enterprise/_components/enterprise-types.ts.ejs +54 -0
  85. package/hedhog/frontend/app/enterprise/_components/enterprise-user-create-sheet.tsx.ejs +211 -0
  86. package/hedhog/frontend/app/enterprise/page.tsx.ejs +39 -7
  87. package/hedhog/frontend/app/exams/[id]/questions/page.tsx.ejs +6 -1
  88. package/hedhog/frontend/app/exams/page.tsx.ejs +6 -1
  89. package/hedhog/frontend/app/instructor-skills/page.tsx.ejs +51 -104
  90. package/hedhog/frontend/app/instructors/_components/instructor-form-sheet.tsx.ejs +625 -366
  91. package/hedhog/frontend/app/instructors/page.tsx.ejs +6 -1
  92. package/hedhog/frontend/app/paths/page.tsx.ejs +9 -4
  93. package/hedhog/frontend/app/reports/evaluations/page.tsx.ejs +6 -1
  94. package/hedhog/frontend/app/training/page.tsx.ejs +9 -4
  95. package/hedhog/frontend/messages/en.json +101 -10
  96. package/hedhog/frontend/messages/pt.json +101 -10
  97. package/hedhog/table/enterprise_student_license_event.yaml +30 -0
  98. package/hedhog/table/instructor_skill.yaml +0 -11
  99. package/package.json +7 -7
  100. package/src/course/course.service.ts +12 -24
  101. package/src/enterprise/enterprise.controller.ts +5 -0
  102. package/src/enterprise/enterprise.service.ts +507 -29
  103. package/src/enterprise/training/training-admin.controller.ts +4 -0
  104. package/src/enterprise/training/training-admin.service.ts +115 -51
  105. package/src/instructor/dto/create-instructor-skill.dto.ts +0 -17
  106. package/src/instructor/dto/update-instructor-skill.dto.ts +0 -18
  107. package/src/instructor/instructor-skill.service.ts +2 -97
  108. package/src/instructor/instructor.controller.ts +16 -0
  109. package/src/instructor/instructor.service.ts +85 -10
  110. package/src/lms.module.ts +1 -0
@@ -0,0 +1,406 @@
1
+ 'use client';
2
+
3
+ import {
4
+ ClassFormSheet,
5
+ type ApiClass,
6
+ } from '@/app/(app)/(libraries)/lms/_components/class-form-sheet';
7
+ import { EmptyState, PaginationFooter } from '@/components/entity-list';
8
+ import { Badge } from '@/components/ui/badge';
9
+ import { Button } from '@/components/ui/button';
10
+ import { Skeleton } from '@/components/ui/skeleton';
11
+ import {
12
+ Table,
13
+ TableBody,
14
+ TableCell,
15
+ TableHead,
16
+ TableHeader,
17
+ TableRow,
18
+ } from '@/components/ui/table';
19
+ import { usePersistedPageSize } from '@/hooks/use-persisted-page-size';
20
+ import { formatDate as formatDateLocalized } from '@/lib/format-date';
21
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
22
+ import { useQueryClient } from '@tanstack/react-query';
23
+ import {
24
+ CalendarIcon,
25
+ Clock,
26
+ Eye,
27
+ Laptop,
28
+ Monitor,
29
+ Pencil,
30
+ Plus,
31
+ Users,
32
+ } from 'lucide-react';
33
+ import { useRouter } from 'next/navigation';
34
+ import { useMemo, useState } from 'react';
35
+
36
+ type ApiClassList = {
37
+ data: ApiClass[];
38
+ total: number;
39
+ page: number;
40
+ pageSize: number;
41
+ lastPage: number;
42
+ };
43
+
44
+ type ScheduledClass = {
45
+ id: number;
46
+ code: string;
47
+ title: string;
48
+ deliveryMode: 'presencial' | 'online' | 'hibrida';
49
+ status: 'aberta' | 'em_andamento' | 'concluida' | 'cancelada';
50
+ startDate: string;
51
+ endDate: string;
52
+ startTime: string;
53
+ endTime: string;
54
+ capacity: number;
55
+ enrolledCount: number;
56
+ instructorName: string;
57
+ };
58
+
59
+ const PAGE_SIZES = [6, 12, 24, 48] as const;
60
+
61
+ const STATUS_VARIANT = {
62
+ aberta: 'default',
63
+ em_andamento: 'secondary',
64
+ concluida: 'outline',
65
+ cancelada: 'destructive',
66
+ } as const;
67
+
68
+ const TYPE_ICON = {
69
+ presencial: Monitor,
70
+ online: Laptop,
71
+ hibrida: Laptop,
72
+ } as const;
73
+
74
+ function toPtType(
75
+ value: ApiClass['deliveryMode']
76
+ ): ScheduledClass['deliveryMode'] {
77
+ if (value === 'presential') return 'presencial';
78
+ if (value === 'hybrid') return 'hibrida';
79
+ return 'online';
80
+ }
81
+
82
+ function toPtStatus(value: ApiClass['status']): ScheduledClass['status'] {
83
+ if (value === 'open') return 'aberta';
84
+ if (value === 'ongoing') return 'em_andamento';
85
+ if (value === 'completed') return 'concluida';
86
+ return 'cancelada';
87
+ }
88
+
89
+ function mapApiClass(item: ApiClass): ScheduledClass {
90
+ return {
91
+ id: item.id,
92
+ code: item.code,
93
+ title: item.title,
94
+ deliveryMode: toPtType(item.deliveryMode),
95
+ status: toPtStatus(item.status),
96
+ startDate: item.startDate?.slice(0, 10) ?? '',
97
+ endDate: item.endDate?.slice(0, 10) ?? item.startDate?.slice(0, 10) ?? '',
98
+ startTime: item.startTime ?? '',
99
+ endTime: item.endTime ?? '',
100
+ capacity: item.capacity,
101
+ enrolledCount: item.enrolledCount,
102
+ instructorName:
103
+ item.professorName ??
104
+ item.professor ??
105
+ item.instructorName ??
106
+ item.instructor ??
107
+ 'Sem professor',
108
+ };
109
+ }
110
+
111
+ function formatSchedule(startTime?: string, endTime?: string) {
112
+ if (startTime && endTime) return `${startTime} - ${endTime}`;
113
+ return startTime || endTime || 'Nao definido';
114
+ }
115
+
116
+ function statusLabel(status: ScheduledClass['status']) {
117
+ if (status === 'aberta') return 'Aberta';
118
+ if (status === 'em_andamento') return 'Em andamento';
119
+ if (status === 'concluida') return 'Concluida';
120
+ return 'Cancelada';
121
+ }
122
+
123
+ function typeLabel(type: ScheduledClass['deliveryMode']) {
124
+ if (type === 'presencial') return 'Presencial';
125
+ if (type === 'hibrida') return 'Hibrida';
126
+ return 'Online';
127
+ }
128
+
129
+ type CourseScheduledClassesTabProps = {
130
+ courseId: string;
131
+ courseTitle: string;
132
+ };
133
+
134
+ export function CourseScheduledClassesTab({
135
+ courseId,
136
+ courseTitle,
137
+ }: CourseScheduledClassesTabProps) {
138
+ const router = useRouter();
139
+ const queryClient = useQueryClient();
140
+ const { request, currentLocaleCode, getSettingValue } = useApp();
141
+
142
+ const [currentPage, setCurrentPage] = useState(1);
143
+ const [pageSize, setPageSize] = usePersistedPageSize({
144
+ storageKey: 'pagination:global:pageSize',
145
+ defaultValue: 12,
146
+ allowedValues: PAGE_SIZES,
147
+ });
148
+ const [sheetOpen, setSheetOpen] = useState(false);
149
+ const [editingClassId, setEditingClassId] = useState<string | undefined>();
150
+
151
+ const { data, isLoading, isFetching, refetch } = useQuery<ApiClassList>({
152
+ queryKey: ['lms-course-scheduled-classes', courseId, currentPage, pageSize],
153
+ enabled: Boolean(courseId),
154
+ queryFn: async () => {
155
+ const response = await request<ApiClassList>({
156
+ url: '/lms/classes',
157
+ method: 'GET',
158
+ params: {
159
+ page: currentPage,
160
+ pageSize,
161
+ courseId: Number(courseId),
162
+ },
163
+ });
164
+
165
+ return response.data;
166
+ },
167
+ placeholderData: (previousData) => previousData,
168
+ });
169
+
170
+ const classes = useMemo(
171
+ () => (data?.data ?? []).map((item) => mapApiClass(item)),
172
+ [data?.data]
173
+ );
174
+
175
+ const totalItems = data?.total ?? 0;
176
+
177
+ const handleCreate = () => {
178
+ setEditingClassId(undefined);
179
+ setSheetOpen(true);
180
+ };
181
+
182
+ const handleEdit = (classId: number) => {
183
+ setEditingClassId(String(classId));
184
+ setSheetOpen(true);
185
+ };
186
+
187
+ const handleSheetSuccess = async () => {
188
+ await queryClient.invalidateQueries({ queryKey: ['lms-classes-list'] });
189
+ await queryClient.invalidateQueries({ queryKey: ['lms-course-detail'] });
190
+ await refetch();
191
+ };
192
+
193
+ return (
194
+ <div className="flex flex-col gap-3">
195
+ <div className="flex flex-col gap-3 rounded-lg border border-border/70 bg-muted/20 p-4 md:flex-row md:items-center md:justify-between">
196
+ <div className="space-y-1">
197
+ <h3 className="text-sm font-semibold text-foreground">
198
+ Turmas agendadas
199
+ </h3>
200
+ <p className="text-sm text-muted-foreground">
201
+ Gerencie as turmas deste curso sem sair do editor.
202
+ </p>
203
+ </div>
204
+ <Button type="button" onClick={handleCreate} className="gap-2">
205
+ <Plus className="size-4" />
206
+ Agendar turma
207
+ </Button>
208
+ </div>
209
+
210
+ {isLoading ? (
211
+ <div className="overflow-hidden rounded-xl border border-border/70">
212
+ <Table>
213
+ <TableHeader>
214
+ <TableRow>
215
+ <TableHead>Turma</TableHead>
216
+ <TableHead>Status</TableHead>
217
+ <TableHead>Tipo</TableHead>
218
+ <TableHead>Periodo</TableHead>
219
+ <TableHead>Horario</TableHead>
220
+ <TableHead className="text-right">Matriculados</TableHead>
221
+ <TableHead className="w-40 text-right">Acoes</TableHead>
222
+ </TableRow>
223
+ </TableHeader>
224
+ <TableBody>
225
+ {Array.from({ length: 4 }).map((_, index) => (
226
+ <TableRow key={index}>
227
+ <TableCell>
228
+ <div className="space-y-1.5">
229
+ <Skeleton className="h-4 w-32" />
230
+ <Skeleton className="h-3 w-48" />
231
+ </div>
232
+ </TableCell>
233
+ <TableCell>
234
+ <Skeleton className="h-5 w-20 rounded-full" />
235
+ </TableCell>
236
+ <TableCell>
237
+ <Skeleton className="h-5 w-20 rounded-full" />
238
+ </TableCell>
239
+ <TableCell>
240
+ <Skeleton className="h-4 w-28" />
241
+ </TableCell>
242
+ <TableCell>
243
+ <Skeleton className="h-4 w-20" />
244
+ </TableCell>
245
+ <TableCell className="text-right">
246
+ <Skeleton className="ml-auto h-4 w-12" />
247
+ </TableCell>
248
+ <TableCell className="text-right">
249
+ <Skeleton className="ml-auto h-8 w-28" />
250
+ </TableCell>
251
+ </TableRow>
252
+ ))}
253
+ </TableBody>
254
+ </Table>
255
+ </div>
256
+ ) : classes.length === 0 ? (
257
+ <EmptyState
258
+ icon={<Users className="h-12 w-12" />}
259
+ title="Nenhuma turma agendada"
260
+ description="Crie a primeira turma deste curso para acompanhar os agendamentos aqui."
261
+ actionLabel="Agendar turma"
262
+ onAction={handleCreate}
263
+ actionIcon={<Plus className="mr-2 size-4" />}
264
+ />
265
+ ) : (
266
+ <div className="overflow-hidden rounded-xl border border-border/70">
267
+ <Table>
268
+ <TableHeader>
269
+ <TableRow>
270
+ <TableHead>Turma</TableHead>
271
+ <TableHead>Status</TableHead>
272
+ <TableHead>Tipo</TableHead>
273
+ <TableHead>Periodo</TableHead>
274
+ <TableHead>Horario</TableHead>
275
+ <TableHead className="text-right">Matriculados</TableHead>
276
+ <TableHead className="w-40 text-right">Acoes</TableHead>
277
+ </TableRow>
278
+ </TableHeader>
279
+ <TableBody>
280
+ {classes.map((item) => {
281
+ const TypeIcon = TYPE_ICON[item.deliveryMode];
282
+
283
+ return (
284
+ <TableRow
285
+ key={item.id}
286
+ className="cursor-pointer"
287
+ onDoubleClick={() => handleEdit(item.id)}
288
+ title="Dê um duplo clique para editar a turma"
289
+ >
290
+ <TableCell>
291
+ <div className="min-w-0">
292
+ <p className="truncate font-semibold text-foreground">
293
+ {item.code}
294
+ </p>
295
+ <p className="truncate text-xs text-muted-foreground">
296
+ {item.instructorName}
297
+ </p>
298
+ </div>
299
+ </TableCell>
300
+ <TableCell>
301
+ <Badge variant={STATUS_VARIANT[item.status]}>
302
+ {statusLabel(item.status)}
303
+ </Badge>
304
+ </TableCell>
305
+ <TableCell>
306
+ <span className="inline-flex items-center gap-1 rounded-full border bg-muted px-2 py-0.5 text-[11px] font-medium text-foreground">
307
+ <TypeIcon className="size-3" />
308
+ {typeLabel(item.deliveryMode)}
309
+ </span>
310
+ </TableCell>
311
+ <TableCell>
312
+ <div className="space-y-1 text-sm">
313
+ <div className="flex items-center gap-1.5 text-muted-foreground">
314
+ <CalendarIcon className="size-3.5" />
315
+ <span>
316
+ {formatDateLocalized(
317
+ item.startDate,
318
+ getSettingValue,
319
+ currentLocaleCode
320
+ )}
321
+ </span>
322
+ </div>
323
+ <div className="text-xs text-muted-foreground">
324
+ ate{' '}
325
+ {formatDateLocalized(
326
+ item.endDate,
327
+ getSettingValue,
328
+ currentLocaleCode
329
+ )}
330
+ </div>
331
+ </div>
332
+ </TableCell>
333
+ <TableCell>
334
+ <div className="flex items-center gap-1.5 text-sm text-muted-foreground">
335
+ <Clock className="size-3.5" />
336
+ <span>
337
+ {formatSchedule(item.startTime, item.endTime)}
338
+ </span>
339
+ </div>
340
+ </TableCell>
341
+ <TableCell className="text-right font-medium">
342
+ {item.enrolledCount}/{item.capacity}
343
+ </TableCell>
344
+ <TableCell>
345
+ <div className="flex items-center justify-end gap-2">
346
+ <Button
347
+ type="button"
348
+ variant="outline"
349
+ size="sm"
350
+ className="gap-1"
351
+ onClick={() => handleEdit(item.id)}
352
+ >
353
+ <Pencil className="size-3.5" />
354
+ Editar
355
+ </Button>
356
+ <Button
357
+ type="button"
358
+ variant="ghost"
359
+ size="sm"
360
+ className="gap-1"
361
+ onClick={() => router.push(`/lms/classes/${item.id}`)}
362
+ >
363
+ <Eye className="size-3.5" />
364
+ Detalhes
365
+ </Button>
366
+ </div>
367
+ </TableCell>
368
+ </TableRow>
369
+ );
370
+ })}
371
+ </TableBody>
372
+ </Table>
373
+ </div>
374
+ )}
375
+
376
+ {!isLoading && totalItems > 0 ? (
377
+ <PaginationFooter
378
+ currentPage={currentPage}
379
+ pageSize={pageSize}
380
+ totalItems={totalItems}
381
+ onPageChange={setCurrentPage}
382
+ onPageSizeChange={(value) => {
383
+ setPageSize(value);
384
+ setCurrentPage(1);
385
+ }}
386
+ pageSizeOptions={PAGE_SIZES}
387
+ />
388
+ ) : null}
389
+
390
+ <ClassFormSheet
391
+ open={sheetOpen}
392
+ onOpenChange={setSheetOpen}
393
+ classId={editingClassId}
394
+ defaultCourse={{ id: Number(courseId), title: courseTitle }}
395
+ lockCourse
396
+ sheetTitle={editingClassId ? 'Editar turma' : 'Agendar turma'}
397
+ sheetDescription="Use o mesmo fluxo rapido da tela de turmas para criar ou ajustar esta turma."
398
+ onSuccess={handleSheetSuccess}
399
+ />
400
+
401
+ {isFetching && !isLoading ? (
402
+ <p className="text-xs text-muted-foreground">Atualizando turmas...</p>
403
+ ) : null}
404
+ </div>
405
+ );
406
+ }
@@ -0,0 +1,134 @@
1
+ 'use client';
2
+
3
+ import { useVirtualizer } from '@tanstack/react-virtual';
4
+ import { SearchX } from 'lucide-react';
5
+ import { useCallback, useMemo, useRef } from 'react';
6
+
7
+ import { useStructureStore } from './store';
8
+ import { buildLessonCountMap, buildVisibleItems } from './tree-helpers';
9
+ import { TreeRow } from './tree-row';
10
+ import type { FlatItem } from './types';
11
+
12
+ // Row height estimates per node type (px)
13
+ const ROW_HEIGHT: Record<FlatItem['type'], number> = {
14
+ course: 34,
15
+ session: 32,
16
+ lesson: 28,
17
+ };
18
+
19
+ export function CourseTree() {
20
+ const course = useStructureStore((s) => s.course);
21
+ const sessions = useStructureStore((s) => s.sessions);
22
+ const lessons = useStructureStore((s) => s.lessons);
23
+ const expandedIds = useStructureStore((s) => s.expandedIds);
24
+ const filterQuery = useStructureStore((s) => s.filterQuery);
25
+ const activeItemId = useStructureStore((s) => s.activeItemId);
26
+ const activeItemType = useStructureStore((s) => s.activeItemType);
27
+ const selectedIds = useStructureStore((s) => s.selectedIds);
28
+
29
+ const scrollRef = useRef<HTMLDivElement>(null);
30
+
31
+ // ── Derived state ───────────────────────────────────────────────────────────
32
+ const { items, matchedIds, expandedBySearch, resultCount } = useMemo(
33
+ () =>
34
+ buildVisibleItems(course, sessions, lessons, expandedIds, filterQuery),
35
+ [course, sessions, lessons, expandedIds, filterQuery]
36
+ );
37
+
38
+ const lessonCountMap = useMemo(() => buildLessonCountMap(lessons), [lessons]);
39
+
40
+ const estimateSize = useCallback(
41
+ (index: number) => ROW_HEIGHT[items[index]?.type ?? 'lesson'] ?? 32,
42
+ [items]
43
+ );
44
+
45
+ // ── Virtualizer ─────────────────────────────────────────────────────────────
46
+ // eslint-disable-next-line react-hooks/incompatible-library -- TanStack Virtual returns non-memoizable functions; this is expected and safe here
47
+ const virtualizer = useVirtualizer({
48
+ count: items.length,
49
+ getScrollElement: () => scrollRef.current,
50
+ estimateSize,
51
+ overscan: 8,
52
+ });
53
+
54
+ const isSearchActive = filterQuery.trim().length > 0;
55
+ const isEmpty = items.length === 0;
56
+
57
+ return (
58
+ <div className="flex flex-col h-full min-h-0">
59
+ {/* Result count bar */}
60
+ {isSearchActive && !isEmpty && (
61
+ <div className="px-3 py-1 shrink-0 border-b">
62
+ <span className="text-[0.65rem] text-muted-foreground">
63
+ {resultCount === 1 ? '1 resultado' : `${resultCount} resultados`}
64
+ </span>
65
+ </div>
66
+ )}
67
+
68
+ {/* Empty state */}
69
+ {isEmpty && (
70
+ <div className="flex flex-col items-center justify-center gap-2 py-12 px-4 text-center">
71
+ <SearchX className="size-8 text-muted-foreground/40" />
72
+ <p className="text-sm text-muted-foreground">
73
+ {isSearchActive
74
+ ? `Nenhum resultado para "${filterQuery.trim()}"`
75
+ : 'Nenhum item no curso.'}
76
+ </p>
77
+ </div>
78
+ )}
79
+
80
+ {/* Virtualised tree */}
81
+ {!isEmpty && (
82
+ <div
83
+ ref={scrollRef}
84
+ className="overflow-y-auto flex-1 min-h-0"
85
+ style={{ contain: 'strict' }}
86
+ >
87
+ <div
88
+ style={{
89
+ height: virtualizer.getTotalSize(),
90
+ width: '100%',
91
+ position: 'relative',
92
+ }}
93
+ >
94
+ {virtualizer.getVirtualItems().map((virtualRow) => {
95
+ const item = items[virtualRow.index];
96
+ if (!item) return null;
97
+
98
+ return (
99
+ <div
100
+ key={virtualRow.key}
101
+ data-index={virtualRow.index}
102
+ style={{
103
+ position: 'absolute',
104
+ top: 0,
105
+ left: 0,
106
+ width: '100%',
107
+ height: `${virtualRow.size}px`,
108
+ transform: `translateY(${virtualRow.start}px)`,
109
+ padding: '1px 4px',
110
+ }}
111
+ >
112
+ <TreeRow
113
+ item={item}
114
+ isActive={
115
+ activeItemId === item.id && activeItemType === item.type
116
+ }
117
+ isSelected={selectedIds.has(`${item.type}:${item.id}`)}
118
+ query={filterQuery}
119
+ isMatched={matchedIds.has(item.id)}
120
+ isEffectivelyExpanded={
121
+ expandedIds.has(item.id) || expandedBySearch.has(item.id)
122
+ }
123
+ lessonCountMap={lessonCountMap}
124
+ visibleItems={items}
125
+ />
126
+ </div>
127
+ );
128
+ })}
129
+ </div>
130
+ </div>
131
+ )}
132
+ </div>
133
+ );
134
+ }
@@ -0,0 +1,113 @@
1
+ 'use client';
2
+
3
+ import { Badge } from '@/components/ui/badge';
4
+ import { Separator } from '@/components/ui/separator';
5
+ import { BookOpen, Globe, Hash, Tag } from 'lucide-react';
6
+ import { useStructureStore } from './store';
7
+
8
+ export function DetailCourse() {
9
+ const course = useStructureStore((s) => s.course);
10
+ const sessions = useStructureStore((s) => s.sessions);
11
+ const lessons = useStructureStore((s) => s.lessons);
12
+
13
+ const totalMinutes = lessons.reduce((sum, l) => sum + l.duration, 0);
14
+ const hours = Math.floor(totalMinutes / 60);
15
+ const minutes = totalMinutes % 60;
16
+
17
+ return (
18
+ <div className="flex flex-col overflow-y-auto h-full">
19
+ {/* Header */}
20
+ <div className="flex items-center gap-3 px-4 py-4 border-b bg-muted/30 shrink-0">
21
+ <div className="flex size-10 items-center justify-center rounded-lg bg-primary/10 shrink-0">
22
+ <BookOpen className="size-5 text-primary" />
23
+ </div>
24
+ <div className="min-w-0 flex-1">
25
+ <h2 className="text-base font-semibold truncate">{course.title}</h2>
26
+ <p className="text-xs text-muted-foreground">{course.slug}</p>
27
+ </div>
28
+ <Badge
29
+ variant={course.published ? 'default' : 'secondary'}
30
+ className="shrink-0"
31
+ >
32
+ {course.published ? 'Publicado' : 'Rascunho'}
33
+ </Badge>
34
+ </div>
35
+
36
+ <div className="flex flex-col gap-5 p-4">
37
+ {/* Stats */}
38
+ <div className="grid grid-cols-3 gap-3">
39
+ <StatCard label="Sessões" value={sessions.length} />
40
+ <StatCard label="Aulas" value={lessons.length} />
41
+ <StatCard
42
+ label="Duração"
43
+ value={hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`}
44
+ />
45
+ </div>
46
+
47
+ <Separator />
48
+
49
+ {/* Info */}
50
+ <div className="flex flex-col gap-2.5">
51
+ <InfoRow
52
+ icon={<Hash className="size-3.5" />}
53
+ label="Slug"
54
+ value={course.slug}
55
+ />
56
+ <InfoRow
57
+ icon={<Tag className="size-3.5" />}
58
+ label="Slug"
59
+ value={course.slug}
60
+ />
61
+ <InfoRow
62
+ icon={<Globe className="size-3.5" />}
63
+ label="Status"
64
+ value={course.published ? 'Publicado' : 'Rascunho'}
65
+ />
66
+ </div>
67
+
68
+ {course.description && (
69
+ <>
70
+ <Separator />
71
+ <div>
72
+ <p className="text-xs font-medium text-muted-foreground mb-1.5">
73
+ Descrição
74
+ </p>
75
+ <p className="text-sm leading-relaxed text-foreground/90">
76
+ {course.description}
77
+ </p>
78
+ </div>
79
+ </>
80
+ )}
81
+ </div>
82
+ </div>
83
+ );
84
+ }
85
+
86
+ function StatCard({ label, value }: { label: string; value: string | number }) {
87
+ return (
88
+ <div className="flex flex-col items-center rounded-lg border bg-muted/30 py-3 gap-0.5">
89
+ <span className="text-lg font-bold tabular-nums">{value}</span>
90
+ <span className="text-[0.65rem] text-muted-foreground">{label}</span>
91
+ </div>
92
+ );
93
+ }
94
+
95
+ function InfoRow({
96
+ icon,
97
+ label,
98
+ value,
99
+ }: {
100
+ icon: React.ReactNode;
101
+ label: string;
102
+ value: string;
103
+ }) {
104
+ return (
105
+ <div className="flex items-center gap-2">
106
+ <span className="text-muted-foreground shrink-0">{icon}</span>
107
+ <span className="text-xs text-muted-foreground w-14 shrink-0">
108
+ {label}
109
+ </span>
110
+ <span className="text-sm truncate">{value}</span>
111
+ </div>
112
+ );
113
+ }