@hed-hog/lms 0.0.330 → 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.
- package/dist/class-group/class-group.controller.d.ts +3 -3
- package/dist/class-group/class-group.service.d.ts +3 -3
- package/dist/course/course.service.d.ts.map +1 -1
- package/dist/course/course.service.js +12 -20
- package/dist/course/course.service.js.map +1 -1
- package/dist/enterprise/enterprise.controller.d.ts +72 -0
- package/dist/enterprise/enterprise.controller.d.ts.map +1 -1
- package/dist/enterprise/enterprise.controller.js +10 -0
- package/dist/enterprise/enterprise.controller.js.map +1 -1
- package/dist/enterprise/enterprise.service.d.ts +78 -0
- package/dist/enterprise/enterprise.service.d.ts.map +1 -1
- package/dist/enterprise/enterprise.service.js +413 -40
- package/dist/enterprise/enterprise.service.js.map +1 -1
- package/dist/enterprise/training/training-admin.controller.d.ts +6 -3
- package/dist/enterprise/training/training-admin.controller.d.ts.map +1 -1
- package/dist/enterprise/training/training-admin.controller.js +10 -6
- package/dist/enterprise/training/training-admin.controller.js.map +1 -1
- package/dist/enterprise/training/training-admin.service.d.ts +8 -2
- package/dist/enterprise/training/training-admin.service.d.ts.map +1 -1
- package/dist/enterprise/training/training-admin.service.js +108 -52
- package/dist/enterprise/training/training-admin.service.js.map +1 -1
- package/dist/enterprise/training/training-viewer.controller.d.ts +3 -0
- package/dist/enterprise/training/training-viewer.controller.d.ts.map +1 -1
- package/dist/evaluation/evaluation.controller.d.ts +4 -4
- package/dist/evaluation/evaluation.service.d.ts +4 -4
- package/dist/instructor/dto/create-instructor-skill.dto.d.ts +0 -4
- package/dist/instructor/dto/create-instructor-skill.dto.d.ts.map +1 -1
- package/dist/instructor/dto/create-instructor-skill.dto.js +0 -21
- package/dist/instructor/dto/create-instructor-skill.dto.js.map +1 -1
- package/dist/instructor/dto/update-instructor-skill.dto.d.ts +0 -4
- package/dist/instructor/dto/update-instructor-skill.dto.d.ts.map +1 -1
- package/dist/instructor/dto/update-instructor-skill.dto.js +0 -22
- package/dist/instructor/dto/update-instructor-skill.dto.js.map +1 -1
- package/dist/instructor/instructor-skill.controller.d.ts +4 -4
- package/dist/instructor/instructor-skill.service.d.ts +4 -7
- package/dist/instructor/instructor-skill.service.d.ts.map +1 -1
- package/dist/instructor/instructor-skill.service.js +2 -89
- package/dist/instructor/instructor-skill.service.js.map +1 -1
- package/dist/instructor/instructor.controller.d.ts +20 -0
- package/dist/instructor/instructor.controller.d.ts.map +1 -1
- package/dist/instructor/instructor.controller.js +19 -0
- package/dist/instructor/instructor.controller.js.map +1 -1
- package/dist/instructor/instructor.service.d.ts +25 -0
- package/dist/instructor/instructor.service.d.ts.map +1 -1
- package/dist/instructor/instructor.service.js +70 -18
- package/dist/instructor/instructor.service.js.map +1 -1
- package/dist/lms.module.d.ts.map +1 -1
- package/dist/lms.module.js.map +1 -1
- package/hedhog/data/route.yaml +23 -1
- package/hedhog/frontend/app/_components/class-form-sheet.tsx.ejs +42 -24
- package/hedhog/frontend/app/_components/create-lms-instructor-sheet.tsx.ejs +591 -0
- package/hedhog/frontend/app/certificates/issued/page.tsx.ejs +6 -1
- package/hedhog/frontend/app/certificates/models/page.tsx.ejs +7 -2
- package/hedhog/frontend/app/classes/[id]/page.tsx.ejs +17 -17
- package/hedhog/frontend/app/classes/page.tsx.ejs +6 -1
- package/hedhog/frontend/app/courses/[id]/_components/CourseClassificationCard.tsx.ejs +3 -33
- package/hedhog/frontend/app/courses/[id]/_components/CourseContentCard.tsx.ejs +9 -9
- package/hedhog/frontend/app/courses/[id]/_components/CourseMainInfoCard.tsx.ejs +109 -0
- package/hedhog/frontend/app/courses/[id]/_components/CourseMultiEntityPicker.tsx.ejs +42 -15
- package/hedhog/frontend/app/courses/[id]/_components/CourseRelationsCard.tsx.ejs +76 -81
- package/hedhog/frontend/app/courses/[id]/_components/CourseSummaryCard.tsx.ejs +60 -0
- package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +3 -3
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-scheduled-classes-tab.tsx.ejs +406 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-dnd.tsx.ejs +1 -1
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree.tsx.ejs +134 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-course.tsx.ejs +113 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +314 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-session.tsx.ejs +174 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +242 -33
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +228 -152
- package/hedhog/frontend/app/courses/[id]/structure/_components/mock-data.ts.ejs +185 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/shortcuts-help.tsx.ejs +71 -31
- package/hedhog/frontend/app/courses/page.tsx.ejs +6 -1
- package/hedhog/frontend/app/enterprise/[id]/page.tsx.ejs +37 -41
- package/hedhog/frontend/app/enterprise/_components/enterprise-activity-timeline.tsx.ejs +87 -0
- package/hedhog/frontend/app/enterprise/_components/enterprise-admin-create-sheet.tsx.ejs +4 -0
- package/hedhog/frontend/app/enterprise/_components/enterprise-administrators-tab.tsx.ejs +31 -5
- package/hedhog/frontend/app/enterprise/_components/enterprise-classes-tab.tsx.ejs +79 -20
- package/hedhog/frontend/app/enterprise/_components/enterprise-company-identity-card.tsx.ejs +11 -2
- package/hedhog/frontend/app/enterprise/_components/enterprise-course-edit-sheet.tsx.ejs +201 -0
- package/hedhog/frontend/app/enterprise/_components/enterprise-courses-tab.tsx.ejs +55 -24
- package/hedhog/frontend/app/enterprise/_components/enterprise-detail-sheet.tsx.ejs +430 -296
- package/hedhog/frontend/app/enterprise/_components/enterprise-mocks.ts.ejs +277 -0
- package/hedhog/frontend/app/enterprise/_components/enterprise-overview-analytics.tsx.ejs +205 -0
- package/hedhog/frontend/app/enterprise/_components/enterprise-person-edit-sheet.tsx.ejs +97 -0
- package/hedhog/frontend/app/enterprise/_components/enterprise-sheet.tsx.ejs +82 -57
- package/hedhog/frontend/app/enterprise/_components/enterprise-student-create-sheet.tsx.ejs +4 -0
- package/hedhog/frontend/app/enterprise/_components/enterprise-students-tab.tsx.ejs +60 -22
- package/hedhog/frontend/app/enterprise/_components/enterprise-types.ts.ejs +54 -0
- package/hedhog/frontend/app/enterprise/_components/enterprise-user-create-sheet.tsx.ejs +211 -0
- package/hedhog/frontend/app/enterprise/page.tsx.ejs +39 -7
- package/hedhog/frontend/app/evaluations/_components/evaluation-topic-form-sheet.tsx.ejs +1 -1
- package/hedhog/frontend/app/exams/[id]/questions/page.tsx.ejs +6 -1
- package/hedhog/frontend/app/exams/page.tsx.ejs +12 -3
- package/hedhog/frontend/app/instructor-skills/page.tsx.ejs +51 -104
- package/hedhog/frontend/app/instructors/_components/instructor-form-sheet.tsx.ejs +712 -427
- package/hedhog/frontend/app/instructors/page.tsx.ejs +77 -53
- package/hedhog/frontend/app/paths/page.tsx.ejs +14 -5
- package/hedhog/frontend/app/reports/courses/page.tsx.ejs +5 -5
- package/hedhog/frontend/app/reports/dashboard/page.tsx.ejs +8 -8
- package/hedhog/frontend/app/reports/evaluations/page.tsx.ejs +6 -1
- package/hedhog/frontend/app/reports/page.tsx.ejs +7 -7
- package/hedhog/frontend/app/reports/students/page.tsx.ejs +6 -6
- package/hedhog/frontend/app/training/page.tsx.ejs +8 -3
- package/hedhog/frontend/messages/en.json +394 -55
- package/hedhog/frontend/messages/pt.json +389 -48
- package/hedhog/frontend/widgets/active-classes-kpi.tsx.ejs +1 -1
- package/hedhog/frontend/widgets/active-courses-kpi.tsx.ejs +1 -1
- package/hedhog/frontend/widgets/approval-rate-kpi.tsx.ejs +1 -1
- package/hedhog/frontend/widgets/class-calendar.tsx.ejs +2 -2
- package/hedhog/frontend/widgets/completion-rate-kpi.tsx.ejs +1 -1
- package/hedhog/frontend/widgets/issued-certificates-kpi.tsx.ejs +1 -1
- package/hedhog/frontend/widgets/total-students-kpi.tsx.ejs +1 -1
- package/hedhog/table/enterprise_student_license_event.yaml +30 -0
- package/hedhog/table/instructor_qualification.yaml +1 -1
- package/hedhog/table/instructor_skill.yaml +0 -11
- package/package.json +8 -8
- package/src/course/course.service.ts +12 -24
- package/src/enterprise/enterprise.controller.ts +5 -0
- package/src/enterprise/enterprise.service.ts +507 -29
- package/src/enterprise/training/training-admin.controller.ts +4 -0
- package/src/enterprise/training/training-admin.service.ts +115 -51
- package/src/instructor/dto/create-instructor-skill.dto.ts +0 -17
- package/src/instructor/dto/update-instructor-skill.dto.ts +0 -18
- package/src/instructor/instructor-skill.service.ts +2 -97
- package/src/instructor/instructor.controller.ts +16 -0
- package/src/instructor/instructor.service.ts +85 -10
- package/src/lms.module.ts +1 -0
package/hedhog/frontend/app/courses/[id]/structure/_components/course-scheduled-classes-tab.tsx.ejs
ADDED
|
@@ -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
|
+
}
|
|
@@ -295,7 +295,7 @@ export function CourseTreeDnd() {
|
|
|
295
295
|
<p className="text-sm font-medium text-foreground/70">
|
|
296
296
|
Nenhuma sessão
|
|
297
297
|
</p>
|
|
298
|
-
<p className="text-xs text-muted-foreground leading-relaxed max-w-
|
|
298
|
+
<p className="text-xs text-muted-foreground leading-relaxed max-w-45">
|
|
299
299
|
Adicione a primeira sessão para começar a organizar o conteúdo
|
|
300
300
|
do curso.
|
|
301
301
|
</p>
|
|
@@ -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
|
+
}
|