mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-12 19:03:39 +03:00
[CustomPopulation] Added option to make population with programmes
This commit is contained in:
@@ -130,6 +130,25 @@ test.describe('Custom population tests', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Custom population by programme', () => {
|
||||
test('Finds students of programmes matching a prefix', async ({ page }) => {
|
||||
await page.getByTestId('search-mode').click()
|
||||
await expect(page.getByTestId('search-button')).toBeDisabled()
|
||||
|
||||
await page.getByTestId('custom-population-programme-prefix-input').fill('KH50')
|
||||
await page.getByTestId('custom-population-add-by-prefix-button').click()
|
||||
await expect(page.getByText('KH50_001')).toBeVisible()
|
||||
|
||||
await page.getByTestId('population-year-selector').click()
|
||||
await page.getByText('2020 - 2021').click()
|
||||
|
||||
await page.getByTestId('search-button').click()
|
||||
await expect(page.getByText('Credit accumulation')).toBeVisible()
|
||||
await expect(page.getByText('Courses of population')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Back to search form' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Custom population search saving', () => {
|
||||
test('Saves a custom population search', async ({ page }) => {
|
||||
const name = await fillName(page)
|
||||
|
||||
+24
@@ -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' })
|
||||
}
|
||||
|
||||
@@ -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' }],
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<
|
||||
|
||||
Reference in New Issue
Block a user