[Completed courses] fix regression + add types

This commit is contained in:
joonas-a
2026-06-10 13:42:07 +03:00
parent 14c22af2ef
commit 7171bf198e
8 changed files with 154 additions and 118 deletions
@@ -2,8 +2,9 @@ import { Router } from 'express'
import { difference, intersection } from 'lodash-es'
import { CanError } from '@oodikone/shared/routes'
import type { CompletedCoursesStudent, CompletedCoursesCourse } from '@oodikone/shared/types'
import { tryCatch } from '@oodikone/shared/util'
import { Courses, FormattedStudent, getCompletedCourses } from '../services/completedCoursesSearch'
import { getCompletedCourses } from '../services/completedCoursesSearch'
import {
getUserSearches,
createNewUserSearch,
@@ -19,11 +20,14 @@ const router = Router()
const importerClient = getImporterClient()
export type SearchResBody = CanError<
{ discardedStudentNumbers: string[] } & { students: Omit<FormattedStudent, 'allEnrollments'>[]; courses: Courses }
type SearchResBody = CanError<
{ discardedStudentNumbers: string[] } & {
students: Omit<CompletedCoursesStudent, 'allEnrollments'>[]
courses: CompletedCoursesCourse[]
}
>
export type SearchReqBody = never
export type SearchQuery = {
type SearchReqBody = never
type SearchQuery = {
studentlist: string
courselist: string
}
@@ -1,62 +1,28 @@
import { col, fn, Op, where } from 'sequelize'
import { CreditTypeCode, EnrollmentState } from '@oodikone/shared/types'
import {
CompletedCoursesCourse,
CompletedCoursesStudent,
CreditTypeCode,
EnrollmentState,
StudentCredits,
} from '@oodikone/shared/types'
import { omitKeys } from '@oodikone/shared/util'
import { CourseModel, CreditModel, EnrollmentModel, StudentModel, StudyplanModel } from '../models'
export type Courses = Array<Pick<CourseModel, 'code' | 'name' | 'substitution_groups'>>
interface StudentCredit {
date: Date
courseCode: string
creditType: CreditTypeCode
substitution: string[] | null
}
interface StudentEnrollment {
date: Date
courseCode: string
substitution: string[] | null
}
interface StudentInfo {
firstnames: string
lastname: string
email: string
sis_person_id: string
coursesInStudyPlan: string[]
secondaryEmail: string | null
credits: StudentCredit[]
enrollments: Record<string, StudentEnrollment>
}
type StudentCredits = Record<
string,
Omit<StudentInfo, 'credits' | 'enrollments'> & {
credits: StudentCredit[]
enrollments: StudentEnrollment[]
}
>
export interface FormattedStudent extends StudentInfo {
studentNumber: string
allEnrollments: StudentEnrollment[]
}
interface StudentWithStudyplanNested
extends Pick<
StudentModel,
'studentnumber' | 'firstnames' | 'lastname' | 'email' | 'sis_person_id' | 'secondary_email'
> {
type StudentWithStudyplanNested = Pick<
StudentModel,
'studentnumber' | 'firstnames' | 'lastname' | 'email' | 'sis_person_id' | 'secondary_email'
> & {
studyplans: { included_courses: string[] }[]
}
interface StudentWithCourses extends Omit<StudentWithStudyplanNested, 'studyplans'> {
type StudentWithCourses = Omit<StudentWithStudyplanNested, 'studyplans'> & {
coursesInStudyPlan: string[]
}
const getCourses = async (courseCodes: string[]) => {
const courses: Courses = (
const courses: CompletedCoursesCourse[] = (
await CourseModel.findAll({
attributes: ['code', 'name', 'substitution_groups'],
where: where(fn('LOWER', col('code')), {
@@ -67,25 +33,28 @@ const getCourses = async (courseCodes: string[]) => {
return courses
}
const getPassedCredits = async (courses: Courses, fullCourseCodes: string[], studentNumbers: string[]) => {
const credits: Array<
Pick<CreditModel, 'course_code' | 'student_studentnumber' | 'credittypecode' | 'attainment_date'>
> = await CreditModel.findAll({
raw: true,
attributes: ['course_code', 'student_studentnumber', 'credittypecode', 'attainment_date'],
order: [['attainment_date', 'DESC']],
where: {
course_code: {
[Op.in]: fullCourseCodes,
const getPassedCredits = async (
courses: CompletedCoursesCourse[],
fullCourseCodes: string[],
studentNumbers: string[]
) => {
const credits: Pick<CreditModel, 'course_code' | 'student_studentnumber' | 'credittypecode' | 'attainment_date'>[] =
await CreditModel.findAll({
raw: true,
attributes: ['course_code', 'student_studentnumber', 'credittypecode', 'attainment_date'],
order: [['attainment_date', 'DESC']],
where: {
course_code: {
[Op.in]: fullCourseCodes,
},
student_studentnumber: {
[Op.in]: studentNumbers,
},
credittypecode: {
[Op.not]: CreditTypeCode.FAILED,
},
},
student_studentnumber: {
[Op.in]: studentNumbers,
},
credittypecode: {
[Op.not]: CreditTypeCode.FAILED,
},
},
})
})
const creditsByStudentNumber = Object.groupBy(credits, ({ student_studentnumber }) => student_studentnumber)
@@ -125,7 +94,11 @@ const getPassedCredits = async (courses: Courses, fullCourseCodes: string[], stu
return formattedCredits
}
const getEnrollments = async (courses: Courses, fullCourseCodes: string[], studentNumbers: string[]) => {
const getEnrollments = async (
courses: CompletedCoursesCourse[],
fullCourseCodes: string[],
studentNumbers: string[]
) => {
const enrollments: Array<Pick<EnrollmentModel, 'course_code' | 'enrollment_date_time' | 'studentnumber'>> =
await EnrollmentModel.findAll({
attributes: ['course_code', 'enrollment_date_time', 'studentnumber'],
@@ -208,7 +181,7 @@ const getStudents = async (studentNumbers: string[]): Promise<StudentWithCourses
export const getCompletedCourses = async (
studentNumbers: string[],
courseCodes: string[]
): Promise<{ students: Omit<FormattedStudent, 'allEnrollments'>[]; courses: Courses }> => {
): Promise<{ students: Omit<CompletedCoursesStudent, 'allEnrollments'>[]; courses: CompletedCoursesCourse[] }> => {
const courses = await getCourses(courseCodes)
const courseCodesSet = new Set(courseCodes)
@@ -280,22 +253,25 @@ export const getCompletedCourses = async (
})
})
const students = Object.keys(studentCredits).reduce((acc: FormattedStudent[], studentNumber) => {
const student = studentCredits[studentNumber]
acc.push({
studentNumber,
sis_person_id: student.sis_person_id,
credits: student.credits,
enrollments: {},
allEnrollments: student.enrollments,
firstnames: student.firstnames,
lastname: student.lastname,
email: student.email,
secondaryEmail: student.secondaryEmail,
coursesInStudyPlan: student.coursesInStudyPlan,
})
return acc
}, [] as FormattedStudent[])
const students = Object.keys(studentCredits).reduce<CompletedCoursesStudent[]>(
(acc: CompletedCoursesStudent[], studentNumber) => {
const student = studentCredits[studentNumber]
acc.push({
studentNumber,
sis_person_id: student.sis_person_id,
credits: student.credits,
enrollments: {},
allEnrollments: student.enrollments,
firstnames: student.firstnames,
lastname: student.lastname,
email: student.email,
secondaryEmail: student.secondaryEmail,
coursesInStudyPlan: student.coursesInStudyPlan,
})
return acc
},
[]
)
students.forEach(student => {
courseCodes.forEach(courseCode => {
@@ -17,20 +17,18 @@ import { useGetCompletedCoursesQuery } from '@/redux/completedCoursesSearch'
import { CropSquareIcon, DoneIcon, RemoveIcon } from '@/theme'
import { getDefaultMRTOptions } from '@/util/getDefaultMRTOptions'
import { formatDate, isWithinSixMonths } from '@/util/timeAndDate'
import { CreditTypeCode } from '@oodikone/shared/types'
import { CompletedCoursesStudent, CreditTypeCode } from '@oodikone/shared/types'
import { SearchValues } from '.'
const isPassed = credit => [CreditTypeCode.PASSED, CreditTypeCode.APPROVED, CreditTypeCode.IMPROVED].includes(credit)
const isPassed = (creditType: CreditTypeCode) =>
[CreditTypeCode.PASSED, CreditTypeCode.APPROVED, CreditTypeCode.IMPROVED].includes(creditType)
const getTotalPassed = student => student.credits.filter(credit => isPassed(credit)).length
const getTotalUnfinished = student => Object.values(student.enrollments).length
const getCompletion = (student, courseCode, { icon }) => {
const getCompletion = (student: CompletedCoursesStudent, courseCode: string, icon: boolean) => {
const completion = student.credits.find(credit => credit.courseCode === courseCode && isPassed(credit.creditType))
const enrollment = student.enrollments[courseCode]
const isInStudyPlan = student.coursesInStudyPlan.includes(courseCode)
if (completion === undefined) {
if (!completion) {
if (!enrollment) {
if (isInStudyPlan) {
return <CropSquareIcon fontSize="small" style={{ color: grey[500] }} />
@@ -47,9 +45,8 @@ const getCompletion = (student, courseCode, { icon }) => {
return `Latest enrollment: ${formatDate(enrollment.date, DateFormat.ISO_DATE)}`
}
const substituted = !!completion.substitution?.length
const substitutionString = substituted ? ` as ${completion.substitution.join(', ')}` : ''
const substituted = Array.isArray(completion.substitution) && !!completion.substitution.length
const substitutionString = substituted ? ` as ${completion.substitution!.join(', ')}` : ''
return icon ? (
<DoneIcon fontSize="small" style={{ color: substituted ? grey[700] : green[700] }} />
) : (
@@ -57,13 +54,13 @@ const getCompletion = (student, courseCode, { icon }) => {
)
}
const getCellTitle = (student, courseCode) => {
const getCellTitle = (student: CompletedCoursesStudent, courseCode: string) => {
const credit = student.credits.find(credit => credit.courseCode === courseCode)
const enrollment = student.enrollments[courseCode]
if (!credit && !enrollment) {
return 'Student has the course in their primary study plan'
}
let title
let title: string
if (credit) {
const substitutionString = credit.substitution?.length ? `Substituted by: ${credit.substitution?.join(', ')}` : ''
title = `Passed on ${formatDate(credit.date, DateFormat.ISO_DATE)}\n${substitutionString}`
@@ -98,7 +95,7 @@ const RightsNotification = ({ discardedStudentNumbers }: { discardedStudentNumbe
)
}
export const SearchResults = ({ searchValues }) => {
export const SearchResults = ({ searchValues }: { searchValues: SearchValues }) => {
const { courseList, studentList } = searchValues
const { visible: namesVisible } = useStudentNameVisibility()
const { data, isFetching } = useGetCompletedCoursesQuery({ courseList, studentList })
@@ -116,8 +113,12 @@ export const SearchResults = ({ searchValues }) => {
})
}, [namesVisible])
const staticColumns = useMemo<MRT_ColumnDef<any>[]>(
() => [
const staticColumns = useMemo<MRT_ColumnDef<CompletedCoursesStudent>[]>(() => {
const getTotalPassed = (student: CompletedCoursesStudent) =>
student.credits.filter(credit => isPassed(credit.creditType)).length
const getTotalUnfinished = (student: CompletedCoursesStudent) =>
(data?.courses?.length ?? 0) - student.credits.length
return [
{
accessorKey: 'lastname',
header: 'Last name',
@@ -139,27 +140,27 @@ export const SearchResults = ({ searchValues }) => {
header: 'Email',
},
{
accessorFn: student => getTotalPassed(student),
accessorFn: getTotalPassed,
id: 'passed',
header: 'Passed',
},
{
accessorFn: student => getTotalUnfinished(student),
accessorFn: getTotalUnfinished,
id: 'unfinished',
header: 'Unfinished',
},
],
[]
)
]
}, [data])
const dynamicColumns = useMemo(() => {
const dynamicColumns = useMemo<MRT_ColumnDef<CompletedCoursesStudent>[]>(() => {
if (!data) return []
const courseOrder = courseList ?? []
return (
data?.courses
.toSorted(({ code: a }, { code: b }) => courseOrder.indexOf(b) < courseOrder.indexOf(a))
data.courses
.toSorted((a, b) => courseOrder.indexOf(a.code) - courseOrder.indexOf(b.code))
.map(course => ({
accessorFn: student => getCompletion(student, course.code, { icon: false }),
accessorFn: (student: CompletedCoursesStudent) => getCompletion(student, course.code, false),
id: course.code,
header: `${course.code} – ${getTextIn(course.name)}`,
Header: () => (
@@ -173,14 +174,17 @@ export const SearchResults = ({ searchValues }) => {
sx={{ display: 'flex', justifyContent: 'center' }}
title={getCellTitle(cell.row.original, course.code)}
>
{getCompletion(cell.row.original, course.code, { icon: true })}
{getCompletion(cell.row.original, course.code, true)}
</Box>
),
})) ?? []
)
}, [data, getTextIn])
const columns = useMemo(() => [...staticColumns, ...dynamicColumns], [staticColumns, dynamicColumns])
const columns: any[] /* MRT getting replaced anyway */ = useMemo(
() => [...staticColumns, ...dynamicColumns],
[staticColumns, dynamicColumns]
)
const defaultOptions = getDefaultMRTOptions(setExportData, setExportModalOpen, language)
@@ -204,7 +208,7 @@ export const SearchResults = ({ searchValues }) => {
onClose={() => setExportModalOpen(false)}
open={exportModalOpen}
/>
{data?.discardedStudentNumbers?.length > 0 && (
{!!data?.discardedStudentNumbers?.length && (
<div style={{ width: '75%' }}>
<RightsNotification discardedStudentNumbers={data.discardedStudentNumbers} />
</div>
@@ -14,9 +14,11 @@ import { CropSquareIcon, DoneIcon, RemoveIcon } from '@/theme'
import { SearchModal } from './SearchModal'
import { SearchResults } from './SearchResults'
export type SearchValues = { courseList: string[]; studentList: string[] }
export const CompletedCourses = () => {
useTitle('Completed courses of students')
const [searchValues, setValues] = useState({ courseList: [], studentList: [] })
const [searchValues, setValues] = useState<SearchValues>({ courseList: [], studentList: [] })
return (
<PageLayout maxWidth="lg">
@@ -1,8 +1,9 @@
import { RTKApi } from '@/apiConnection'
import type { CompletedCoursesRes } from '@oodikone/shared/routes/completedCoursesOfStudents'
const completedCoursesSearchApi = RTKApi.injectEndpoints({
endpoints: builder => ({
getCompletedCourses: builder.query<any, { courseList: string[]; studentList: string[] }>({
getCompletedCourses: builder.query<CompletedCoursesRes, { courseList: string[]; studentList: string[] }>({
query: ({ courseList, studentList }) =>
`completedcoursessearch?courselist=${JSON.stringify(courseList)}&studentlist=${JSON.stringify(studentList)}`,
}),
@@ -0,0 +1,7 @@
import type { CompletedCoursesCourse, CompletedCoursesStudent } from '../types'
export type CompletedCoursesRes = {
discardedStudentNumbers: string[]
students: Omit<CompletedCoursesStudent, 'allEnrollments'>[]
courses: CompletedCoursesCourse[]
}
+41
View File
@@ -0,0 +1,41 @@
import { Course } from '../models'
import { CreditTypeCode } from '../types'
type StudentCredit = {
date: Date
courseCode: string
creditType: CreditTypeCode
substitution: string[] | null
}
type StudentEnrollment = {
date: Date
courseCode: string
substitution: string[] | null
}
type StudentInfo = {
firstnames: string
lastname: string
email: string
sis_person_id: string
coursesInStudyPlan: string[]
secondaryEmail: string | null
credits: StudentCredit[]
enrollments: Record<string, StudentEnrollment>
}
export type StudentCredits = Record<
string,
Omit<StudentInfo, 'credits' | 'enrollments'> & {
credits: StudentCredit[]
enrollments: StudentEnrollment[]
}
>
export type CompletedCoursesStudent = StudentInfo & {
studentNumber: string
allEnrollments: StudentEnrollment[]
}
export type CompletedCoursesCourse = Pick<Course, 'code' | 'name' | 'substitution_groups'>
+1
View File
@@ -61,3 +61,4 @@ export { ExtentCode } from './extentCode'
export { DegreeProgrammeType } from './degreeProgrammeType'
export { Phase } from './phase'
export { Unification } from './unification'
export type { StudentCredits, CompletedCoursesStudent, CompletedCoursesCourse } from './completedCourses'