[CustomPopulation] Added option to make population with programmes

This commit is contained in:
Aaro Koivukangas
2026-08-18 10:23:12 +03:00
parent 7d3b7b8227
commit 2449dc521f
20 changed files with 632 additions and 113 deletions
@@ -0,0 +1,24 @@
const { STRING, JSONB } = require('sequelize')
module.exports = {
up: async ({ context: queryInterface }) => {
await queryInterface.addColumn('custom_population_searches', 'mode', {
type: STRING,
})
await queryInterface.addColumn('custom_population_searches', 'programmes', {
type: JSONB,
})
await queryInterface.addColumn('custom_population_searches', 'year', {
type: STRING,
})
await queryInterface.bulkUpdate('custom_population_searches', { mode: 'studentNumbers' }, {})
},
down: async ({ context: queryInterface }) => {
await queryInterface.bulkDelete('custom_population_searches', [{ mode: 'programmes' }])
await queryInterface.removeColumn('custom_population_searches', 'mode')
await queryInterface.removeColumn('custom_population_searches', 'programmes')
await queryInterface.removeColumn('custom_population_searches', 'year')
},
}
@@ -19,6 +19,15 @@ export class CustomPopulationSearchModel extends Model implements CustomPopulati
@Column(DataType.STRING)
declare name: CustomPopulationSearch['name']
@Column(DataType.STRING)
declare mode: CustomPopulationSearch['mode']
@Column(DataType.JSONB)
declare programmes: CustomPopulationSearch['programmes']
@Column(DataType.STRING)
declare year: CustomPopulationSearch['year']
@Column(DataType.ARRAY(DataType.STRING))
declare students: CustomPopulationSearch['students']
}
@@ -21,51 +21,75 @@ router.get<never, GetCustomPopulationSearchResBody>('/', async (req, res) => {
type PostCustomPopulationSearchReqBody = {
name: string
mode: 'studentNumbers' | 'programmes'
students: string[]
programmes: { code: string; name: string }[]
year: string
}
type PostCustomPopulationSearchResBody = CustomPopulationSearch
router.post<never, CanError<PostCustomPopulationSearchResBody>, PostCustomPopulationSearchReqBody>(
'/',
async (req, res) => {
const { name, students } = req.body
const { name, mode, students, programmes, year } = req.body
const { id } = req.user
if (!name) {
return res.status(400).json({ error: 'Name missing' })
}
if (students && !Array.isArray(students)) {
return res.status(400).json({ error: 'Students must be of type array' })
if (!mode) {
return res.status(400).json({ error: 'Search mode missing!!!' })
}
const customPopulationSearch = await createCustomPopulationSearch(name, id, students ?? [])
if (mode === 'studentNumbers' && students && !Array.isArray(students))
return res.status(400).json({ error: 'Students must be of type array' })
if (mode === 'programmes' && !year) return res.status(400).json({ error: 'Year missing' })
if (mode === 'programmes' && programmes && !Array.isArray(programmes))
return res.status(400).json({ error: 'Programmes must be of type array' })
const customPopulationSearch = await createCustomPopulationSearch(
name,
id,
mode,
students ?? [],
programmes ?? [],
year
)
res.json(customPopulationSearch)
}
)
type PutCustomPopulationSearchReqBody = {
mode: 'studentNumbers' | 'programmes'
students: string[]
programmes: { code: string; name: string }[]
year: string
}
type PutCustomPopulationSearchResBody = CustomPopulationSearch
router.put<never, CanError<PutCustomPopulationSearchResBody>, PutCustomPopulationSearchReqBody>(
'/:id',
async (req, res) => {
const { students } = req.body
const { mode, students, programmes, year } = req.body
const { id } = req.params
const { id: userId } = req.user
if (!id) {
return res.status(400).json({ error: 'Id missing' })
}
if (!students) {
return res.status(400).json({ error: 'Students missing' })
}
if (!Array.isArray(students)) {
return res.status(400).json({ error: 'Students must be of type array' })
if (!mode) {
return res.status(400).json({ error: 'Search mode missing' })
}
const updatedPopulationSearch = await updateCustomPopulationSearch(userId, id, students)
if (mode === 'studentNumbers' && students && !Array.isArray(students))
return res.status(400).json({ error: 'Students must be of type array' })
if (mode === 'programmes' && !year) return res.status(400).json({ error: 'Year missing' })
if (mode === 'programmes' && programmes && !Array.isArray(programmes))
return res.status(400).json({ error: 'Programmes must be of type array' })
const updatedPopulationSearch = await updateCustomPopulationSearch(userId, id, mode, students, programmes, year)
if (!updatedPopulationSearch) {
return res.status(404).json({ error: 'Custom population search not found' })
}
+67 -9
View File
@@ -9,8 +9,10 @@ import {
type PopulationstatisticsbycourseResBody,
type PopulationstatisticsbycourseReqBody,
type PopulationstatisticsbycourseParams,
type GetCustomPopulationResBody,
type CustomPopulationQuery,
type CustomPopulationByStudentNumbersQuery,
type CustomPopulationByStudentNumbersResBody,
type CustomPopulationByProgrammesQuery,
type CustomPopulationByProgrammesResBody,
type PopulationstatisticsStudyprogrammesResBody,
PopulationstatisticsMaxYearsToCreatePopulationFormQuery,
PopulationstatisticsMaxYearsToCreatePopulationFormResBody,
@@ -109,7 +111,7 @@ router.get<never, CanError<PopulationstatisticsResBody>, PopulationstatisticsReq
const years = handleQueryArrays(req.query.years)
const semesters = handleQueryArrays(req.query.semesters)
const studentStatuses = handleQueryArrays(req.query.studentStatuses) // NOTE: TRANSFERRED means transferred out of the program
const studentStatuses = handleQueryArrays(req.query.studentStatuses ?? []) // NOTE: TRANSFERRED means transferred out of the program
const requiredFields = [years, semesters, programme]
if (requiredFields.some(field => !field.length)) {
@@ -234,13 +236,13 @@ router.get<
})
// Used in custom population and single study guidance groups
router.post<never, CanError<GetCustomPopulationResBody>, CustomPopulationQuery>(
router.post<never, CanError<CustomPopulationByStudentNumbersResBody>, CustomPopulationByStudentNumbersQuery>(
'/populationstatisticsbystudentnumbers',
async (req, res) => {
const { studentNumbers, tags } = req.body ?? { studentNumbers: [] }
const { id: userId, roles, studentsUserCanAccess } = req.user
const students = handleQueryArrays(studentNumbers)
const students = handleQueryArrays(studentNumbers ?? [])
if (!students.length) {
return res.status(422).json({ error: 'Body should include student numbers' })
@@ -251,14 +253,14 @@ router.post<never, CanError<GetCustomPopulationResBody>, CustomPopulationQuery>(
: intersection(students, studentsUserCanAccess)
const studyProgrammeCode = tags?.studyProgramme?.split('+')[0]
const studyRights = [studyProgrammeCode].filter(value => value !== undefined)
const tagMap = await getStudentTagMap(studyRights, filteredStudentNumbers, userId)
const filterCreditsAndEnrollmentsByDate = false
const { startDate } = tags?.year
? parseDateRangeFromParams({ semesters: ['FALL', 'SPRING'], years: [tags?.year] })
: { startDate: undefined }
const startDate = tags?.year
? parseDateRangeFromParams({ semesters: ['FALL', 'SPRING'], years: [tags?.year] }).startDate
: undefined
const result = await statisticsOf(
filteredStudentNumbers,
@@ -275,6 +277,62 @@ router.post<never, CanError<GetCustomPopulationResBody>, CustomPopulationQuery>(
}
)
// Used in custom population
router.post<never, CanError<CustomPopulationByProgrammesResBody>, CustomPopulationByProgrammesQuery>(
'/populationstatisticsbyprogrammecodes',
async (req, res) => {
const { programmes: programmesParam, years: yearsParam } = req.body ?? {}
const { id: userId, roles, studentsUserCanAccess, programmeRights: userProgrammeRights } = req.user
const programmes = handleQueryArrays(programmesParam ?? [])
const years = handleQueryArrays(yearsParam ?? [])
if (!years?.length) {
return res.status(400).json({ error: 'Programme based populations require years to be defined' })
}
const hasFullAccessToStudents = hasFullAccessToStudentData(roles)
const userProgrammeRightsCodes = userProgrammeRights.map(({ code }) => code)
const unauthorizedProgrammes = programmes.filter(code => !userProgrammeRightsCodes.includes(code))
if (!hasFullAccessToStudents && unauthorizedProgrammes.length) {
return res.status(403).json({ error: 'Trying to request unauthorized students data' })
}
const { startDate: parsedStartDate, endDate } = parseDateRangeFromParams({
semesters: ['FALL', 'SPRING'],
years,
})
const studentNumbersWithStudyRights = await getStudentNumbersWithStudyRights({
programmeCodes: programmes,
startDate: parsedStartDate,
endDate,
// Continuing education programmes (e.g. EEL) have non-degree study rights
studentStatuses: ['NONDEGREE'],
})
const filteredStudentNumbers = hasFullAccessToStudents
? studentNumbersWithStudyRights
: intersection(studentNumbersWithStudyRights, studentsUserCanAccess)
const studyRights = programmes
const tagMap = await getStudentTagMap(studyRights, filteredStudentNumbers, userId)
const filterCreditsAndEnrollmentsByDate = false
const result = await statisticsOf(
filteredStudentNumbers,
studyRights,
tagMap,
filterCreditsAndEnrollmentsByDate,
parsedStartDate
)
res.status(200).json(result)
}
)
router.get<never, PopulationstatisticsStudyprogrammesResBody>(
'/populationstatistics/studyprogrammes',
async (req, res) => {
@@ -8,15 +8,32 @@ export const getCustomPopulationSearchesByUser = async (userId: string) => {
})
}
export const createCustomPopulationSearch = async (name: string, userId: string, students: string[]) => {
export const createCustomPopulationSearch = async (
name: string,
userId: string,
mode: 'studentNumbers' | 'programmes',
students: string[],
programmes: { code: string; name: string }[],
year: string
) => {
return CustomPopulationSearchModel.create({
name,
userId,
students,
mode,
students: mode === 'studentNumbers' ? students : [],
programmes: mode === 'programmes' ? programmes : [],
year: mode === 'programmes' ? year : null,
})
}
export const updateCustomPopulationSearch = async (userId: string, id: string, students: string[]) => {
export const updateCustomPopulationSearch = async (
userId: string,
id: string,
mode: 'studentNumbers' | 'programmes',
students: string[],
programmes: { code: string; name: string }[],
year: string
) => {
const targetCustomPopulationSearch = await CustomPopulationSearchModel.findOne({
where: {
id,
@@ -29,7 +46,10 @@ export const updateCustomPopulationSearch = async (userId: string, id: string, s
}
return targetCustomPopulationSearch.update({
students,
mode,
students: mode === 'studentNumbers' ? students : [],
programmes: mode === 'programmes' ? programmes : [],
year: mode === 'programmes' ? year : null,
})
}
@@ -22,7 +22,17 @@ type StudentStudyRights = Pick<SISStudyRightModel, 'studentNumber' | 'studyRight
studyRightElements: Array<Pick<SISStudyRightElementModel, 'code' | 'endDate' | 'startDate' | 'phase'>>
}
export const getStudentNumbersWithStudyRights = async ({ programmeCodes, startDate, endDate, studentStatuses }) => {
export const getStudentNumbersWithStudyRights = async ({
programmeCodes,
startDate,
endDate,
studentStatuses,
}: {
programmeCodes: string[]
startDate: string
endDate: string
studentStatuses: string[]
}) => {
const includeExchangeStudents = !!studentStatuses?.includes('EXCHANGE')
const includeNondegreeStudents = !!studentStatuses?.includes('NONDEGREE')
const includeTransferredOutStudents = !!studentStatuses?.includes('TRANSFERRED')
@@ -0,0 +1,54 @@
import Button from '@mui/material/Button'
import Tooltip from '@mui/material/Tooltip'
import { utils, writeFile } from 'xlsx'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { DownloadIcon } from '@/theme'
import { FilteredCourse } from '@/util/coursesOfPopulation'
import { getTimestamp } from '@/util/timeAndDate'
import { FormattedStudent } from '@oodikone/shared/types'
import { calculateExcelData } from '@/components/CustomPopulation/courseMatrix'
export const CourseMatrixExport = ({
students,
courses,
}: {
students: FormattedStudent[]
courses: FilteredCourse[]
}) => {
const { getTextIn } = useLanguage()
const getXlsx = () => {
const courseNameByCode = new Map(courses.map(({ course }) => [course.code, getTextIn(course.name) ?? '']))
const { completedCoursesRows, courseCounterRows } = calculateExcelData(students, courseNameByCode)
// Columns: Student number, Name, student's attainments in the format: "course name (course code)"
const completedCoursesSheet = utils.aoa_to_sheet([['Student number', 'Name'], ...completedCoursesRows])
// Columns: Code, Course name, Student count, Total credits
const courseCounterSheet = utils.aoa_to_sheet([
['Code', 'Course name', 'Student count', 'Total credits'],
...courseCounterRows,
])
const workbook = utils.book_new()
utils.book_append_sheet(workbook, completedCoursesSheet, 'Completed courses')
utils.book_append_sheet(workbook, courseCounterSheet, 'Course statistics')
return workbook
}
const filename = `oodikone_course_matrix_${students.length}_students_${getTimestamp()}.xlsx`
return (
<Tooltip title="Download an Excel workbook with each student's completed (HOPS-included) courses.">
<Button
color="primary"
onClick={() => writeFile(getXlsx(), filename)}
startIcon={<DownloadIcon />}
variant="contained"
>
Export courses
</Button>
</Tooltip>
)
}
@@ -2,6 +2,7 @@ import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Chip from '@mui/material/Chip'
import Stack from '@mui/material/Stack'
import Tooltip from '@mui/material/Tooltip'
import Typography from '@mui/material/Typography'
import { useEffect } from 'react'
@@ -11,6 +12,7 @@ import { PageTitle } from '@/components/common/PageTitle'
import { PanelView } from '@/components/common/PanelView'
import { StudentAmountLimiter } from '@/components/common/StudentAmountLimiter'
import { StyledMessage } from '@/components/common/StyledMessage'
import { CourseMatrixExport } from '@/components/CustomPopulation/CourseMatrixExport'
import { useColumns as columnsGeneralTab } from '@/components/CustomPopulation/studentColumns'
import { UnihowDataExport } from '@/components/CustomPopulation/UnihowDataExport'
import { InfoBox } from '@/components/InfoBox/InfoBoxWithTooltip'
@@ -34,6 +36,7 @@ export const CustomPopulationContent = ({
discardedStudentNumbers,
unfilteredPopulationLength,
associatedProgramme,
programmes,
isFetchingPopulation,
resetState,
}: {
@@ -43,6 +46,7 @@ export const CustomPopulationContent = ({
discardedStudentNumbers: string[]
unfilteredPopulationLength: number
associatedProgramme?: string | null
programmes: string[]
isFetchingPopulation: boolean
resetState: () => void
}) => {
@@ -94,7 +98,12 @@ export const CustomPopulationContent = ({
title: `Students (${filteredStudents.length})`,
content: (
<PopulationStudents
dataExport={<UnihowDataExport students={filteredStudents} />}
dataExport={
<Stack direction="row" spacing={1}>
<UnihowDataExport students={filteredStudents} />
<CourseMatrixExport courses={filteredCourses} students={filteredStudents} />
</Stack>
}
filteredStudents={filteredStudents}
generalTabColumnFunction={() =>
columnsGeneralTab({
@@ -137,7 +146,20 @@ export const CustomPopulationContent = ({
Back to search form
</Button>
<Box sx={{ flex: 1, display: 'flex', justifyContent: 'center' }}>
{associatedProgramme ? (
{programmes.length > 0 ? (
<Stack direction="row" spacing={1}>
{programmes.map(programme => (
<Chip
key={programme}
color="primary"
icon={<LabelIcon fontSize="small" />}
label={programme}
sx={{ width: 'fit-content', p: 1, justifySelf: 'center' }}
variant="filled"
/>
))}
</Stack>
) : associatedProgramme ? (
<Tooltip title="Degree programme associated with the custom population">
<Chip
color="primary"
@@ -9,24 +9,38 @@ import { useState } from 'react'
import { extractItems } from '@/common'
import { PageTitle } from '@/components/common/PageTitle'
import { SearchHistory } from '@/components/common/SearchHistory'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { EnrollmentDateSelector } from '@/components/PopulationSearch/EnrollmentDateSelector'
import { Section } from '@/components/Section'
import { CustomPopulationState } from '@/components/CustomPopulation/'
import { Section } from '@/components/Section'
import {
useCreateCustomPopulationSearchMutation,
useDeleteCustomPopulationSearchMutation,
useGetCustomPopulationSearchesQuery,
useUpdateCustomPopulationSearchMutation,
} from '@/redux/customPopulationSearch'
import { useGetProgrammesQuery } from '@/redux/populations'
import { useFilteredAndFormattedStudyProgrammes } from '@/redux/studyProgramme'
import { DeleteIcon, SaveIcon, SendIcon } from '@/theme'
import { CustomPopulationSearch } from '@oodikone/shared/models/kone'
import { ToggleContainer } from '../common/toggle/ToggleContainer'
import { Toggle } from '../common/toggle/Toggle'
const customPopulationInfo = `
In this view you can search for a custom population with a list of student numbers. A custom population can be saved
by giving it a name and clicking the save button in the bottom. Saved populations are personal; they will only be visible to you. You can only search for students you have access rights to i.e. you have rights to the programme they are in.
In this view you can search for a custom population with a list of student numbers or with study programme(s) and
an academic year. A custom population can be saved by giving it a name and clicking the save button in the bottom.
Saved populations are personal; they will only be visible to you. You can only search for students you have access
rights to i.e. you have rights to the programme they are in.
`
const getDefaultYear = () => {
const date = new Date()
return date.getMonth() < 7 ? date.getFullYear() - 1 : date.getFullYear()
}
type StudyProgrammeOption = { code: string; name: string }
export const CustomPopulationSearchForm = ({
setCustomPopulationState,
showPopulation,
@@ -34,16 +48,31 @@ export const CustomPopulationSearchForm = ({
setCustomPopulationState: React.Dispatch<React.SetStateAction<CustomPopulationState>>
showPopulation: () => void
}) => {
const { getTextIn } = useLanguage()
// Form mode
const [searchMode, setSearchMode] = useState<'studentNumbers' | 'programmes'>('studentNumbers')
// Form values
const [nameInput, setNameInput] = useState<string>('')
const [studentNumberInput, setStudentNumberInput] = useState<string>('')
const [associatedProgramme, setAssociatedProgramme] = useState<(typeof studyProgrammes)[number] | null>(null)
const [associatedProgramme, setAssociatedProgramme] = useState<(typeof filteredStudyProgrammes)[number] | null>(null)
const [selectedSearch, setSelectedSearch] = useState<CustomPopulationSearch | null>(null)
// Programme based form values
const [year, setYear] = useState<number>(getDefaultYear())
const [selectedProgrammes, setSelectedProgrammes] = useState<StudyProgrammeOption[]>([])
const [prefixInput, setPrefixInput] = useState<string>('')
// Only show programmes the current user has access to,
// see customPopulationInfo above
const studyProgrammes = useFilteredAndFormattedStudyProgrammes().filteredProgrammes
const filteredStudyProgrammes = useFilteredAndFormattedStudyProgrammes().filteredProgrammes
const { data: rawProgrammesData } = useGetProgrammesQuery()
const filteredProgrammes: StudyProgrammeOption[] = Object.values(rawProgrammesData?.filteredProgrammes ?? {}).map(
({ code, name }) => ({ code, name: getTextIn(name) ?? '' })
)
const { data: searches, isFetching } = useGetCustomPopulationSearchesQuery(undefined)
const [createSearch] = useCreateCustomPopulationSearchMutation()
@@ -54,19 +83,26 @@ export const CustomPopulationSearchForm = ({
setNameInput(newName)
}
const handleSearchModeChange = (isModeProgrammes: boolean) => {
setSearchMode(isModeProgrammes ? 'programmes' : 'studentNumbers')
}
const clearForm = () => {
setNameInput('')
setStudentNumberInput('')
setAssociatedProgramme(null)
setSelectedSearch(null)
setSelectedProgrammes([])
setPrefixInput('')
setYear(getDefaultYear())
}
const onSave = () => {
const students = extractItems(studentNumberInput)
if (selectedSearch) {
void updateSearch({ id: selectedSearch.id, students })
void updateSearch({ id: selectedSearch.id, mode: searchMode, students, programmes: selectedProgrammes, year })
} else {
void createSearch({ name: nameInput, students })
void createSearch({ name: nameInput, mode: searchMode, students, programmes: selectedProgrammes, year })
}
}
@@ -87,15 +123,48 @@ export const CustomPopulationSearchForm = ({
setStudentNumberInput(selectedSearch.students.join('\n'))
setNameInput(selectedSearch.name)
setSelectedSearch(selectedSearch)
setSearchMode(selectedSearch.mode)
setSelectedProgrammes(selectedSearch.programmes)
const year = parseInt(selectedSearch.year)
setYear(!isNaN(year) ? year : getDefaultYear())
}
}
const addProgrammesByPrefix = () => {
const prefix = prefixInput.trim().toUpperCase()
if (!prefix) return
const programmesToAdd = filteredProgrammes.filter(
programme =>
programme.code.toUpperCase().startsWith(prefix) &&
!selectedProgrammes.some(selected => selected.code === programme.code)
)
setSelectedProgrammes(prev => [...prev, ...programmesToAdd])
setPrefixInput('')
}
const onSearch = (event: React.MouseEvent) => {
event.preventDefault()
const studentNumbers = extractItems(studentNumberInput)
if (studentNumbers.length) {
setCustomPopulationState({ selectedSearch, studentNumbers, associatedProgramme: associatedProgramme?.key })
if (searchMode === 'studentNumbers') {
const studentNumbers = extractItems(studentNumberInput)
if (studentNumbers.length) {
setCustomPopulationState({
selectedSearch,
studentNumbers,
associatedProgramme: associatedProgramme?.key,
programmes: [],
years: [],
})
showPopulation()
}
} else if (searchMode === 'programmes') {
setCustomPopulationState({
selectedSearch: null,
studentNumbers: [],
associatedProgramme: undefined,
programmes: selectedProgrammes.map(programme => programme.code),
years: [year.toString()],
})
showPopulation()
}
}
@@ -110,6 +179,16 @@ export const CustomPopulationSearchForm = ({
infoBoxContent={customPopulationInfo}
title="New custom population"
>
<ToggleContainer>
<Toggle
cypress="search-mode"
firstLabel="By student numbers"
secondLabel="By study programme(s)"
setValue={handleSearchModeChange}
value={searchMode === 'programmes'}
/>
</ToggleContainer>
<Box>
<Typography>Insert a name for this custom population if you wish to save it</Typography>
<TextField
@@ -122,49 +201,121 @@ export const CustomPopulationSearchForm = ({
/>
</Box>
<Box>
<Typography>
Insert student numbers to use for the population. Each student number needs to be separated with a comma, a
semicolon, a space, or a line break.
</Typography>
<TextField
data-cy="student-number-input"
fullWidth
multiline
onChange={event => setStudentNumberInput(event.target.value)}
placeholder={'012345678\n012345679'}
rows={7}
value={studentNumberInput}
/>
</Box>
{searchMode === 'studentNumbers' ? (
<>
<Box>
<Typography>
Insert student numbers to use for the population. Each student number needs to be separated with a
comma, a semicolon, a space, or a line break.
</Typography>
<TextField
data-cy="student-number-input"
fullWidth
multiline
onChange={event => setStudentNumberInput(event.target.value)}
placeholder={'012345678\n012345679'}
rows={7}
value={studentNumberInput}
/>
</Box>
<Box>
<Typography>
(Optional) Associate a degree programme for this search. This will affect how some degree programme
dependent statistics are calculated. If unset, defaults to the latest active degree programme for each
student.
</Typography>
<Autocomplete
autoHighlight
getOptionLabel={opt => `${opt.text} - ${opt.value}`}
isOptionEqualToValue={(opt, value) => opt.value === value.value}
onChange={(_, value) => setAssociatedProgramme(value)}
options={studyProgrammes}
renderInput={params => (
<TextField {...params} placeholder="Search for degree programme" sx={{ p: 0, border: 'none' }} />
)}
renderOption={(props, option) => {
const { key, ...optionProps } = props
return (
<li key={key} style={{ justifyContent: 'space-between' }} {...optionProps}>
<Typography>{option.text}</Typography>
<Typography fontWeight="lighter">{option.description}</Typography>
</li>
)
}}
value={associatedProgramme}
/>
</Box>
<Box>
<Typography>
(Optional) Associate a degree programme for this search. This will affect how some degree programme
dependent statistics are calculated. If unset, defaults to the latest active degree programme for each
student.
</Typography>
<Autocomplete
autoHighlight
getOptionLabel={opt => `${opt.text} - ${opt.value}`}
isOptionEqualToValue={(opt, value) => opt.value === value.value}
onChange={(_, value) => setAssociatedProgramme(value)}
options={filteredStudyProgrammes}
renderInput={params => (
<TextField {...params} placeholder="Search for degree programme" sx={{ p: 0, border: 'none' }} />
)}
renderOption={(props, option) => {
const { key, ...optionProps } = props
return (
<li key={key} style={{ justifyContent: 'space-between' }} {...optionProps}>
<Typography>{option.text}</Typography>
<Typography fontWeight="lighter">{option.description}</Typography>
</li>
)
}}
value={associatedProgramme}
/>
</Box>
</>
) : (
<>
<Box>
<Typography>Academic year in which the students started in the selected programme(s)</Typography>
<EnrollmentDateSelector setYear={setYear} year={year} slim />
</Box>
<Box>
<Typography>Select one or more study programmes for the population.</Typography>
<Autocomplete
autoHighlight
fullWidth
getOptionLabel={opt => `${opt.name} - ${opt.code}`}
isOptionEqualToValue={(opt, value) => opt.code === value.code}
multiple
onChange={(_, value) => setSelectedProgrammes(value)}
options={filteredProgrammes}
renderInput={params => (
<TextField
{...params}
data-cy="custom-population-programme-selector"
placeholder="Select study programme(s)"
/>
)}
renderOption={(props, option) => {
const { key, ...optionProps } = props
return (
<li key={key} style={{ justifyContent: 'space-between' }} {...optionProps}>
<Typography>{option.name}</Typography>
<Typography fontWeight="lighter">{option.code}</Typography>
</li>
)
}}
value={selectedProgrammes}
/>
</Box>
<Box>
<Typography>
Alternatively add all programmes starting with a given prefix, e.g. EEL. Students with non-degree study
rights are included automatically.
</Typography>
<Stack direction="row" spacing={1}>
<TextField
data-cy="custom-population-programme-prefix-input"
fullWidth
onChange={event => setPrefixInput(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault()
addProgrammesByPrefix()
}
}}
placeholder="e.g. EEL"
size="small"
value={prefixInput}
/>
<Button
data-cy="custom-population-add-by-prefix-button"
disabled={!prefixInput.trim()}
onClick={addProgrammesByPrefix}
variant="outlined"
>
Add all programmes starting with prefix
</Button>
</Stack>
</Box>
</>
)}
<SearchHistory
handleSearch={selected => onSelectSearch(selected?.id)}
@@ -183,7 +334,11 @@ export const CustomPopulationSearchForm = ({
<Box>
<Button
color="success"
disabled={!nameInput || isFetching || !studentNumberInput}
disabled={
searchMode === 'studentNumbers'
? !nameInput || isFetching || !studentNumberInput
: !nameInput || isFetching || !selectedProgrammes.length
}
endIcon={<SaveIcon />}
loading={isFetching}
onClick={onSave}
@@ -208,7 +363,7 @@ export const CustomPopulationSearchForm = ({
</Button>
<Button
data-cy="search-button"
disabled={!studentNumberInput}
disabled={searchMode === 'studentNumbers' ? !studentNumberInput : selectedProgrammes.length === 0}
endIcon={<SendIcon />}
onClick={event => onSearch(event)}
sx={{ ml: 1 }}
@@ -19,7 +19,10 @@ import {
} from '@/components/FilterView/filters'
import { GenericFilter } from '@/components/FilterView/filters/createFilter'
import { useSemesters } from '@/hooks/useSemesters'
import { useGetCustomPopulationQuery } from '@/redux/populations'
import {
useGetCustomPopulationByProgrammesQuery,
useGetCustomPopulationByStudentNumbersQuery,
} from '@/redux/populations'
export const CustomPopulationWrapper = ({
customPopulationState,
@@ -30,7 +33,7 @@ export const CustomPopulationWrapper = ({
}) => {
const { semesters } = useSemesters()
const { data: population, isFetching } = useGetCustomPopulationQuery(
const studentNumbersPopulation = useGetCustomPopulationByStudentNumbersQuery(
{
studentNumbers: customPopulationState.studentNumbers,
tags: { studyProgramme: customPopulationState.associatedProgramme },
@@ -38,9 +41,21 @@ export const CustomPopulationWrapper = ({
{ skip: !customPopulationState.studentNumbers.length }
)
const programmesPopulation = useGetCustomPopulationByProgrammesQuery(
{
programmes: customPopulationState.programmes,
years: customPopulationState.years,
},
{ skip: !customPopulationState.programmes.length }
)
const { data: population, isFetching } = customPopulationState.studentNumbers.length
? studentNumbersPopulation
: programmesPopulation
const populationStudents = population?.students ?? []
const associatedProgramme = population?.studyProgramme
const discardedStudentNumbers = population?.discardedStudentNumbers ?? []
const associatedProgramme = studentNumbersPopulation.data?.studyProgramme
const discardedStudentNumbers = studentNumbersPopulation.data?.discardedStudentNumbers ?? []
const filters: GenericFilter[] = useMemo(() => {
const filtersList = [
@@ -80,6 +95,7 @@ export const CustomPopulationWrapper = ({
filteredStudents={filteredStudents}
isFetchingPopulation={isFetching}
populationName={customPopulationState.selectedSearch?.name}
programmes={customPopulationState.programmes}
resetState={resetState}
unfilteredPopulationLength={populationStudents.length}
/>
@@ -0,0 +1,75 @@
import { FormattedStudent, StudentCourse } from '@oodikone/shared/types/studentData'
export type CourseMatrixCourse = {
code: string
name: string
credits: number
}
export type CourseMatrixRow = {
studentNumber: string
name: string
courses: CourseMatrixCourse[]
}
export type CourseMatrixAggregate = {
code: string
name: string
credits: number
students: number
}
/**
* Passed completions of the student that are included in one of the student's study plans (HOPS).
* Returns the latest completion per course code, sorted by course code.
*/
export const getHopsCourses = (student: FormattedStudent): StudentCourse[] => {
const hopsCourseCodes = new Set(student.studyplans?.flatMap(plan => plan.included_courses ?? []) ?? [])
const passedCoursesInHops = student.courses.filter(course => course.passed && hopsCourseCodes.has(course.course_code))
const latestByCode = new Map<string, StudentCourse>()
for (const course of passedCoursesInHops) {
if (!course.course_code) continue
const existing = latestByCode.get(course.course_code)
if (!existing || new Date(course.date).getTime() > new Date(existing.date).getTime()) {
latestByCode.set(course.course_code, course)
}
}
return [...latestByCode.values()].sort((a, b) => a.course_code.localeCompare(b.course_code))
}
export const calculateExcelData = (students: FormattedStudent[], courseNameByCode: Map<string, string>) => {
const counters = new Map<string, { name: string; credits: number; students: Set<string> }>()
const rows = students.map(student => {
const courses = getHopsCourses(student).map(course => {
const code = course.course_code
const counter = counters.get(code) ?? {
name: courseNameByCode.get(code) ?? '',
credits: 0,
students: new Set(),
}
counters.set(code, counter)
counter.credits += course.credits ?? 0
counter.students.add(student.studentNumber)
return { code, name: courseNameByCode.get(code) ?? '', credits: course.credits ?? 0 }
})
return { studentNumber: student.studentNumber, studentName: student.name, courses }
})
const completedCoursesRows = rows.map(({ studentNumber, studentName: name, courses }) => [
studentNumber,
name,
...courses.map(course => `${course.name} (${course.code})`),
])
const courseCounterRows = [...counters.entries()]
.map(([code, { name, credits, students }]) => [code, name, students.size.toString(), credits.toString()])
.sort(([_a, __a, aStudents], [_b, __b, bStudents]) => parseInt(bStudents) - parseInt(aStudents))
return { completedCoursesRows, courseCounterRows }
}
@@ -7,6 +7,8 @@ import { CustomPopulationSearch } from '@oodikone/shared/models/kone'
export type CustomPopulationState = {
selectedSearch: CustomPopulationSearch | null
studentNumbers: string[]
programmes: string[]
years: string[]
associatedProgramme?: string // programme code
}
@@ -21,6 +23,8 @@ export const CustomPopulation = () => {
const defaultState = {
selectedSearch: null,
studentNumbers: [],
programmes: [],
years: [],
associatedProgramme: '',
}
@@ -10,7 +10,7 @@ import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { PageLoading } from '@/components/Loading'
import { SingleStudyGuidanceGroupPanels } from '@/components/StudyGuidanceGroups/SingleStudyGuidanceGroupPanels'
import { startYearToAcademicYear, useGetFilters } from '@/components/StudyGuidanceGroups/utils'
import { useGetCustomPopulationQuery } from '@/redux/populations'
import { useGetCustomPopulationByStudentNumbersQuery } from '@/redux/populations'
import { useFilteredAndFormattedStudyProgrammes } from '@/redux/studyProgramme'
import { CalendarMonthIcon, LabelIcon } from '@/theme'
import { GroupsWithTags } from '@oodikone/shared/types/studyGuidanceGroup'
@@ -18,7 +18,7 @@ import { GroupsWithTags } from '@oodikone/shared/types/studyGuidanceGroup'
export const SingleStudyGuidanceGroupContainer = ({ group }: { group: GroupsWithTags | undefined }) => {
// Sorting is needed for RTK query cache to work properly
const groupStudentNumbers = group?.members?.map(({ personStudentNumber }) => personStudentNumber).sort() ?? []
const { data: population, isLoading } = useGetCustomPopulationQuery({
const { data: population, isLoading } = useGetCustomPopulationByStudentNumbersQuery({
studentNumbers: groupStudentNumbers,
tags: {
studyProgramme: group?.tags?.studyProgramme,
@@ -4,7 +4,7 @@ import { isMastersProgramme } from '@/common'
import * as filters from '@/components/FilterView/filters'
import { GenericFilter } from '@/components/FilterView/filters/createFilter'
import { useSemesters } from '@/hooks/useSemesters'
import { GetCustomPopulationResBody } from '@oodikone/shared/routes/populations'
import { CustomPopulationByStudentNumbersResBody } from '@oodikone/shared/routes/populations'
import { GroupsWithTags } from '@oodikone/shared/types/studyGuidanceGroup'
export const createAcademicYearStartDate = (year: number) => new Date(year, 7, 1)
@@ -14,7 +14,7 @@ export const startYearToAcademicYear = (year: string | null | undefined) =>
export const useGetFilters = (
group: GroupsWithTags | undefined,
population: Pick<GetCustomPopulationResBody, 'coursestatistics'> | undefined
population: Pick<CustomPopulationByStudentNumbersResBody, 'coursestatistics'> | undefined
) => {
const { semesters } = useSemesters()
@@ -7,7 +7,7 @@ import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { sortBy } from 'lodash-es'
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { DateFormat } from '@/constants/date'
import { AccessTimeIcon } from '@/theme'
@@ -43,12 +43,9 @@ export const SearchHistory = ({
}
}
useEffect(() => {
if (selected === null) return
if (items.every(item => item.id !== selected.id)) {
setSelected(null)
}
}, [items, selected])
if (selected && items.every(item => item.id !== selected.id)) {
setSelected(null)
}
return (
<Card variant="outlined">
@@ -65,6 +62,7 @@ export const SearchHistory = ({
getOptionLabel={option => option.text}
onChange={handleChange}
options={sortedItems}
getOptionKey={(option: SearchHistoryItem) => `${option.text}-${option?.id}`}
renderInput={params => <TextField {...params} data-cy="history-search" label="Select a previous search" />}
renderOption={(props, option) => {
const { key, ...optionProps } = props
@@ -8,22 +8,28 @@ const customPopulationSearchApi = RTKApi.injectEndpoints({
providesTags: [{ type: 'CustomPopulationSearches', id: 'LIST' }],
}),
createCustomPopulationSearch: builder.mutation({
query: ({ name, students }) => ({
query: ({ name, mode, students, programmes, year }) => ({
url: '/custom-population-search',
method: 'POST',
body: {
name,
mode,
students,
programmes,
year,
},
}),
invalidatesTags: [{ type: 'CustomPopulationSearches', id: 'LIST' }],
}),
updateCustomPopulationSearch: builder.mutation({
query: ({ id, students }) => ({
query: ({ id, mode, students, programmes, year }) => ({
url: `/custom-population-search/${id}`,
method: 'PUT',
body: {
mode,
students,
programmes,
year,
},
}),
invalidatesTags: [{ type: 'CustomPopulationSearches', id: 'LIST' }],
+24 -10
View File
@@ -8,8 +8,10 @@ import type {
PopulationstatisticsMaxYearsToCreatePopulationFormResBody,
PopulationstatisticsMaxYearsToCreatePopulationFormQuery,
PopulationstatisticsStudyprogrammesResBody,
CustomPopulationQuery,
GetCustomPopulationResBody,
CustomPopulationByStudentNumbersQuery,
CustomPopulationByStudentNumbersResBody,
CustomPopulationByProgrammesQuery,
CustomPopulationByProgrammesResBody,
} from '@oodikone/shared/routes/populations'
const populationApi = RTKApi.injectEndpoints({
@@ -22,16 +24,27 @@ const populationApi = RTKApi.injectEndpoints({
}),
transformResponse: formatPopulationData<PopulationstatisticsResBody>,
}),
getCustomPopulation: builder.query<Output<GetCustomPopulationResBody>, CustomPopulationQuery>({
getCustomPopulationByStudentNumbers: builder.query<
Output<CustomPopulationByStudentNumbersResBody>,
CustomPopulationByStudentNumbersQuery
>({
query: ({ studentNumbers, tags }) => ({
url: '/populationstatisticsbystudentnumbers',
method: 'POST',
body: {
studentNumbers,
tags,
},
body: { studentNumbers, tags },
}),
transformResponse: formatPopulationData<GetCustomPopulationResBody>,
transformResponse: formatPopulationData<CustomPopulationByStudentNumbersResBody>,
}),
getCustomPopulationByProgrammes: builder.query<
Output<CustomPopulationByProgrammesResBody>,
CustomPopulationByProgrammesQuery
>({
query: ({ programmes, years }) => ({
url: '/populationstatisticsbyprogrammecodes',
method: 'POST',
body: { programmes, years },
}),
transformResponse: formatPopulationData<CustomPopulationByProgrammesResBody>,
}),
getPopulationStatisticsByCourse: builder.query<
Output<PopulationstatisticsbycourseResBody>,
@@ -54,7 +67,7 @@ const populationApi = RTKApi.injectEndpoints({
}),
getProgrammes: builder.query<PopulationstatisticsStudyprogrammesResBody, void>({
query: () => '/populationstatistics/studyprogrammes',
keepUnusedDataFor: 60 * 60,
// keepUnusedDataFor: 60 * 60,
}),
}),
overrideExisting: false,
@@ -62,7 +75,8 @@ const populationApi = RTKApi.injectEndpoints({
export const {
useGetPopulationStatisticsQuery,
useGetCustomPopulationQuery,
useGetCustomPopulationByStudentNumbersQuery,
useGetCustomPopulationByProgrammesQuery,
useGetPopulationStatisticsByCourseQuery,
useGetMaxYearsToCreatePopulationFromQuery,
useGetProgrammesQuery,
@@ -6,7 +6,10 @@ export type CustomPopulationSearch = {
id: string
userId: string
name: string
mode: 'studentNumbers' | 'programmes'
students: string[]
programmes: { code: string; name: string }[]
year: string
createdAt?: string // Both are dates
updatedAt?: string
}
+13 -5
View File
@@ -108,17 +108,25 @@ export type PopulationstatisticsbycourseParams = {
}
// populationstatisticsbystudentnumbers
export type GetCustomPopulationResBody = PopulationstatisticsResBody & {
studyProgramme?: string | null
discardedStudentNumbers: string[]
}
export type CustomPopulationQuery = {
export type CustomPopulationByStudentNumbersQuery = {
studentNumbers: string[]
tags?: {
studyProgramme?: string | null
year?: string | null
}
}
export type CustomPopulationByStudentNumbersResBody = PopulationstatisticsResBody & {
studyProgramme?: string | null
discardedStudentNumbers: string[]
}
// populationstatisticsbyprogrammecodes
export type CustomPopulationByProgrammesQuery = {
programmes: string | string[]
years: string | string[]
}
export type CustomPopulationByProgrammesResBody = PopulationstatisticsResBody
// populationstatistics/studyprogrammes
export type PopulationstatisticsStudyprogrammesResBody = Record<