@hed-hog/lms 0.0.319 → 0.0.320
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 +65 -2
- package/dist/class-group/class-group.controller.d.ts.map +1 -1
- package/dist/class-group/class-group.controller.js +35 -0
- package/dist/class-group/class-group.controller.js.map +1 -1
- package/dist/class-group/class-group.service.d.ts +67 -2
- package/dist/class-group/class-group.service.d.ts.map +1 -1
- package/dist/class-group/class-group.service.js +164 -13
- package/dist/class-group/class-group.service.js.map +1 -1
- package/dist/class-group/dto/create-class-group.dto.d.ts.map +1 -1
- package/dist/class-group/dto/create-class-group.dto.js +2 -1
- package/dist/class-group/dto/create-class-group.dto.js.map +1 -1
- package/dist/class-group/dto/material.dto.d.ts +18 -0
- package/dist/class-group/dto/material.dto.d.ts.map +1 -0
- package/dist/class-group/dto/material.dto.js +86 -0
- package/dist/class-group/dto/material.dto.js.map +1 -0
- package/dist/course/course.service.d.ts +2 -0
- package/dist/course/course.service.d.ts.map +1 -1
- package/dist/course/course.service.js +27 -2
- package/dist/course/course.service.js.map +1 -1
- package/dist/course/dto/create-course.dto.d.ts +2 -2
- package/dist/course/dto/create-course.dto.d.ts.map +1 -1
- package/dist/course/dto/create-course.dto.js.map +1 -1
- package/dist/enterprise/enterprise.controller.d.ts +7 -1
- package/dist/enterprise/enterprise.controller.d.ts.map +1 -1
- package/dist/enterprise/enterprise.controller.js +72 -2
- package/dist/enterprise/enterprise.controller.js.map +1 -1
- package/dist/enterprise/enterprise.module.d.ts.map +1 -1
- package/dist/enterprise/enterprise.module.js +2 -1
- package/dist/enterprise/enterprise.module.js.map +1 -1
- package/dist/enterprise/enterprise.service.d.ts +3 -0
- package/dist/enterprise/enterprise.service.d.ts.map +1 -1
- package/dist/enterprise/enterprise.service.js +84 -1
- package/dist/enterprise/enterprise.service.js.map +1 -1
- package/dist/enterprise/training/enterprise-training.module.d.ts +3 -0
- package/dist/enterprise/training/enterprise-training.module.d.ts.map +1 -0
- package/dist/enterprise/training/enterprise-training.module.js +40 -0
- package/dist/enterprise/training/enterprise-training.module.js.map +1 -0
- package/dist/enterprise/training/training-admin.controller.d.ts +525 -0
- package/dist/enterprise/training/training-admin.controller.d.ts.map +1 -0
- package/dist/enterprise/training/training-admin.controller.js +385 -0
- package/dist/enterprise/training/training-admin.controller.js.map +1 -0
- package/dist/enterprise/training/training-admin.service.d.ts +582 -0
- package/dist/enterprise/training/training-admin.service.d.ts.map +1 -0
- package/dist/enterprise/training/training-admin.service.js +2283 -0
- package/dist/enterprise/training/training-admin.service.js.map +1 -0
- package/dist/enterprise/training/training-instructor.controller.d.ts +260 -0
- package/dist/enterprise/training/training-instructor.controller.d.ts.map +1 -0
- package/dist/enterprise/training/training-instructor.controller.js +199 -0
- package/dist/enterprise/training/training-instructor.controller.js.map +1 -0
- package/dist/enterprise/training/training-instructor.service.d.ts +280 -0
- package/dist/enterprise/training/training-instructor.service.d.ts.map +1 -0
- package/dist/enterprise/training/training-instructor.service.js +1218 -0
- package/dist/enterprise/training/training-instructor.service.js.map +1 -0
- package/dist/enterprise/training/training-student.controller.d.ts +168 -0
- package/dist/enterprise/training/training-student.controller.d.ts.map +1 -0
- package/dist/enterprise/training/training-student.controller.js +104 -0
- package/dist/enterprise/training/training-student.controller.js.map +1 -0
- package/dist/enterprise/training/training-student.service.d.ts +185 -0
- package/dist/enterprise/training/training-student.service.d.ts.map +1 -0
- package/dist/enterprise/training/training-student.service.js +674 -0
- package/dist/enterprise/training/training-student.service.js.map +1 -0
- package/dist/enterprise/training/training-viewer.controller.d.ts +298 -0
- package/dist/enterprise/training/training-viewer.controller.d.ts.map +1 -0
- package/dist/enterprise/training/training-viewer.controller.js +178 -0
- package/dist/enterprise/training/training-viewer.controller.js.map +1 -0
- package/dist/evaluation/dto/create-evaluation-topic.dto.d.ts +18 -0
- package/dist/evaluation/dto/create-evaluation-topic.dto.d.ts.map +1 -0
- package/dist/evaluation/dto/create-evaluation-topic.dto.js +59 -0
- package/dist/evaluation/dto/create-evaluation-topic.dto.js.map +1 -0
- package/dist/evaluation/dto/update-evaluation-topic.dto.d.ts +6 -0
- package/dist/evaluation/dto/update-evaluation-topic.dto.d.ts.map +1 -0
- package/dist/evaluation/dto/update-evaluation-topic.dto.js +9 -0
- package/dist/evaluation/dto/update-evaluation-topic.dto.js.map +1 -0
- package/dist/evaluation/evaluation.controller.d.ts +66 -0
- package/dist/evaluation/evaluation.controller.d.ts.map +1 -1
- package/dist/evaluation/evaluation.controller.js +73 -0
- package/dist/evaluation/evaluation.controller.js.map +1 -1
- package/dist/evaluation/evaluation.service.d.ts +71 -0
- package/dist/evaluation/evaluation.service.d.ts.map +1 -1
- package/dist/evaluation/evaluation.service.js +121 -0
- package/dist/evaluation/evaluation.service.js.map +1 -1
- package/dist/instructor/instructor.service.js +6 -6
- package/dist/instructor/instructor.service.js.map +1 -1
- package/dist/lms.module.d.ts.map +1 -1
- package/dist/lms.module.js +3 -0
- package/dist/lms.module.js.map +1 -1
- package/hedhog/data/menu.yaml +19 -2
- package/hedhog/data/route.yaml +730 -0
- package/hedhog/frontend/app/_components/class-form-sheet.tsx.ejs +74 -8
- package/hedhog/frontend/app/_components/course-avatar.tsx.ejs +27 -47
- package/hedhog/frontend/app/classes/[id]/_components/event-summary-popover.tsx.ejs +15 -15
- package/hedhog/frontend/app/classes/[id]/_components/quick-create-session-popover.tsx.ejs +5 -5
- package/hedhog/frontend/app/classes/[id]/page.tsx.ejs +2141 -308
- package/hedhog/frontend/app/classes/page.tsx.ejs +8 -7
- package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +21 -8
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +10 -6
- package/hedhog/frontend/app/evaluations/_components/evaluation-topic-form-sheet.tsx.ejs +201 -0
- package/hedhog/frontend/app/evaluations/_components/evaluation-topic-types.ts.ejs +49 -0
- package/hedhog/frontend/app/evaluations/page.tsx.ejs +621 -1250
- package/hedhog/frontend/app/instructors/page.tsx.ejs +22 -20
- package/hedhog/frontend/app/reports/evaluations/page.tsx.ejs +1278 -0
- package/hedhog/frontend/messages/en.json +98 -7
- package/hedhog/frontend/messages/pt.json +98 -7
- package/hedhog/table/course_class_group_material.yaml +45 -0
- package/package.json +8 -8
- package/src/class-group/class-group.controller.ts +30 -0
- package/src/class-group/class-group.service.ts +176 -5
- package/src/class-group/dto/create-class-group.dto.ts +8 -8
- package/src/class-group/dto/material.dto.ts +69 -0
- package/src/course/course.service.ts +54 -21
- package/src/course/dto/create-course.dto.ts +8 -8
- package/src/enterprise/enterprise.controller.ts +62 -1
- package/src/enterprise/enterprise.module.ts +2 -1
- package/src/enterprise/enterprise.service.ts +84 -1
- package/src/enterprise/training/enterprise-training.module.ts +27 -0
- package/src/enterprise/training/training-admin.controller.ts +278 -0
- package/src/enterprise/training/training-admin.service.ts +2523 -0
- package/src/enterprise/training/training-instructor.controller.ts +141 -0
- package/src/enterprise/training/training-instructor.service.ts +1303 -0
- package/src/enterprise/training/training-student.controller.ts +65 -0
- package/src/enterprise/training/training-student.service.ts +762 -0
- package/src/enterprise/training/training-viewer.controller.ts +115 -0
- package/src/evaluation/dto/create-evaluation-topic.dto.ts +48 -0
- package/src/evaluation/dto/update-evaluation-topic.dto.ts +6 -0
- package/src/evaluation/evaluation.controller.ts +63 -1
- package/src/evaluation/evaluation.service.ts +150 -1
- package/src/instructor/instructor.service.ts +4 -4
- package/src/lms.module.ts +3 -0
|
@@ -0,0 +1,1303 @@
|
|
|
1
|
+
import { PrismaService } from '@hed-hog/api-prisma';
|
|
2
|
+
import { FileService } from '@hed-hog/core';
|
|
3
|
+
import { Injectable, NotFoundException } from '@nestjs/common';
|
|
4
|
+
|
|
5
|
+
@Injectable()
|
|
6
|
+
export class TrainingInstructorService {
|
|
7
|
+
constructor(
|
|
8
|
+
private readonly prisma: PrismaService,
|
|
9
|
+
private readonly fileService: FileService,
|
|
10
|
+
) {}
|
|
11
|
+
|
|
12
|
+
async getDashboard(userId: number, enterpriseId?: number) {
|
|
13
|
+
const instructor = await this.prisma.instructor.findFirst({
|
|
14
|
+
where: { person: { person_user: { some: { user_id: userId } } } },
|
|
15
|
+
select: { id: true, person: { select: { name: true } } },
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
if (!instructor) {
|
|
19
|
+
throw new NotFoundException('No instructor profile found for this user');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const now = new Date();
|
|
23
|
+
const weekStart = this.startOfWeek(now);
|
|
24
|
+
const weekEnd = this.endOfDay(this.addDays(weekStart, 6));
|
|
25
|
+
|
|
26
|
+
const enterpriseFilter = enterpriseId !== undefined
|
|
27
|
+
? { enterprise_class_group: { some: { enterprise_id: enterpriseId } } }
|
|
28
|
+
: {};
|
|
29
|
+
|
|
30
|
+
// Collect class group IDs where this instructor has sessions
|
|
31
|
+
const sessionRows = await this.prisma.course_class_session.findMany({
|
|
32
|
+
where: {
|
|
33
|
+
course_class_session_instructor: { some: { instructor_id: instructor.id } },
|
|
34
|
+
course_class_group: { status: { in: ['open', 'ongoing'] }, ...enterpriseFilter },
|
|
35
|
+
},
|
|
36
|
+
select: { course_class_group_id: true },
|
|
37
|
+
distinct: ['course_class_group_id'],
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const cgIds = sessionRows
|
|
41
|
+
.map((r) => r.course_class_group_id)
|
|
42
|
+
.filter((id): id is number => id != null);
|
|
43
|
+
|
|
44
|
+
const [kpis, weeklyAgenda, myClasses, receivedFeedback] = await Promise.all([
|
|
45
|
+
this.getKpis(instructor.id, cgIds, weekStart, weekEnd),
|
|
46
|
+
this.getWeeklyAgenda(instructor.id, weekStart, weekEnd),
|
|
47
|
+
this.getMyClasses(cgIds, now),
|
|
48
|
+
this.getReceivedFeedbackSummary(instructor.id),
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
kpis,
|
|
53
|
+
weeklyAgenda,
|
|
54
|
+
myClasses,
|
|
55
|
+
receivedFeedback,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Private helpers ───────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
private async getKpis(
|
|
62
|
+
instructorId: number,
|
|
63
|
+
cgIds: number[],
|
|
64
|
+
weekStart: Date,
|
|
65
|
+
weekEnd: Date,
|
|
66
|
+
) {
|
|
67
|
+
const [totalStudents, sessionsThisWeek, avgScore] = await Promise.all([
|
|
68
|
+
cgIds.length > 0
|
|
69
|
+
? this.prisma.course_enrollment.count({
|
|
70
|
+
where: {
|
|
71
|
+
course_class_group_id: { in: cgIds },
|
|
72
|
+
status: { not: 'cancelled' },
|
|
73
|
+
},
|
|
74
|
+
})
|
|
75
|
+
: Promise.resolve(0),
|
|
76
|
+
this.prisma.course_class_session.count({
|
|
77
|
+
where: {
|
|
78
|
+
course_class_session_instructor: { some: { instructor_id: instructorId } },
|
|
79
|
+
session_date: { gte: weekStart, lte: weekEnd },
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
this.prisma.exam_attempt.aggregate({
|
|
83
|
+
where: {
|
|
84
|
+
status: 'completed',
|
|
85
|
+
score: { not: null },
|
|
86
|
+
course_enrollment: {
|
|
87
|
+
course_class_group_id: cgIds.length > 0 ? { in: cgIds } : undefined,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
_avg: { score: true },
|
|
91
|
+
}),
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
activeClasses: {
|
|
96
|
+
value: cgIds.length,
|
|
97
|
+
change: null,
|
|
98
|
+
changeType: 'absolute' as const,
|
|
99
|
+
},
|
|
100
|
+
totalStudents: {
|
|
101
|
+
value: totalStudents,
|
|
102
|
+
change: null,
|
|
103
|
+
changeType: 'absolute' as const,
|
|
104
|
+
},
|
|
105
|
+
sessionsThisWeek: {
|
|
106
|
+
value: sessionsThisWeek,
|
|
107
|
+
change: null,
|
|
108
|
+
changeType: 'absolute' as const,
|
|
109
|
+
},
|
|
110
|
+
averageScore: {
|
|
111
|
+
value: this.round(avgScore._avg.score ?? 0, 1),
|
|
112
|
+
change: null,
|
|
113
|
+
changeType: 'absolute' as const,
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private async getWeeklyAgenda(instructorId: number, weekStart: Date, weekEnd: Date) {
|
|
119
|
+
const sessions = await this.prisma.course_class_session.findMany({
|
|
120
|
+
where: {
|
|
121
|
+
course_class_session_instructor: { some: { instructor_id: instructorId } },
|
|
122
|
+
session_date: { gte: weekStart, lte: weekEnd },
|
|
123
|
+
},
|
|
124
|
+
orderBy: [{ session_date: 'asc' }, { start_time: 'asc' }],
|
|
125
|
+
include: {
|
|
126
|
+
course_class_group: {
|
|
127
|
+
include: {
|
|
128
|
+
course: { select: { title: true } },
|
|
129
|
+
_count: {
|
|
130
|
+
select: {
|
|
131
|
+
course_enrollment: { where: { status: { not: 'cancelled' } } },
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
return sessions.map((s) => ({
|
|
140
|
+
sessionId: s.id,
|
|
141
|
+
classGroupId: s.course_class_group?.id ?? null,
|
|
142
|
+
date: s.session_date,
|
|
143
|
+
time: s.start_time,
|
|
144
|
+
endTime: s.end_time ?? null,
|
|
145
|
+
className: s.course_class_group?.title ?? null,
|
|
146
|
+
courseName: s.course_class_group?.course?.title ?? null,
|
|
147
|
+
topic: (s as any).topic ?? null,
|
|
148
|
+
location: s.location ?? null,
|
|
149
|
+
meetingUrl: s.meeting_url ?? null,
|
|
150
|
+
deliveryMode: s.course_class_group?.delivery_mode ?? null,
|
|
151
|
+
studentCount: s.course_class_group?._count.course_enrollment ?? 0,
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private async getMyClasses(cgIds: number[], now: Date) {
|
|
156
|
+
if (cgIds.length === 0) return [];
|
|
157
|
+
|
|
158
|
+
const classGroups = await this.prisma.course_class_group.findMany({
|
|
159
|
+
where: { id: { in: cgIds } },
|
|
160
|
+
orderBy: { start_date: 'desc' },
|
|
161
|
+
include: {
|
|
162
|
+
course: {
|
|
163
|
+
select: {
|
|
164
|
+
id: true,
|
|
165
|
+
title: true,
|
|
166
|
+
course_image: {
|
|
167
|
+
where: { image_type: { slug: 'course-logo' } },
|
|
168
|
+
orderBy: { is_primary: 'desc' as const },
|
|
169
|
+
take: 1,
|
|
170
|
+
select: { file: { select: { location: true } } },
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
_count: {
|
|
175
|
+
select: {
|
|
176
|
+
course_enrollment: { where: { status: { not: 'cancelled' } } },
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
course_class_session: {
|
|
180
|
+
where: { session_date: { gte: now } },
|
|
181
|
+
orderBy: { session_date: 'asc' },
|
|
182
|
+
take: 1,
|
|
183
|
+
select: { session_date: true, start_time: true, location: true },
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
return classGroups.map((cg) => {
|
|
189
|
+
const nextSession = cg.course_class_session[0] ?? null;
|
|
190
|
+
const start = cg.start_date?.getTime() ?? 0;
|
|
191
|
+
const end = cg.end_date?.getTime() ?? 0;
|
|
192
|
+
const elapsed = end > start ? ((now.getTime() - start) / (end - start)) * 100 : 0;
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
id: cg.id,
|
|
196
|
+
name: cg.title,
|
|
197
|
+
code: cg.code ?? null,
|
|
198
|
+
courseId: cg.course?.id ?? null,
|
|
199
|
+
courseName: cg.course?.title ?? null,
|
|
200
|
+
courseLogoUrl: cg.course?.course_image?.[0]?.file?.location ?? null,
|
|
201
|
+
studentCount: cg._count.course_enrollment,
|
|
202
|
+
nextSession: nextSession
|
|
203
|
+
? {
|
|
204
|
+
date: nextSession.session_date,
|
|
205
|
+
time: nextSession.start_time,
|
|
206
|
+
location: nextSession.location ?? null,
|
|
207
|
+
}
|
|
208
|
+
: null,
|
|
209
|
+
progress: this.round(Math.min(Math.max(elapsed, 0), 100), 0),
|
|
210
|
+
status: cg.status,
|
|
211
|
+
deliveryMode: cg.delivery_mode ?? null,
|
|
212
|
+
pendingEvaluations: 0, // populated separately if needed
|
|
213
|
+
};
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private async getReceivedFeedbackSummary(instructorId: number) {
|
|
218
|
+
// Find session IDs taught by this instructor
|
|
219
|
+
const sessionRows = await this.prisma.course_class_session.findMany({
|
|
220
|
+
where: {
|
|
221
|
+
course_class_session_instructor: { some: { instructor_id: instructorId } },
|
|
222
|
+
},
|
|
223
|
+
select: {
|
|
224
|
+
id: true,
|
|
225
|
+
title: true,
|
|
226
|
+
session_date: true,
|
|
227
|
+
course_class_group: { select: { title: true } },
|
|
228
|
+
},
|
|
229
|
+
orderBy: { session_date: 'desc' },
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const sessionIds = sessionRows.map((s) => s.id);
|
|
233
|
+
if (sessionIds.length === 0) {
|
|
234
|
+
return { totalRatings: 0, averageScore: 0, recentItems: [] };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Fetch evaluation topics for these sessions
|
|
238
|
+
const topics = await this.prisma.evaluation_topic.findMany({
|
|
239
|
+
where: {
|
|
240
|
+
course_class_session_id: { in: sessionIds },
|
|
241
|
+
target_type: 'course_class_session',
|
|
242
|
+
is_active: true,
|
|
243
|
+
},
|
|
244
|
+
include: {
|
|
245
|
+
evaluation_rating: { select: { score: true } },
|
|
246
|
+
course_class_session: {
|
|
247
|
+
select: {
|
|
248
|
+
id: true,
|
|
249
|
+
title: true,
|
|
250
|
+
session_date: true,
|
|
251
|
+
course_class_group: { select: { title: true } },
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// Aggregate per session
|
|
258
|
+
const sessionMap = new Map<number, {
|
|
259
|
+
sessionTitle: string;
|
|
260
|
+
className: string | null;
|
|
261
|
+
sessionDate: Date;
|
|
262
|
+
scores: number[];
|
|
263
|
+
}>();
|
|
264
|
+
|
|
265
|
+
for (const topic of topics) {
|
|
266
|
+
const sess = topic.course_class_session;
|
|
267
|
+
if (!sess) continue;
|
|
268
|
+
if (!sessionMap.has(sess.id)) {
|
|
269
|
+
sessionMap.set(sess.id, {
|
|
270
|
+
sessionTitle: sess.title,
|
|
271
|
+
className: sess.course_class_group?.title ?? null,
|
|
272
|
+
sessionDate: sess.session_date,
|
|
273
|
+
scores: [],
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
for (const r of topic.evaluation_rating) {
|
|
277
|
+
sessionMap.get(sess.id)!.scores.push(Number(r.score));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const allScores = [...sessionMap.values()].flatMap((v) => v.scores);
|
|
282
|
+
const totalRatings = allScores.length;
|
|
283
|
+
const averageScore =
|
|
284
|
+
totalRatings > 0 ? this.round(allScores.reduce((a, b) => a + b, 0) / totalRatings, 1) : 0;
|
|
285
|
+
|
|
286
|
+
// Recent items — last 5 sessions with at least 1 rating, sorted desc
|
|
287
|
+
const recentItems = [...sessionMap.entries()]
|
|
288
|
+
.filter(([, v]) => v.scores.length > 0)
|
|
289
|
+
.sort(([, a], [, b]) => b.sessionDate.getTime() - a.sessionDate.getTime())
|
|
290
|
+
.slice(0, 5)
|
|
291
|
+
.map(([, v]) => ({
|
|
292
|
+
sessionTitle: v.sessionTitle,
|
|
293
|
+
className: v.className,
|
|
294
|
+
sessionDate: v.sessionDate,
|
|
295
|
+
avgScore: this.round(v.scores.reduce((a, b) => a + b, 0) / v.scores.length, 1),
|
|
296
|
+
ratingCount: v.scores.length,
|
|
297
|
+
}));
|
|
298
|
+
|
|
299
|
+
return { totalRatings, averageScore, recentItems };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private async getPendingEvals(cgIds: number[]) {
|
|
303
|
+
if (cgIds.length === 0) return [];
|
|
304
|
+
|
|
305
|
+
const attempts = await this.prisma.exam_attempt.findMany({
|
|
306
|
+
where: {
|
|
307
|
+
status: 'completed',
|
|
308
|
+
score: null,
|
|
309
|
+
course_enrollment: { course_class_group_id: { in: cgIds } },
|
|
310
|
+
},
|
|
311
|
+
take: 20,
|
|
312
|
+
orderBy: { finished_at: 'asc' },
|
|
313
|
+
include: {
|
|
314
|
+
course_enrollment: {
|
|
315
|
+
include: {
|
|
316
|
+
person: { select: { name: true } },
|
|
317
|
+
course_class_group: { select: { title: true } },
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
exam: { select: { id: true, title: true } },
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
return attempts.map((a) => ({
|
|
325
|
+
attemptId: a.id,
|
|
326
|
+
studentName: a.course_enrollment?.person?.name ?? null,
|
|
327
|
+
className: a.course_enrollment?.course_class_group?.title ?? null,
|
|
328
|
+
examTitle: a.exam?.title ?? null,
|
|
329
|
+
submittedAt: a.finished_at,
|
|
330
|
+
}));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ── Date helpers ──────────────────────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
async getClassGroups(
|
|
336
|
+
userId: number,
|
|
337
|
+
options: {
|
|
338
|
+
enterpriseId?: number;
|
|
339
|
+
search?: string;
|
|
340
|
+
status?: string;
|
|
341
|
+
deliveryMode?: string;
|
|
342
|
+
} = {},
|
|
343
|
+
) {
|
|
344
|
+
const instructor = await this.prisma.instructor.findFirst({
|
|
345
|
+
where: { person: { person_user: { some: { user_id: userId } } } },
|
|
346
|
+
select: { id: true },
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
if (!instructor) {
|
|
350
|
+
throw new NotFoundException('No instructor profile found for this user');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const { enterpriseId, search, status, deliveryMode } = options;
|
|
354
|
+
const now = new Date();
|
|
355
|
+
|
|
356
|
+
// IDs where instructor leads at least one session
|
|
357
|
+
const sessionRows = await this.prisma.course_class_session.findMany({
|
|
358
|
+
where: {
|
|
359
|
+
course_class_session_instructor: { some: { instructor_id: instructor.id } },
|
|
360
|
+
},
|
|
361
|
+
select: { course_class_group_id: true },
|
|
362
|
+
distinct: ['course_class_group_id'],
|
|
363
|
+
});
|
|
364
|
+
const cgIdsFromSessions = sessionRows
|
|
365
|
+
.map((r) => r.course_class_group_id)
|
|
366
|
+
.filter((id): id is number => id != null);
|
|
367
|
+
|
|
368
|
+
const classGroups = await this.prisma.course_class_group.findMany({
|
|
369
|
+
where: {
|
|
370
|
+
AND: [
|
|
371
|
+
// instructor of the group OR instructor of at least one session
|
|
372
|
+
{
|
|
373
|
+
OR: [
|
|
374
|
+
{ instructor_id: instructor.id },
|
|
375
|
+
{ id: { in: cgIdsFromSessions } },
|
|
376
|
+
],
|
|
377
|
+
},
|
|
378
|
+
// enterprise filter
|
|
379
|
+
...(enterpriseId !== undefined
|
|
380
|
+
? [{ enterprise_class_group: { some: { enterprise_id: enterpriseId } } }]
|
|
381
|
+
: []),
|
|
382
|
+
// status filter
|
|
383
|
+
...(status ? [{ status: status as any }] : []),
|
|
384
|
+
// delivery mode filter
|
|
385
|
+
...(deliveryMode ? [{ delivery_mode: deliveryMode as any }] : []),
|
|
386
|
+
// search filter
|
|
387
|
+
...(search
|
|
388
|
+
? [
|
|
389
|
+
{
|
|
390
|
+
OR: [
|
|
391
|
+
{ title: { contains: search, mode: 'insensitive' as const } },
|
|
392
|
+
{ code: { contains: search, mode: 'insensitive' as const } },
|
|
393
|
+
{ course: { title: { contains: search, mode: 'insensitive' as const } } },
|
|
394
|
+
],
|
|
395
|
+
},
|
|
396
|
+
]
|
|
397
|
+
: []),
|
|
398
|
+
],
|
|
399
|
+
},
|
|
400
|
+
orderBy: { start_date: 'desc' },
|
|
401
|
+
include: {
|
|
402
|
+
course: {
|
|
403
|
+
select: {
|
|
404
|
+
id: true,
|
|
405
|
+
title: true,
|
|
406
|
+
course_image: {
|
|
407
|
+
where: { image_type: { slug: { in: ['course-logo', 'course-banner'] } } },
|
|
408
|
+
orderBy: { is_primary: 'desc' as const },
|
|
409
|
+
select: {
|
|
410
|
+
file: { select: { location: true } },
|
|
411
|
+
image_type: { select: { slug: true } },
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
_count: {
|
|
417
|
+
select: {
|
|
418
|
+
course_enrollment: { where: { status: { not: 'cancelled' } } },
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
course_class_session: {
|
|
422
|
+
where: { session_date: { gte: now } },
|
|
423
|
+
orderBy: { session_date: 'asc' },
|
|
424
|
+
take: 1,
|
|
425
|
+
select: {
|
|
426
|
+
session_date: true,
|
|
427
|
+
start_time: true,
|
|
428
|
+
location: true,
|
|
429
|
+
meeting_url: true,
|
|
430
|
+
},
|
|
431
|
+
},
|
|
432
|
+
exam: {
|
|
433
|
+
select: {
|
|
434
|
+
id: true,
|
|
435
|
+
exam_attempt: {
|
|
436
|
+
where: { status: 'completed', score: null },
|
|
437
|
+
select: { id: true },
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
const data = classGroups.map((cg) => {
|
|
445
|
+
const start = cg.start_date?.getTime() ?? 0;
|
|
446
|
+
const end = cg.end_date?.getTime() ?? 0;
|
|
447
|
+
const elapsed = end > start ? ((now.getTime() - start) / (end - start)) * 100 : 0;
|
|
448
|
+
|
|
449
|
+
const nextSession = cg.course_class_session[0] ?? null;
|
|
450
|
+
|
|
451
|
+
const pendingEvaluations = cg.exam.reduce(
|
|
452
|
+
(sum, ex) => sum + ex.exam_attempt.length,
|
|
453
|
+
0,
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
return {
|
|
457
|
+
id: cg.id,
|
|
458
|
+
name: cg.title,
|
|
459
|
+
code: cg.code ?? '',
|
|
460
|
+
courseId: cg.course?.id ?? null,
|
|
461
|
+
courseName: cg.course?.title ?? null,
|
|
462
|
+
courseLogoUrl: cg.course?.course_image.find((ci) => ci.image_type?.slug === 'course-logo')?.file?.location ?? null,
|
|
463
|
+
courseBannerUrl: cg.course?.course_image.find((ci) => ci.image_type?.slug === 'course-banner')?.file?.location ?? null,
|
|
464
|
+
studentCount: cg._count.course_enrollment,
|
|
465
|
+
maxStudents: cg.capacity,
|
|
466
|
+
nextSession: nextSession
|
|
467
|
+
? {
|
|
468
|
+
date: nextSession.session_date,
|
|
469
|
+
time: nextSession.start_time,
|
|
470
|
+
location: nextSession.location ?? null,
|
|
471
|
+
meetingUrl: nextSession.meeting_url ?? null,
|
|
472
|
+
}
|
|
473
|
+
: null,
|
|
474
|
+
progress: this.round(Math.min(Math.max(elapsed, 0), 100), 0),
|
|
475
|
+
status: cg.status,
|
|
476
|
+
deliveryMode: cg.delivery_mode,
|
|
477
|
+
startDate: cg.start_date,
|
|
478
|
+
endDate: cg.end_date ?? null,
|
|
479
|
+
pendingEvaluations,
|
|
480
|
+
};
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
return { data, total: data.length };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ── Class-group detail endpoints ─────────────────────────────────────────────
|
|
487
|
+
|
|
488
|
+
private async assertInstructorOwnsClass(userId: number, classGroupId: number) {
|
|
489
|
+
const instructor = await this.prisma.instructor.findFirst({
|
|
490
|
+
where: { person: { person_user: { some: { user_id: userId } } } },
|
|
491
|
+
select: { id: true },
|
|
492
|
+
});
|
|
493
|
+
if (!instructor) throw new NotFoundException('No instructor profile found for this user');
|
|
494
|
+
|
|
495
|
+
const cg = await this.prisma.course_class_group.findFirst({
|
|
496
|
+
where: {
|
|
497
|
+
id: classGroupId,
|
|
498
|
+
OR: [
|
|
499
|
+
{ instructor_id: instructor.id },
|
|
500
|
+
{
|
|
501
|
+
course_class_session: {
|
|
502
|
+
some: {
|
|
503
|
+
course_class_session_instructor: { some: { instructor_id: instructor.id } },
|
|
504
|
+
},
|
|
505
|
+
},
|
|
506
|
+
},
|
|
507
|
+
],
|
|
508
|
+
},
|
|
509
|
+
select: { id: true },
|
|
510
|
+
});
|
|
511
|
+
if (!cg) throw new NotFoundException('Class group not found or not accessible to this instructor');
|
|
512
|
+
return { instructorId: instructor.id };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async getClassGroupDetail(userId: number, classGroupId: number) {
|
|
516
|
+
await this.assertInstructorOwnsClass(userId, classGroupId);
|
|
517
|
+
const now = new Date();
|
|
518
|
+
|
|
519
|
+
const cg = await this.prisma.course_class_group.findUnique({
|
|
520
|
+
where: { id: classGroupId },
|
|
521
|
+
include: {
|
|
522
|
+
course: { select: { id: true, title: true } },
|
|
523
|
+
instructor: { select: { person: { select: { name: true } } } },
|
|
524
|
+
_count: {
|
|
525
|
+
select: {
|
|
526
|
+
course_enrollment: { where: { status: { not: 'cancelled' } } },
|
|
527
|
+
course_class_session: true,
|
|
528
|
+
},
|
|
529
|
+
},
|
|
530
|
+
course_class_session: {
|
|
531
|
+
where: { session_date: { gte: now } },
|
|
532
|
+
orderBy: { session_date: 'asc' },
|
|
533
|
+
take: 1,
|
|
534
|
+
select: { session_date: true, start_time: true, location: true, meeting_url: true },
|
|
535
|
+
},
|
|
536
|
+
},
|
|
537
|
+
});
|
|
538
|
+
if (!cg) throw new NotFoundException(`Class group #${classGroupId} not found`);
|
|
539
|
+
|
|
540
|
+
// completedSessions = sessions with session_date < now
|
|
541
|
+
const completedSessions = await this.prisma.course_class_session.count({
|
|
542
|
+
where: { course_class_group_id: classGroupId, session_date: { lt: now } },
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
// avgAttendance: mean of (present/total) per past session
|
|
546
|
+
const pastSessions = await this.prisma.course_class_session.findMany({
|
|
547
|
+
where: { course_class_group_id: classGroupId, session_date: { lt: now } },
|
|
548
|
+
select: {
|
|
549
|
+
id: true,
|
|
550
|
+
_count: { select: { course_class_attendance: true } },
|
|
551
|
+
course_class_attendance: {
|
|
552
|
+
where: { present: true },
|
|
553
|
+
select: { student_id: true },
|
|
554
|
+
},
|
|
555
|
+
},
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
const totalEnrolled = cg._count.course_enrollment;
|
|
559
|
+
let avgAttendance = 0;
|
|
560
|
+
if (pastSessions.length > 0 && totalEnrolled > 0) {
|
|
561
|
+
const pcts = pastSessions.map((s) =>
|
|
562
|
+
(s.course_class_attendance.length / totalEnrolled) * 100,
|
|
563
|
+
);
|
|
564
|
+
avgAttendance = this.round(pcts.reduce((a, b) => a + b, 0) / pcts.length, 0);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const nextSession = cg.course_class_session[0] ?? null;
|
|
568
|
+
|
|
569
|
+
return {
|
|
570
|
+
id: cg.id,
|
|
571
|
+
courseName: cg.course?.title ?? null,
|
|
572
|
+
className: cg.title,
|
|
573
|
+
code: cg.code,
|
|
574
|
+
instructorName: cg.instructor?.person?.name ?? null,
|
|
575
|
+
startDate: cg.start_date,
|
|
576
|
+
endDate: cg.end_date ?? null,
|
|
577
|
+
status: cg.status,
|
|
578
|
+
deliveryMode: cg.delivery_mode,
|
|
579
|
+
totalSlots: cg.capacity,
|
|
580
|
+
enrolledCount: totalEnrolled,
|
|
581
|
+
totalSessions: cg._count.course_class_session,
|
|
582
|
+
completedSessions,
|
|
583
|
+
avgAttendance,
|
|
584
|
+
nextSession: nextSession
|
|
585
|
+
? {
|
|
586
|
+
date: nextSession.session_date,
|
|
587
|
+
time: nextSession.start_time,
|
|
588
|
+
location: nextSession.location ?? null,
|
|
589
|
+
meetingUrl: nextSession.meeting_url ?? null,
|
|
590
|
+
}
|
|
591
|
+
: null,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async getClassGroupStudents(
|
|
596
|
+
userId: number,
|
|
597
|
+
classGroupId: number,
|
|
598
|
+
params: { page?: number; pageSize?: number; search?: string; status?: string },
|
|
599
|
+
) {
|
|
600
|
+
await this.assertInstructorOwnsClass(userId, classGroupId);
|
|
601
|
+
const page = Math.max(Number(params.page) || 1, 1);
|
|
602
|
+
const pageSize = Math.max(Number(params.pageSize) || 20, 1);
|
|
603
|
+
const skip = (page - 1) * pageSize;
|
|
604
|
+
|
|
605
|
+
const where: any = {
|
|
606
|
+
course_class_group_id: classGroupId,
|
|
607
|
+
status: { not: 'cancelled' },
|
|
608
|
+
};
|
|
609
|
+
if (params.search) {
|
|
610
|
+
where.person = { name: { contains: params.search, mode: 'insensitive' } };
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const [enrollments, total, totalSessions] = await Promise.all([
|
|
614
|
+
this.prisma.course_enrollment.findMany({
|
|
615
|
+
where,
|
|
616
|
+
skip,
|
|
617
|
+
take: pageSize,
|
|
618
|
+
orderBy: { person: { name: 'asc' } },
|
|
619
|
+
include: {
|
|
620
|
+
person: { select: { id: true, name: true } },
|
|
621
|
+
},
|
|
622
|
+
}),
|
|
623
|
+
this.prisma.course_enrollment.count({ where }),
|
|
624
|
+
this.prisma.course_class_session.count({ where: { course_class_group_id: classGroupId } }),
|
|
625
|
+
]);
|
|
626
|
+
|
|
627
|
+
if (enrollments.length === 0) {
|
|
628
|
+
return { total, page, pageSize, lastPage: Math.max(1, Math.ceil(total / pageSize)), data: [] };
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Fetch attendance counts per student in one query
|
|
632
|
+
const personIds = enrollments.map((e) => e.person_id).filter(Boolean) as number[];
|
|
633
|
+
const attendanceRows = await this.prisma.course_class_attendance.groupBy({
|
|
634
|
+
by: ['student_id'],
|
|
635
|
+
where: { student_id: { in: personIds }, present: true },
|
|
636
|
+
_count: { id: true },
|
|
637
|
+
});
|
|
638
|
+
const attendanceMap = new Map(attendanceRows.map((r) => [r.student_id, r._count.id]));
|
|
639
|
+
|
|
640
|
+
const data = enrollments.map((e) => {
|
|
641
|
+
const presentCount = attendanceMap.get(e.person_id ?? 0) ?? 0;
|
|
642
|
+
const attendance = totalSessions > 0 ? this.round((presentCount / totalSessions) * 100, 0) : 0;
|
|
643
|
+
const progress = e.progress_percent ?? 0;
|
|
644
|
+
const grade = e.final_score != null ? Number(e.final_score) : null;
|
|
645
|
+
let status: 'ok' | 'risk' | 'inactive';
|
|
646
|
+
if (e.status === 'paused') status = 'inactive';
|
|
647
|
+
else if (attendance < 60 || progress < 50) status = 'risk';
|
|
648
|
+
else status = 'ok';
|
|
649
|
+
|
|
650
|
+
// Filter by status if requested
|
|
651
|
+
return { _status: status, id: e.person_id, name: e.person?.name ?? null, avatarUrl: null, progress, attendance, grade, status };
|
|
652
|
+
}).filter((s) => !params.status || s.status === params.status);
|
|
653
|
+
|
|
654
|
+
return {
|
|
655
|
+
total,
|
|
656
|
+
page,
|
|
657
|
+
pageSize,
|
|
658
|
+
lastPage: Math.max(1, Math.ceil(total / pageSize)),
|
|
659
|
+
data: data.map(({ _status: _, ...rest }) => rest),
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async getClassGroupSessions(userId: number, classGroupId: number) {
|
|
664
|
+
await this.assertInstructorOwnsClass(userId, classGroupId);
|
|
665
|
+
const now = new Date();
|
|
666
|
+
|
|
667
|
+
const sessions = await this.prisma.course_class_session.findMany({
|
|
668
|
+
where: { course_class_group_id: classGroupId },
|
|
669
|
+
orderBy: { session_date: 'asc' },
|
|
670
|
+
select: {
|
|
671
|
+
id: true,
|
|
672
|
+
title: true,
|
|
673
|
+
session_date: true,
|
|
674
|
+
start_time: true,
|
|
675
|
+
end_time: true,
|
|
676
|
+
location: true,
|
|
677
|
+
meeting_url: true,
|
|
678
|
+
_count: { select: { course_class_attendance: true } },
|
|
679
|
+
course_class_attendance: {
|
|
680
|
+
where: { present: true },
|
|
681
|
+
select: { student_id: true },
|
|
682
|
+
},
|
|
683
|
+
},
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
const totalStudents = await this.prisma.course_enrollment.count({
|
|
687
|
+
where: { course_class_group_id: classGroupId, status: { not: 'cancelled' } },
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
let hasFoundNext = false;
|
|
691
|
+
return sessions.map((s) => {
|
|
692
|
+
const isPast = s.session_date < now;
|
|
693
|
+
let status: 'completed' | 'next' | 'upcoming';
|
|
694
|
+
if (isPast) {
|
|
695
|
+
status = 'completed';
|
|
696
|
+
} else if (!hasFoundNext) {
|
|
697
|
+
status = 'next';
|
|
698
|
+
hasFoundNext = true;
|
|
699
|
+
} else {
|
|
700
|
+
status = 'upcoming';
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
return {
|
|
704
|
+
id: s.id,
|
|
705
|
+
title: s.title,
|
|
706
|
+
date: s.session_date,
|
|
707
|
+
time: s.start_time,
|
|
708
|
+
endTime: s.end_time ?? null,
|
|
709
|
+
location: s.location ?? null,
|
|
710
|
+
meetingUrl: s.meeting_url ?? null,
|
|
711
|
+
status,
|
|
712
|
+
attendanceCount: isPast ? s.course_class_attendance.length : null,
|
|
713
|
+
totalStudents,
|
|
714
|
+
};
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
async getClassGroupAttendance(userId: number, classGroupId: number) {
|
|
719
|
+
await this.assertInstructorOwnsClass(userId, classGroupId);
|
|
720
|
+
const now = new Date();
|
|
721
|
+
|
|
722
|
+
const [sessions, enrollments] = await Promise.all([
|
|
723
|
+
this.prisma.course_class_session.findMany({
|
|
724
|
+
where: { course_class_group_id: classGroupId, session_date: { lt: now } },
|
|
725
|
+
orderBy: { session_date: 'asc' },
|
|
726
|
+
select: {
|
|
727
|
+
id: true,
|
|
728
|
+
title: true,
|
|
729
|
+
session_date: true,
|
|
730
|
+
course_class_attendance: {
|
|
731
|
+
select: { student_id: true, present: true },
|
|
732
|
+
},
|
|
733
|
+
},
|
|
734
|
+
}),
|
|
735
|
+
this.prisma.course_enrollment.findMany({
|
|
736
|
+
where: { course_class_group_id: classGroupId, status: { not: 'cancelled' } },
|
|
737
|
+
select: { person_id: true, person: { select: { name: true } } },
|
|
738
|
+
orderBy: { person: { name: 'asc' } },
|
|
739
|
+
}),
|
|
740
|
+
]);
|
|
741
|
+
|
|
742
|
+
return {
|
|
743
|
+
students: enrollments.map((e) => ({
|
|
744
|
+
studentId: e.person_id,
|
|
745
|
+
studentName: e.person?.name ?? null,
|
|
746
|
+
})),
|
|
747
|
+
sessions: sessions.map((s) => {
|
|
748
|
+
const attendanceMap = new Map(s.course_class_attendance.map((a) => [a.student_id, a.present]));
|
|
749
|
+
return {
|
|
750
|
+
sessionId: s.id,
|
|
751
|
+
title: s.title,
|
|
752
|
+
date: s.session_date,
|
|
753
|
+
records: enrollments.map((e) => ({
|
|
754
|
+
studentId: e.person_id,
|
|
755
|
+
present: attendanceMap.get(e.person_id ?? 0) ?? null,
|
|
756
|
+
})),
|
|
757
|
+
};
|
|
758
|
+
}),
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
async getClassGroupMaterials(userId: number, classGroupId: number) {
|
|
763
|
+
await this.assertInstructorOwnsClass(userId, classGroupId);
|
|
764
|
+
|
|
765
|
+
const materials = await this.prisma.course_class_group_material.findMany({
|
|
766
|
+
where: { course_class_group_id: classGroupId },
|
|
767
|
+
orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }],
|
|
768
|
+
select: {
|
|
769
|
+
id: true,
|
|
770
|
+
title: true,
|
|
771
|
+
description: true,
|
|
772
|
+
material_type: true,
|
|
773
|
+
file_id: true,
|
|
774
|
+
url: true,
|
|
775
|
+
course_class_session_id: true,
|
|
776
|
+
sort_order: true,
|
|
777
|
+
created_at: true,
|
|
778
|
+
},
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
return materials.map((m) => ({
|
|
782
|
+
id: m.id,
|
|
783
|
+
title: m.title,
|
|
784
|
+
description: m.description ?? null,
|
|
785
|
+
materialType: m.material_type,
|
|
786
|
+
fileId: m.file_id ?? null,
|
|
787
|
+
url: m.url ?? null,
|
|
788
|
+
sessionId: m.course_class_session_id ?? null,
|
|
789
|
+
sortOrder: m.sort_order,
|
|
790
|
+
createdAt: m.created_at?.toISOString() ?? null,
|
|
791
|
+
}));
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
async getMaterialDownloadUrl(userId: number, classGroupId: number, materialId: number) {
|
|
795
|
+
await this.assertInstructorOwnsClass(userId, classGroupId);
|
|
796
|
+
|
|
797
|
+
const material = await this.prisma.course_class_group_material.findFirst({
|
|
798
|
+
where: { id: materialId, course_class_group_id: classGroupId },
|
|
799
|
+
select: { file_id: true },
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
if (!material) {
|
|
803
|
+
throw new NotFoundException('Material not found');
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
if (!material.file_id) {
|
|
807
|
+
throw new NotFoundException('This material has no associated file');
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const file = await this.fileService.get(material.file_id);
|
|
811
|
+
return { url: await this.fileService.tempURL(file.path) };
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
async uploadMaterial(userId: number, classGroupId: number, file: MulterFile) {
|
|
815
|
+
await this.assertInstructorOwnsClass(userId, classGroupId);
|
|
816
|
+
|
|
817
|
+
const uploaded = await this.fileService.upload('class-materials', file);
|
|
818
|
+
|
|
819
|
+
const material = await this.prisma.course_class_group_material.create({
|
|
820
|
+
data: {
|
|
821
|
+
course_class_group_id: classGroupId,
|
|
822
|
+
title: uploaded.filename,
|
|
823
|
+
material_type: 'file' as any,
|
|
824
|
+
file_id: uploaded.id,
|
|
825
|
+
sort_order: 0,
|
|
826
|
+
},
|
|
827
|
+
select: {
|
|
828
|
+
id: true,
|
|
829
|
+
title: true,
|
|
830
|
+
description: true,
|
|
831
|
+
material_type: true,
|
|
832
|
+
file_id: true,
|
|
833
|
+
url: true,
|
|
834
|
+
course_class_session_id: true,
|
|
835
|
+
sort_order: true,
|
|
836
|
+
created_at: true,
|
|
837
|
+
},
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
return {
|
|
841
|
+
id: material.id,
|
|
842
|
+
title: material.title,
|
|
843
|
+
description: material.description ?? null,
|
|
844
|
+
materialType: material.material_type,
|
|
845
|
+
fileId: material.file_id ?? null,
|
|
846
|
+
url: material.url ?? null,
|
|
847
|
+
sessionId: material.course_class_session_id ?? null,
|
|
848
|
+
sortOrder: material.sort_order,
|
|
849
|
+
createdAt: material.created_at?.toISOString() ?? null,
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
async deleteMaterial(userId: number, classGroupId: number, materialId: number) {
|
|
854
|
+
await this.assertInstructorOwnsClass(userId, classGroupId);
|
|
855
|
+
|
|
856
|
+
const material = await this.prisma.course_class_group_material.findFirst({
|
|
857
|
+
where: { id: materialId, course_class_group_id: classGroupId },
|
|
858
|
+
select: { id: true },
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
if (!material) {
|
|
862
|
+
throw new NotFoundException('Material not found');
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
await this.prisma.course_class_group_material.delete({
|
|
866
|
+
where: { id: materialId },
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
return { success: true };
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
async getClassEvaluations(userId: number, classGroupId: number) {
|
|
873
|
+
await this.assertInstructorOwnsClass(userId, classGroupId);
|
|
874
|
+
|
|
875
|
+
// Fetch sessions of this class group
|
|
876
|
+
const sessions = await this.prisma.course_class_session.findMany({
|
|
877
|
+
where: { course_class_group_id: classGroupId },
|
|
878
|
+
select: { id: true, title: true, session_date: true },
|
|
879
|
+
orderBy: { session_date: 'asc' },
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
const sessionIds = sessions.map((s) => s.id);
|
|
883
|
+
if (sessionIds.length === 0) {
|
|
884
|
+
return {
|
|
885
|
+
totalRatings: 0,
|
|
886
|
+
averageScore: 0,
|
|
887
|
+
distribution: [
|
|
888
|
+
{ range: '9–10', count: 0 },
|
|
889
|
+
{ range: '7–8', count: 0 },
|
|
890
|
+
{ range: '5–6', count: 0 },
|
|
891
|
+
{ range: '0–4', count: 0 },
|
|
892
|
+
],
|
|
893
|
+
sessions: [],
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// Fetch active evaluation topics for these sessions (anonymous — scores + comments, per-topic names)
|
|
898
|
+
const topics = await this.prisma.evaluation_topic.findMany({
|
|
899
|
+
where: {
|
|
900
|
+
course_class_session_id: { in: sessionIds },
|
|
901
|
+
target_type: 'course_class_session',
|
|
902
|
+
is_active: true,
|
|
903
|
+
},
|
|
904
|
+
select: {
|
|
905
|
+
id: true,
|
|
906
|
+
name: true,
|
|
907
|
+
course_class_session_id: true,
|
|
908
|
+
evaluation_rating: { select: { score: true, comment: true } },
|
|
909
|
+
},
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
// Build per-session aggregations (scores, anonymous comments, per-topic breakdown)
|
|
913
|
+
type SessionAgg = {
|
|
914
|
+
scores: number[];
|
|
915
|
+
comments: string[];
|
|
916
|
+
topics: Map<number, { name: string; scores: number[] }>;
|
|
917
|
+
};
|
|
918
|
+
const sessionMap = new Map<number, SessionAgg>();
|
|
919
|
+
for (const sessId of sessionIds) {
|
|
920
|
+
sessionMap.set(sessId, { scores: [], comments: [], topics: new Map() });
|
|
921
|
+
}
|
|
922
|
+
for (const topic of topics) {
|
|
923
|
+
const sessId = topic.course_class_session_id;
|
|
924
|
+
if (sessId == null) continue;
|
|
925
|
+
const agg = sessionMap.get(sessId);
|
|
926
|
+
if (!agg) continue;
|
|
927
|
+
if (!agg.topics.has(topic.id)) {
|
|
928
|
+
agg.topics.set(topic.id, { name: topic.name, scores: [] });
|
|
929
|
+
}
|
|
930
|
+
const topicAgg = agg.topics.get(topic.id)!;
|
|
931
|
+
for (const r of topic.evaluation_rating) {
|
|
932
|
+
const score = Number(r.score);
|
|
933
|
+
agg.scores.push(score);
|
|
934
|
+
topicAgg.scores.push(score);
|
|
935
|
+
if (r.comment) agg.comments.push(r.comment);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
const allScores = [...sessionMap.values()].flatMap((a) => a.scores);
|
|
940
|
+
const totalRatings = allScores.length;
|
|
941
|
+
const averageScore =
|
|
942
|
+
totalRatings > 0 ? this.round(allScores.reduce((a, b) => a + b, 0) / totalRatings, 1) : 0;
|
|
943
|
+
|
|
944
|
+
const distribution = [
|
|
945
|
+
{ range: '9–10', count: allScores.filter((s) => s >= 9).length },
|
|
946
|
+
{ range: '7–8', count: allScores.filter((s) => s >= 7 && s < 9).length },
|
|
947
|
+
{ range: '5–6', count: allScores.filter((s) => s >= 5 && s < 7).length },
|
|
948
|
+
{ range: '0–4', count: allScores.filter((s) => s < 5).length },
|
|
949
|
+
];
|
|
950
|
+
|
|
951
|
+
const sessionData = sessions.map((s) => {
|
|
952
|
+
const agg = sessionMap.get(s.id) ?? {
|
|
953
|
+
scores: [], comments: [], topics: new Map<number, { name: string; scores: number[] }>(),
|
|
954
|
+
};
|
|
955
|
+
const avg =
|
|
956
|
+
agg.scores.length > 0
|
|
957
|
+
? this.round(agg.scores.reduce((a, b) => a + b, 0) / agg.scores.length, 1)
|
|
958
|
+
: null;
|
|
959
|
+
const topicBreakdown = Array.from(agg.topics.entries()).map(([topicId, { name, scores }]) => ({
|
|
960
|
+
topicId,
|
|
961
|
+
name,
|
|
962
|
+
avgScore:
|
|
963
|
+
scores.length > 0
|
|
964
|
+
? this.round(scores.reduce((a, b) => a + b, 0) / scores.length, 1)
|
|
965
|
+
: null,
|
|
966
|
+
}));
|
|
967
|
+
return {
|
|
968
|
+
sessionId: s.id,
|
|
969
|
+
title: s.title,
|
|
970
|
+
date: s.session_date,
|
|
971
|
+
avgScore: avg,
|
|
972
|
+
ratingCount: agg.scores.length,
|
|
973
|
+
topics: topicBreakdown,
|
|
974
|
+
comments: agg.comments,
|
|
975
|
+
};
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
return { totalRatings, averageScore, distribution, sessions: sessionData };
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
async getReports(userId: number, enterpriseId?: number, dateFrom?: string, dateTo?: string) {
|
|
982
|
+
const instructor = await this.prisma.instructor.findFirst({
|
|
983
|
+
where: { person: { person_user: { some: { user_id: userId } } } },
|
|
984
|
+
select: { id: true },
|
|
985
|
+
});
|
|
986
|
+
if (!instructor) throw new NotFoundException('No instructor profile found for this user');
|
|
987
|
+
|
|
988
|
+
const now = new Date();
|
|
989
|
+
|
|
990
|
+
// Date range for period filtering
|
|
991
|
+
const startDate = dateFrom ? new Date(dateFrom) : new Date(now.getFullYear(), 0, 1);
|
|
992
|
+
const endDate = dateTo ? new Date(dateTo + 'T23:59:59.999Z') : new Date(now.getFullYear(), 11, 31, 23, 59, 59);
|
|
993
|
+
const sessionDateFilter = { gte: startDate, lte: endDate };
|
|
994
|
+
|
|
995
|
+
// Treat enterpriseId=0 as "no filter" (frontend may send 0 when no enterprise is selected)
|
|
996
|
+
const effectiveEnterpriseId = enterpriseId && enterpriseId > 0 ? enterpriseId : undefined;
|
|
997
|
+
|
|
998
|
+
const enterpriseFilter = effectiveEnterpriseId !== undefined
|
|
999
|
+
? { enterprise_class_group: { some: { enterprise_id: effectiveEnterpriseId } } }
|
|
1000
|
+
: {};
|
|
1001
|
+
|
|
1002
|
+
// ── 1. Collect all class groups taught by this instructor ──────────────────
|
|
1003
|
+
const sessionRows = await this.prisma.course_class_session.findMany({
|
|
1004
|
+
where: {
|
|
1005
|
+
course_class_session_instructor: { some: { instructor_id: instructor.id } },
|
|
1006
|
+
session_date: sessionDateFilter,
|
|
1007
|
+
...(effectiveEnterpriseId !== undefined ? { course_class_group: enterpriseFilter } : {}),
|
|
1008
|
+
},
|
|
1009
|
+
select: { course_class_group_id: true },
|
|
1010
|
+
distinct: ['course_class_group_id'],
|
|
1011
|
+
});
|
|
1012
|
+
const cgIds = sessionRows
|
|
1013
|
+
.map((r) => r.course_class_group_id)
|
|
1014
|
+
.filter((id): id is number => id != null);
|
|
1015
|
+
|
|
1016
|
+
const CLASS_COLORS = ['#F97316', '#6366F1', '#10B981', '#0EA5E9', '#8B5CF6', '#F43F5E'];
|
|
1017
|
+
|
|
1018
|
+
if (cgIds.length === 0) {
|
|
1019
|
+
return {
|
|
1020
|
+
kpis: { totalStudents: 0, averageEvaluationScore: 0, averageAttendance: 0, sessionsCompleted: 0 },
|
|
1021
|
+
classes: [],
|
|
1022
|
+
topicNames: [],
|
|
1023
|
+
evaluationsBySession: [],
|
|
1024
|
+
attendanceBySession: [],
|
|
1025
|
+
studentGrades: [],
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
const classGroupRows = await this.prisma.course_class_group.findMany({
|
|
1030
|
+
where: { id: { in: cgIds } },
|
|
1031
|
+
orderBy: { start_date: 'asc' },
|
|
1032
|
+
select: { id: true, title: true },
|
|
1033
|
+
take: 6,
|
|
1034
|
+
});
|
|
1035
|
+
const cgIdsCapped = classGroupRows.map((cg) => cg.id);
|
|
1036
|
+
const classes = classGroupRows.map((cg, i) => ({
|
|
1037
|
+
key: `t${i + 1}` as string,
|
|
1038
|
+
name: cg.title,
|
|
1039
|
+
color: CLASS_COLORS[i] ?? '#94A3B8',
|
|
1040
|
+
}));
|
|
1041
|
+
const classKeyById = new Map(classGroupRows.map((cg, i) => [cg.id, `t${i + 1}`]));
|
|
1042
|
+
const classNameById = new Map(classGroupRows.map((cg) => [cg.id, cg.title]));
|
|
1043
|
+
|
|
1044
|
+
// ── 2. Parallel data fetches ───────────────────────────────────────────────
|
|
1045
|
+
const [allSessions, evalTopics, attendanceRows, enrollmentRows, completedSessionCount] =
|
|
1046
|
+
await Promise.all([
|
|
1047
|
+
// All sessions for these class groups within the date range
|
|
1048
|
+
this.prisma.course_class_session.findMany({
|
|
1049
|
+
where: { course_class_group_id: { in: cgIdsCapped }, session_date: sessionDateFilter },
|
|
1050
|
+
select: {
|
|
1051
|
+
id: true,
|
|
1052
|
+
title: true,
|
|
1053
|
+
session_date: true,
|
|
1054
|
+
course_class_group_id: true,
|
|
1055
|
+
},
|
|
1056
|
+
orderBy: { session_date: 'asc' },
|
|
1057
|
+
}),
|
|
1058
|
+
// Evaluation topics+ratings for sessions taught by this instructor within the date range
|
|
1059
|
+
this.prisma.evaluation_topic.findMany({
|
|
1060
|
+
where: {
|
|
1061
|
+
target_type: 'course_class_session',
|
|
1062
|
+
is_active: true,
|
|
1063
|
+
course_class_session: {
|
|
1064
|
+
course_class_group_id: { in: cgIdsCapped },
|
|
1065
|
+
session_date: sessionDateFilter,
|
|
1066
|
+
},
|
|
1067
|
+
},
|
|
1068
|
+
select: {
|
|
1069
|
+
id: true,
|
|
1070
|
+
name: true,
|
|
1071
|
+
course_class_session_id: true,
|
|
1072
|
+
evaluation_rating: { select: { score: true } },
|
|
1073
|
+
},
|
|
1074
|
+
}),
|
|
1075
|
+
// Attendance records for sessions in the date range
|
|
1076
|
+
this.prisma.course_class_attendance.findMany({
|
|
1077
|
+
where: {
|
|
1078
|
+
course_class_session: {
|
|
1079
|
+
course_class_group_id: { in: cgIdsCapped },
|
|
1080
|
+
session_date: { gte: startDate, lt: now < endDate ? now : endDate },
|
|
1081
|
+
},
|
|
1082
|
+
},
|
|
1083
|
+
select: {
|
|
1084
|
+
course_class_session_id: true,
|
|
1085
|
+
student_id: true,
|
|
1086
|
+
present: true,
|
|
1087
|
+
},
|
|
1088
|
+
}),
|
|
1089
|
+
// Enrollment final scores (notas de prova)
|
|
1090
|
+
this.prisma.course_enrollment.findMany({
|
|
1091
|
+
where: {
|
|
1092
|
+
course_class_group_id: { in: cgIdsCapped },
|
|
1093
|
+
final_score: { not: null },
|
|
1094
|
+
status: { not: 'cancelled' },
|
|
1095
|
+
},
|
|
1096
|
+
select: {
|
|
1097
|
+
course_class_group_id: true,
|
|
1098
|
+
final_score: true,
|
|
1099
|
+
person: { select: { name: true } },
|
|
1100
|
+
},
|
|
1101
|
+
orderBy: { final_score: 'desc' },
|
|
1102
|
+
}),
|
|
1103
|
+
// Completed sessions count within the date range
|
|
1104
|
+
this.prisma.course_class_session.count({
|
|
1105
|
+
where: {
|
|
1106
|
+
course_class_group_id: { in: cgIdsCapped },
|
|
1107
|
+
session_date: { gte: startDate, lt: now < endDate ? now : endDate },
|
|
1108
|
+
},
|
|
1109
|
+
}),
|
|
1110
|
+
]);
|
|
1111
|
+
|
|
1112
|
+
// ── 3. KPIs ────────────────────────────────────────────────────────────────
|
|
1113
|
+
const totalStudents = await this.prisma.course_enrollment.count({
|
|
1114
|
+
where: {
|
|
1115
|
+
course_class_group_id: { in: cgIdsCapped },
|
|
1116
|
+
status: { not: 'cancelled' },
|
|
1117
|
+
},
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
// Average evaluation score: compute from already-filtered evalTopics
|
|
1121
|
+
const allEvalScores: number[] = [];
|
|
1122
|
+
for (const topic of evalTopics) {
|
|
1123
|
+
for (const r of topic.evaluation_rating) allEvalScores.push(Number(r.score));
|
|
1124
|
+
}
|
|
1125
|
+
const averageEvaluationScore =
|
|
1126
|
+
allEvalScores.length > 0
|
|
1127
|
+
? this.round(allEvalScores.reduce((a, b) => a + b, 0) / allEvalScores.length, 2)
|
|
1128
|
+
: 0;
|
|
1129
|
+
|
|
1130
|
+
// Average attendance: per session, count present / total records
|
|
1131
|
+
const pastSessions = allSessions.filter((s) => s.session_date < now);
|
|
1132
|
+
let attendanceSum = 0;
|
|
1133
|
+
let attendanceDenom = 0;
|
|
1134
|
+
for (const sess of pastSessions) {
|
|
1135
|
+
const records = attendanceRows.filter((r) => r.course_class_session_id === sess.id);
|
|
1136
|
+
if (records.length > 0) {
|
|
1137
|
+
attendanceSum += records.filter((r) => r.present).length;
|
|
1138
|
+
attendanceDenom += records.length;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
const averageAttendance =
|
|
1142
|
+
attendanceDenom > 0 ? this.round((attendanceSum / attendanceDenom) * 100, 0) : 0;
|
|
1143
|
+
|
|
1144
|
+
// ── 4. Evaluations by session ──────────────────────────────────────────────
|
|
1145
|
+
// Build session → { topics: Map<name, scores[]> }
|
|
1146
|
+
type SessEvalAgg = { topics: Map<string, number[]> };
|
|
1147
|
+
const sessEvalMap = new Map<number, SessEvalAgg>();
|
|
1148
|
+
for (const s of allSessions) sessEvalMap.set(s.id, { topics: new Map() });
|
|
1149
|
+
|
|
1150
|
+
// Collect all unique topic names
|
|
1151
|
+
const allTopicNames = new Set<string>();
|
|
1152
|
+
for (const topic of evalTopics) {
|
|
1153
|
+
if (topic.course_class_session_id == null) continue;
|
|
1154
|
+
allTopicNames.add(topic.name);
|
|
1155
|
+
const agg = sessEvalMap.get(topic.course_class_session_id);
|
|
1156
|
+
if (!agg) continue;
|
|
1157
|
+
if (!agg.topics.has(topic.name)) agg.topics.set(topic.name, []);
|
|
1158
|
+
for (const r of topic.evaluation_rating) {
|
|
1159
|
+
agg.topics.get(topic.name)!.push(Number(r.score));
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
const topicNames = Array.from(allTopicNames).sort();
|
|
1163
|
+
|
|
1164
|
+
const evaluationsBySession = allSessions
|
|
1165
|
+
.filter((s) => {
|
|
1166
|
+
const agg = sessEvalMap.get(s.id);
|
|
1167
|
+
return agg && [...agg.topics.values()].some((scores) => scores.length > 0);
|
|
1168
|
+
})
|
|
1169
|
+
.map((s) => {
|
|
1170
|
+
const agg = sessEvalMap.get(s.id)!;
|
|
1171
|
+
const topicsObj: Record<string, number | null> = {};
|
|
1172
|
+
let totalScore = 0;
|
|
1173
|
+
let totalCount = 0;
|
|
1174
|
+
for (const tName of topicNames) {
|
|
1175
|
+
const scores = agg.topics.get(tName) ?? [];
|
|
1176
|
+
if (scores.length > 0) {
|
|
1177
|
+
const avg = this.round(scores.reduce((a, b) => a + b, 0) / scores.length, 2);
|
|
1178
|
+
topicsObj[tName] = avg;
|
|
1179
|
+
totalScore += avg;
|
|
1180
|
+
totalCount++;
|
|
1181
|
+
} else {
|
|
1182
|
+
topicsObj[tName] = null;
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
const media = totalCount > 0 ? this.round(totalScore / totalCount, 2) : 0;
|
|
1186
|
+
const classKey = classKeyById.get(s.course_class_group_id ?? -1) ?? null;
|
|
1187
|
+
const className = classNameById.get(s.course_class_group_id ?? -1) ?? null;
|
|
1188
|
+
// Short label: first 20 chars of title
|
|
1189
|
+
const label = s.title.length > 20 ? `${s.title.slice(0, 18)}…` : s.title;
|
|
1190
|
+
return {
|
|
1191
|
+
sessionId: s.id,
|
|
1192
|
+
label,
|
|
1193
|
+
date: s.session_date,
|
|
1194
|
+
classKey,
|
|
1195
|
+
className,
|
|
1196
|
+
topics: topicsObj,
|
|
1197
|
+
media,
|
|
1198
|
+
hi: Math.min(5, this.round(media + 0.5, 2)),
|
|
1199
|
+
lo: Math.max(0, this.round(media - 0.5, 2)),
|
|
1200
|
+
};
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
// ── 5. Attendance by session ───────────────────────────────────────────────
|
|
1204
|
+
const attendanceBySession = pastSessions
|
|
1205
|
+
.filter((s) => attendanceRows.some((r) => r.course_class_session_id === s.id))
|
|
1206
|
+
.map((s) => {
|
|
1207
|
+
const records = attendanceRows.filter((r) => r.course_class_session_id === s.id);
|
|
1208
|
+
const presentCount = records.filter((r) => r.present).length;
|
|
1209
|
+
const classKey = classKeyById.get(s.course_class_group_id ?? -1) ?? null;
|
|
1210
|
+
const className = classNameById.get(s.course_class_group_id ?? -1) ?? null;
|
|
1211
|
+
const label = s.title.length > 12 ? `${s.title.slice(0, 10)}…` : s.title;
|
|
1212
|
+
return {
|
|
1213
|
+
sessionId: s.id,
|
|
1214
|
+
label,
|
|
1215
|
+
date: s.session_date,
|
|
1216
|
+
classKey,
|
|
1217
|
+
className,
|
|
1218
|
+
presentCount,
|
|
1219
|
+
};
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
// ── 6. Student grades ──────────────────────────────────────────────────────
|
|
1223
|
+
const studentGrades = enrollmentRows
|
|
1224
|
+
.filter((e) => e.final_score != null)
|
|
1225
|
+
.map((e) => ({
|
|
1226
|
+
name: e.person?.name ?? 'Aluno',
|
|
1227
|
+
grade: Number(e.final_score),
|
|
1228
|
+
classKey: classKeyById.get(e.course_class_group_id ?? -1) ?? null,
|
|
1229
|
+
className: classNameById.get(e.course_class_group_id ?? -1) ?? null,
|
|
1230
|
+
}))
|
|
1231
|
+
.sort((a, b) => b.grade - a.grade);
|
|
1232
|
+
|
|
1233
|
+
return {
|
|
1234
|
+
kpis: {
|
|
1235
|
+
totalStudents,
|
|
1236
|
+
averageEvaluationScore,
|
|
1237
|
+
averageAttendance,
|
|
1238
|
+
sessionsCompleted: completedSessionCount,
|
|
1239
|
+
},
|
|
1240
|
+
classes,
|
|
1241
|
+
topicNames,
|
|
1242
|
+
evaluationsBySession,
|
|
1243
|
+
attendanceBySession,
|
|
1244
|
+
studentGrades,
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
private round(value: number, digits = 0) {
|
|
1249
|
+
const factor = 10 ** digits;
|
|
1250
|
+
return Math.round(value * factor) / factor;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
private addDays(date: Date, days: number) {
|
|
1254
|
+
const result = new Date(date);
|
|
1255
|
+
result.setDate(result.getDate() + days);
|
|
1256
|
+
return result;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
async submitSessionAttendance(
|
|
1260
|
+
userId: number,
|
|
1261
|
+
classGroupId: number,
|
|
1262
|
+
sessionId: number,
|
|
1263
|
+
records: { studentId: number; present: boolean }[],
|
|
1264
|
+
) {
|
|
1265
|
+
await this.assertInstructorOwnsClass(userId, classGroupId);
|
|
1266
|
+
|
|
1267
|
+
const session = await this.prisma.course_class_session.findFirst({
|
|
1268
|
+
where: { id: sessionId, course_class_group_id: classGroupId },
|
|
1269
|
+
select: { id: true },
|
|
1270
|
+
});
|
|
1271
|
+
if (!session) throw new NotFoundException('Session not found in this class group');
|
|
1272
|
+
|
|
1273
|
+
await this.prisma.$transaction([
|
|
1274
|
+
this.prisma.course_class_attendance.deleteMany({
|
|
1275
|
+
where: { course_class_session_id: sessionId },
|
|
1276
|
+
}),
|
|
1277
|
+
this.prisma.course_class_attendance.createMany({
|
|
1278
|
+
data: records.map((r) => ({
|
|
1279
|
+
course_class_session_id: sessionId,
|
|
1280
|
+
student_id: r.studentId,
|
|
1281
|
+
present: r.present,
|
|
1282
|
+
})),
|
|
1283
|
+
}),
|
|
1284
|
+
]);
|
|
1285
|
+
|
|
1286
|
+
return { success: true, count: records.length };
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
private endOfDay(date: Date) {
|
|
1290
|
+
const result = new Date(date);
|
|
1291
|
+
result.setHours(23, 59, 59, 999);
|
|
1292
|
+
return result;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
private startOfWeek(date: Date) {
|
|
1296
|
+
const result = new Date(date);
|
|
1297
|
+
result.setHours(0, 0, 0, 0);
|
|
1298
|
+
const day = result.getDay();
|
|
1299
|
+
const diff = day === 0 ? -6 : 1 - day;
|
|
1300
|
+
result.setDate(result.getDate() + diff);
|
|
1301
|
+
return result;
|
|
1302
|
+
}
|
|
1303
|
+
}
|