mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-12 19:03:39 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
072360763f | ||
|
|
639858f874 | ||
|
|
92f3e67293 | ||
|
|
983390040e | ||
|
|
277b97327e | ||
|
|
3dab357396 | ||
|
|
a39648f380 | ||
|
|
ccb1865853 | ||
|
|
81da089798 | ||
|
|
785b7d5d74 | ||
|
|
aca70d5442 | ||
|
|
41d93c9b43 | ||
|
|
835c9e580b | ||
|
|
9c9f317ecb | ||
|
|
3ee6758c72 | ||
|
|
88764f83de | ||
|
|
e8908d28fc | ||
|
|
a6d7da315f | ||
|
|
e72e0bb5ad | ||
|
|
15b0286eda | ||
|
|
1104483cac | ||
|
|
604bcf4998 | ||
|
|
cc839a9e14 | ||
|
|
2a076ef6e8 | ||
|
|
b05e1c22e5 | ||
|
|
b9800c857f | ||
|
|
6d535bf4bb | ||
|
|
714f85d5c0 | ||
|
|
01af4cfd2c | ||
|
|
fe55b770e9 | ||
|
|
43e4e605d6 | ||
|
|
15a54f1e26 | ||
|
|
631b5be00a | ||
|
|
b8d9648b8c | ||
|
|
d52562330b | ||
|
|
f4c9137a05 | ||
|
|
9a1802b6b4 | ||
|
|
fd2d95a832 | ||
|
|
dfb557d4e1 | ||
|
|
22e2ccf562 | ||
|
|
68480a7211 | ||
|
|
bb4edef070 | ||
|
|
6877716468 | ||
|
|
a21e91fbaa | ||
|
|
62e7de5fef | ||
|
|
785b84b14c | ||
|
|
e488327fd4 | ||
|
|
2642e15479 | ||
|
|
547d1c7d35 | ||
|
|
d57ecb2aa9 | ||
|
|
ef76b0f350 | ||
|
|
3ba2bd8d5b | ||
|
|
12976a961b | ||
|
|
01c6159ffa | ||
|
|
a6c1e3891d | ||
|
|
6c4b42c98d | ||
|
|
10941259a2 | ||
|
|
b7563cd4ec | ||
|
|
8d261aae51 | ||
|
|
ccf70ed420 | ||
|
|
20f4bd32f8 | ||
|
|
d62140e97d |
@@ -194,7 +194,7 @@ describe('Population Statistics', () => {
|
||||
cy.cs('startYearAtUni-dropdown').selectFromDropdown(0)
|
||||
checkFilteringResult(1)
|
||||
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
|
||||
cy.cs('startYearAtUni-dropdown').selectFromDropdown(6)
|
||||
cy.cs('startYearAtUni-dropdown').selectFromDropdown(7)
|
||||
checkFilteringResult(21)
|
||||
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
|
||||
})
|
||||
@@ -249,7 +249,7 @@ describe('Population Statistics', () => {
|
||||
cy.cs('genderFilter-dropdown').selectFromDropdown(0)
|
||||
checkFilteringResult(13)
|
||||
cy.cs('StartYearAtUni-header').click()
|
||||
cy.cs('startYearAtUni-dropdown').selectFromDropdown(5)
|
||||
cy.cs('startYearAtUni-dropdown').selectFromDropdown(7)
|
||||
checkFilteringResult(8)
|
||||
cy.cs('reset-all-filters').click()
|
||||
checkFilteringResult(defaultAmountOfStudents)
|
||||
|
||||
+10
-4
@@ -43,10 +43,16 @@ describe('Open uni population tests', () => {
|
||||
cy.contains('01.01.2015')
|
||||
cy.contains('End of the search for enrollments:')
|
||||
cy.contains('01.01.2020')
|
||||
cy.url().should(
|
||||
'include',
|
||||
'/openunipopulation?courseCode=TKT10001&courseCode=TKT10002&enddate=01-01-2020&startdate=01-01-2015'
|
||||
)
|
||||
cy.location('pathname').should('eq', '/openunipopulation')
|
||||
cy.location('search').then(search => {
|
||||
const params = new URLSearchParams(search)
|
||||
|
||||
const courseCodes = params.getAll('courseCode')
|
||||
expect(courseCodes).to.include.members(['TKT10001', 'TKT10002'])
|
||||
|
||||
expect(params.get('startdate')).to.eq('01-01-2015')
|
||||
expect(params.get('enddate')).to.eq('01-01-2020')
|
||||
})
|
||||
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[0])
|
||||
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[1])
|
||||
})
|
||||
|
||||
@@ -49,9 +49,10 @@ services:
|
||||
ports:
|
||||
- 8080:8080
|
||||
volumes:
|
||||
- ./environments/ha:/opt/app-root/src/src/environment
|
||||
- ./services/backend/src:/opt/app-root/src/src
|
||||
- ./services/shared/:/opt/app-root/src/src/shared
|
||||
- backend-nodemod:/opt/app-root/backend/node_modules
|
||||
- ./environments/testu:/opt/app-root/backend/src/environment
|
||||
- ./services/backend/:/opt/app-root/backend
|
||||
- ./services/shared/:/opt/app-root/shared
|
||||
|
||||
frontend:
|
||||
<<: *defaults
|
||||
@@ -66,8 +67,9 @@ services:
|
||||
REACT_APP_LANGUAGE_CENTER_VIEW_ENABLED: 'false'
|
||||
REACT_APP_SIS_URL: 'https://sis-qa.funidata.fi'
|
||||
volumes:
|
||||
- ./services/frontend/src/:/opt/app-root/src/src
|
||||
- ./services/shared/:/opt/app-root/src/src/shared
|
||||
- frontend-nodemod:/opt/app-root/frontend/node_modules
|
||||
- ./services/frontend/:/opt/app-root/frontend
|
||||
- ./services/shared/:/opt/app-root/shared
|
||||
|
||||
kone-db:
|
||||
<<: *defaults
|
||||
@@ -171,6 +173,8 @@ services:
|
||||
- 12347:5432
|
||||
|
||||
volumes:
|
||||
backend-nodemod:
|
||||
frontend-nodemod:
|
||||
kone-db-data:
|
||||
redis-data:
|
||||
sis-db-data:
|
||||
|
||||
@@ -83,6 +83,7 @@ export default [
|
||||
'object-shorthand': ['error', 'always'],
|
||||
'prefer-const': 'error',
|
||||
'prefer-destructuring': ['error', { VariableDeclarator: { object: true } }],
|
||||
"no-restricted-imports": ['error', { 'patterns': [{ 'regex': '^@mui/[^/]+$' }] }],
|
||||
quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: false }],
|
||||
'react/display-name': 'off', // TODO: Delete this override
|
||||
'react/function-component-definition': [
|
||||
|
||||
Generated
+805
-453
File diff suppressed because it is too large
Load Diff
+18
-15
@@ -25,8 +25,11 @@
|
||||
"prepare": "husky",
|
||||
"rapodiff": "docker exec -t oodikone_backend_1 /opt/app-root/backend/node_modules/.bin/tsx /opt/app-root/backend/src/rapodiff/rapodiff.js",
|
||||
"install:local": "npm i && npm i --prefix services/shared && npm i --prefix services/frontend && npm i --prefix services/backend && npm i --prefix updater/sis-updater-scheduler && npm i --prefix updater/sis-updater-worker",
|
||||
"ci:local": "npm ci && npm ci --prefix services/shared && npm ci --prefix services/frontend && npm ci --prefix services/backend && npm ci --prefix updater/sis-updater-scheduler && npm ci --prefix updater/sis-updater-worker",
|
||||
"install:docker": "docker exec frontend npm i && docker exec oodikone_backend_1 npm i && docker exec sis-updater-scheduler npm i && docker exec oodikone_sis-updater-worker_1 npm i",
|
||||
"install:both": "npm run install:local && npm run install:docker"
|
||||
"ci:docker": "docker exec frontend npm ci && docker exec oodikone_backend_1 npm ci && docker exec sis-updater-scheduler npm ci && docker exec oodikone_sis-updater-worker_1 npm ci",
|
||||
"install:both": "npm run install:local && npm run install:docker",
|
||||
"ci:both": "npm run ci:local && npm run ci:docker"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -38,27 +41,27 @@
|
||||
},
|
||||
"homepage": "https://github.com/UniversityOfHelsinkiCS/oodikone#readme",
|
||||
"devDependencies": {
|
||||
"@cypress/code-coverage": "^3.13.12",
|
||||
"@eslint/js": "^9.22.0",
|
||||
"@cypress/code-coverage": "^3.14.4",
|
||||
"@eslint/js": "^9.28.0",
|
||||
"@istanbuljs/schema": "^0.1.3",
|
||||
"cypress": "^14.1.0",
|
||||
"eslint": "^9.22.0",
|
||||
"eslint-config-prettier": "^10.1.1",
|
||||
"eslint-import-resolver-typescript": "^3.8.3",
|
||||
"eslint-plugin-cypress": "^4.2.0",
|
||||
"cypress": "^14.4.1",
|
||||
"eslint": "^9.28.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-import-resolver-typescript": "^4.4.3",
|
||||
"eslint-plugin-cypress": "^5.1.0",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-prettier": "^5.2.3",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"eslint-plugin-prettier": "^5.4.1",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"globals": "^16.0.0",
|
||||
"globals": "^16.2.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^15.4.3",
|
||||
"lint-staged": "^16.1.0",
|
||||
"node-actionlint": "^1.2.2",
|
||||
"nyc": "^17.1.0",
|
||||
"prettier": "3.5.3",
|
||||
"stylelint": "^16.15.0",
|
||||
"stylelint-config-standard": "^37.0.0",
|
||||
"typescript-eslint": "^8.26.0"
|
||||
"stylelint": "^16.20.0",
|
||||
"stylelint-config-standard": "^38.0.0",
|
||||
"typescript-eslint": "^8.34.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.21",
|
||||
|
||||
Generated
+730
-429
File diff suppressed because it is too large
Load Diff
@@ -6,40 +6,41 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@oodikone/shared": "file:../shared",
|
||||
"@sentry/node": "^8.55.0",
|
||||
"@sentry/profiling-node": "^8.55.0",
|
||||
"axios": "^1.8.1",
|
||||
"bullmq": "^5.41.8",
|
||||
"@sentry/node": "^9.28.1",
|
||||
"@sentry/profiling-node": "^9.28.1",
|
||||
"axios": "^1.9.0",
|
||||
"bullmq": "^5.53.2",
|
||||
"compression": "^1.8.0",
|
||||
"cors": "^2.8.5",
|
||||
"cron": "^4.1.0",
|
||||
"cron": "^4.3.1",
|
||||
"express": "^4.21.2",
|
||||
"express-async-errors": "^3.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"lru-cache": "^11.0.2",
|
||||
"lru-cache": "^11.1.0",
|
||||
"moment": "^2.30.1",
|
||||
"morgan": "^1.10.0",
|
||||
"papaparse": "^5.5.2",
|
||||
"pg": "^8.13.3",
|
||||
"redis": "^4.7.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"pg": "^8.16.0",
|
||||
"redis": "^4.7.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"sequelize": "^6.37.6",
|
||||
"sequelize": "^6.37.7",
|
||||
"sequelize-typescript": "^2.1.6",
|
||||
"umzug": "^3.8.2",
|
||||
"unfuck-utf8-headers-middleware": "^1.0.1",
|
||||
"winston": "^3.17.0",
|
||||
"winston-gelf-transporter": "^1.0.2"
|
||||
"winston-gelf-transporter": "^1.0.2",
|
||||
"winston-loki": "^6.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/node": "^22.13.10",
|
||||
"@types/validator": "^13.12.2",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.2"
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^4.17.23",
|
||||
"@types/lodash": "^4.17.17",
|
||||
"@types/morgan": "^1.9.10",
|
||||
"@types/node": "^22.15.31",
|
||||
"@types/validator": "^13.15.1",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"overrides": {
|
||||
"winston-gelf-transporter": {
|
||||
|
||||
@@ -65,7 +65,7 @@ const refreshProgrammesAndFaculties = async () => {
|
||||
export const refreshProgramme = async (code: string) => {
|
||||
await updateBasicView(code, '')
|
||||
await updateStudyTrackView(code, '')
|
||||
const combinedProgramme = combinedStudyProgrammes[code] || ''
|
||||
const combinedProgramme = combinedStudyProgrammes[code] ?? ''
|
||||
await updateBasicView(code, combinedProgramme)
|
||||
await updateStudyTrackView(code, combinedProgramme)
|
||||
}
|
||||
|
||||
@@ -86,6 +86,12 @@ export class SISStudyRightModel extends Model<SISStudyRight> implements SISStudy
|
||||
@HasMany(() => EnrollmentModel, { foreignKey: 'studyright_id', sourceKey: 'id' })
|
||||
enrollments!: Enrollment[]
|
||||
|
||||
@Column(DataType.BOOLEAN)
|
||||
tvex!: boolean
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
expirationRuleUrns!: string[]
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
|
||||
@@ -23,7 +23,7 @@ type SearchQuery = {
|
||||
router.get<never, SearchResBody, SearchReqBody, SearchQuery>('/', async (req, res) => {
|
||||
const { courselist, startdate, enddate } = req.query
|
||||
|
||||
const courseCodes = (await safeJSONParse(courselist)) || []
|
||||
const courseCodes = (await safeJSONParse(courselist)) ?? []
|
||||
if (!Array.isArray(courseCodes)) {
|
||||
return res.status(400).json({ error: 'Courses must be of type array' })
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ router.post('/', async (req: Request, res: Response) => {
|
||||
return res.status(400).json({ error: 'Students must be of type array' })
|
||||
}
|
||||
|
||||
const customPopulationSearch = await createCustomPopulationSearch(name, id, students || [])
|
||||
const customPopulationSearch = await createCustomPopulationSearch(name, id, students ?? [])
|
||||
res.json(customPopulationSearch)
|
||||
})
|
||||
|
||||
|
||||
@@ -180,16 +180,10 @@ export class CourseStatsCounter {
|
||||
}
|
||||
|
||||
public markEnrollment(studentNumber: string, state: EnrollmentState, semester: string) {
|
||||
if (!this.enrollments[state]) {
|
||||
this.enrollments[state] = new Set()
|
||||
}
|
||||
this.enrollments[state] ??= new Set()
|
||||
this.enrollments[state].add(studentNumber)
|
||||
if (!this.enrollments.semesters[semester]) {
|
||||
this.enrollments.semesters[semester] = {}
|
||||
}
|
||||
if (!this.enrollments.semesters[semester][state]) {
|
||||
this.enrollments.semesters[semester][state] = new Set()
|
||||
}
|
||||
this.enrollments.semesters[semester] ??= {}
|
||||
this.enrollments.semesters[semester][state] ??= new Set()
|
||||
this.enrollments.semesters[semester][state].add(studentNumber)
|
||||
}
|
||||
|
||||
|
||||
@@ -73,9 +73,7 @@ export const getStudentNumberToSrElementsMap = async (studentNumbers: string[])
|
||||
const studentNumberToSrElementsMap = studyRightElements.reduce((obj, cur) => {
|
||||
const studyRight = studyRightMap[cur.studyRightId]
|
||||
const { studentNumber } = studyRight
|
||||
if (!obj[studentNumber]) {
|
||||
obj[studentNumber] = []
|
||||
}
|
||||
obj[studentNumber] ??= []
|
||||
obj[studentNumber].push({ ...cur.toJSON(), studyRight })
|
||||
return obj
|
||||
}, {})
|
||||
|
||||
@@ -71,15 +71,13 @@ const parseCredit = (
|
||||
const { yearcode: yearCode, yearname: yearName, semestercode: semesterCode, name: semesterName } = semester
|
||||
|
||||
const studyRightElements = studentNumberToSrElementsMap[studentNumber] || []
|
||||
let programmeOfCredit: SISStudyRightElementModel | undefined
|
||||
programmeOfCredit = studyRightElements.find(studyRightElement => studyRightElement.studyRightId === studyRightId)
|
||||
if (!programmeOfCredit) {
|
||||
programmeOfCredit = studyRightElements
|
||||
const programmeOfCredit: SISStudyRightElementModel | undefined =
|
||||
studyRightElements.find(studyRightElement => studyRightElement.studyRightId === studyRightId) ??
|
||||
studyRightElements
|
||||
.filter(studyRightElement =>
|
||||
dateIsBetween(attainmentDate, studyRightElement.startDate, studyRightElement.endDate)
|
||||
)
|
||||
.sort((a, b) => b.startDate.getTime() - a.startDate.getTime())[0] // The newest studyRightElement
|
||||
}
|
||||
|
||||
const programmes = programmeOfCredit ? [programmeOfCredit].map(formatStudyRightElement) : []
|
||||
|
||||
|
||||
@@ -89,13 +89,9 @@ const findThesisAndLatestAndEarliestAttainments = (
|
||||
) {
|
||||
thesisData = attainment
|
||||
}
|
||||
if (!attainmentDates.latestTotal) {
|
||||
attainmentDates.latestTotal = attainment.attainment_date
|
||||
}
|
||||
attainmentDates.latestTotal ??= attainment.attainment_date
|
||||
if (studyPlan.included_courses.includes(attainment.course?.code)) {
|
||||
if (!attainmentDates.latestHops) {
|
||||
attainmentDates.latestHops = attainment.attainment_date
|
||||
}
|
||||
attainmentDates.latestHops ??= attainment.attainment_date
|
||||
if (
|
||||
!attainmentDates.earliestHops ||
|
||||
attainment.attainment_date.getTime() < attainmentDates.earliestHops.getTime()
|
||||
|
||||
@@ -133,7 +133,7 @@ type StudentStudyRightElement = Pick<
|
||||
|
||||
type StudentStudyRight = Pick<
|
||||
SISStudyRightModel,
|
||||
'id' | 'extentCode' | 'facultyCode' | 'admissionType' | 'cancelled' | 'semesterEnrollments' | 'startDate'
|
||||
'id' | 'extentCode' | 'facultyCode' | 'admissionType' | 'cancelled' | 'semesterEnrollments' | 'startDate' | 'tvex'
|
||||
> & {
|
||||
studyRightElements: Array<StudentStudyRightElement>
|
||||
}
|
||||
@@ -182,6 +182,7 @@ export const getStudents = (studentNumbers: string[]): Promise<Array<StudentData
|
||||
'cancelled',
|
||||
'semesterEnrollments',
|
||||
'startDate',
|
||||
'tvex',
|
||||
],
|
||||
separate: true,
|
||||
include: [
|
||||
|
||||
@@ -234,11 +234,7 @@ const formatSharedStudentData = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (credits === undefined) {
|
||||
credits = []
|
||||
}
|
||||
|
||||
const formattedCredits = credits
|
||||
const formattedCredits = (credits ?? [])
|
||||
.sort((a, b) => new Date(a.attainment_date).getTime() - new Date(b.attainment_date).getTime())
|
||||
.map(toCourse)
|
||||
|
||||
|
||||
@@ -51,9 +51,7 @@ const getGraduationTimeStats = async (
|
||||
) => {
|
||||
for (const programmeOrTrack of Object.keys(graduationTimes)) {
|
||||
const stats = graduationTimes[programmeOrTrack]
|
||||
if (!finalGraduationTimes[programmeOrTrack]) {
|
||||
finalGraduationTimes[programmeOrTrack] = { medians: { basic: [] as MedianEntry[], combo: [] as MedianEntry[] } }
|
||||
}
|
||||
finalGraduationTimes[programmeOrTrack] ??= { medians: { basic: [] as MedianEntry[], combo: [] as MedianEntry[] } }
|
||||
for (const [year, oneYearStats] of Object.entries(stats)) {
|
||||
const classSize = mainStatsByTrack[combinedProgramme ? studyProgramme : programmeOrTrack].find(
|
||||
stats => stats[0] === year
|
||||
|
||||
@@ -102,7 +102,7 @@ const markCreditForSemester = (semesters: Record<string, { id: number; name: Nam
|
||||
const markCreditForYear = (years: Record<number, any>, credit: Credit) => {
|
||||
const { passed, failed, credits, semester, transferred } = parseCreditInfo(credit)
|
||||
const { yearcode, yearname } = semester
|
||||
const { stats, ...rest } = years[yearcode] || { id: yearcode, name: yearname }
|
||||
const { stats, ...rest } = years[yearcode] ?? { id: yearcode, name: yearname }
|
||||
return {
|
||||
...years,
|
||||
[yearcode]: {
|
||||
@@ -116,7 +116,7 @@ const markCreditForCourse = (courses: Record<string, any>, credit: Credit) => {
|
||||
const { passed, failed, credits, course, semester, transferred } = parseCreditInfo(credit)
|
||||
const { code, name } = course
|
||||
const { semestercode } = semester
|
||||
const { stats, semesters = {}, ...rest } = courses[code] || { id: code, name }
|
||||
const { stats, semesters = {}, ...rest } = courses[code] ?? { id: code, name }
|
||||
return {
|
||||
...courses,
|
||||
[code]: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const os = require('os')
|
||||
const winston = require('winston')
|
||||
const { WinstonGelfTransporter } = require('winston-gelf-transporter')
|
||||
const LokiTransport = require('winston-loki')
|
||||
|
||||
const { isProduction, serviceProvider } = require('../config')
|
||||
|
||||
@@ -8,6 +9,15 @@ const { colorize, combine, timestamp, printf } = winston.format
|
||||
|
||||
const transports = [new winston.transports.Console()]
|
||||
|
||||
if (serviceProvider !== 'fd') {
|
||||
transports.push(
|
||||
new LokiTransport({
|
||||
host: 'http://loki-svc.toska-lokki.svc.cluster.local:3100',
|
||||
labels: { app: 'oodikone', environment: process.env.NODE_ENV ?? 'production' },
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (isProduction && serviceProvider !== 'fd') {
|
||||
transports.push(
|
||||
new WinstonGelfTransporter({
|
||||
|
||||
@@ -39,7 +39,7 @@ COPY ./frontend/package*.json .
|
||||
RUN npm ci --include=dev
|
||||
COPY ./frontend .
|
||||
|
||||
RUN npm run build && npm install -g serve@12.0.0
|
||||
RUN npm run build && npm install -g serve@^14
|
||||
|
||||
EXPOSE 5000
|
||||
CMD ["serve", "--single", "--listen", "5000", "build"]
|
||||
|
||||
Generated
+362
-331
File diff suppressed because it is too large
Load Diff
@@ -11,20 +11,19 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@mui/icons-material": "^6.4.7",
|
||||
"@mui/material": "^6.4.7",
|
||||
"@mui/x-date-pickers": "^7.27.3",
|
||||
"@mui/icons-material": "^6.4.12",
|
||||
"@mui/material": "^6.4.12",
|
||||
"@mui/x-date-pickers": "^7.29.4",
|
||||
"@oodikone/shared": "file:../shared",
|
||||
"@reduxjs/toolkit": "^2.6.1",
|
||||
"@sentry/browser": "^8.55.0",
|
||||
"axios": "^1.8.2",
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"@sentry/browser": "^9.28.1",
|
||||
"axios": "^1.9.0",
|
||||
"highcharts": "^11.4.8",
|
||||
"immer": "^10.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"material-react-table": "^3.2.1",
|
||||
"moment": "^2.30.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"query-string": "^9.1.1",
|
||||
"react": "^18.3.1",
|
||||
"react-datetime": "^3.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -32,8 +31,8 @@
|
||||
"react-markdown": "^9.1.0",
|
||||
"react-range": "^1.10.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.3.0",
|
||||
"scheduler": "^0.25.0",
|
||||
"react-router": "^7.6.2",
|
||||
"scheduler": "^0.26.0",
|
||||
"semantic-ui-css": "^2.5.0",
|
||||
"semantic-ui-react": "^3.0.0-beta.2",
|
||||
"unfuck-spa-shibboleth-session": "^4.1.0",
|
||||
@@ -41,14 +40,14 @@
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/node": "^22.13.10",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/react-highcharts": "^16.0.6",
|
||||
"@vitejs/plugin-react-swc": "^3.8.0",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^6.2.1"
|
||||
"@types/lodash": "^4.17.17",
|
||||
"@types/node": "^22.15.31",
|
||||
"@types/react": "^18.3.23",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@types/react-highcharts": "^16.0.7",
|
||||
"@vitejs/plugin-react-swc": "^3.10.2",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.5"
|
||||
},
|
||||
"overrides": {
|
||||
"react-highcharts": {
|
||||
|
||||
@@ -4,6 +4,7 @@ import axios from 'axios'
|
||||
|
||||
import { showAsUserKey } from '@/common'
|
||||
import { apiBasePath, isDev } from '@/conf'
|
||||
import { formatToArray } from '@oodikone/shared/util'
|
||||
|
||||
const getHeaders = () => {
|
||||
// Set up dev user for development environment, mimicking production admin user
|
||||
@@ -128,6 +129,16 @@ export const RTKApi = createApi({
|
||||
Object.entries(getHeaders()).forEach(([key, value]) => headers.set(key, value))
|
||||
return headers
|
||||
},
|
||||
paramsSerializer: params => {
|
||||
const searchParams = new URLSearchParams()
|
||||
|
||||
Object.entries(params).map(([key, val]) => {
|
||||
const subfix = Array.isArray(val) ? '[]' : ''
|
||||
formatToArray(val).forEach(item => searchParams.append(key + subfix, item))
|
||||
})
|
||||
|
||||
return searchParams.toString()
|
||||
},
|
||||
}),
|
||||
endpoints: () => ({}),
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { CssBaseline } from '@mui/material'
|
||||
import { ThemeProvider } from '@mui/material/styles'
|
||||
import { AdapterMoment } from '@mui/x-date-pickers/AdapterMoment'
|
||||
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'
|
||||
import CssBaseline from '@mui/material/CssBaseline'
|
||||
import ThemeProvider from '@mui/material/styles/ThemeProvider'
|
||||
import { AdapterMoment } from '@mui/x-date-pickers/AdapterMoment/AdapterMoment'
|
||||
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider/LocalizationProvider'
|
||||
|
||||
import * as Sentry from '@sentry/browser'
|
||||
import HighCharts from 'highcharts'
|
||||
import moment from 'moment'
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import qs from 'query-string'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useLocation } from 'react-router'
|
||||
import { Form, Header, Input, Segment } from 'semantic-ui-react'
|
||||
@@ -32,6 +31,7 @@ import { useGetStudentListCourseStatisticsQuery } from '@/redux/populationCourse
|
||||
import { useGetPopulationStatisticsByCourseQuery } from '@/redux/populations'
|
||||
import { useGetSemestersQuery } from '@/redux/semesters'
|
||||
import { useGetSingleCourseStatsQuery } from '@/redux/singleCourseStats'
|
||||
import { parseQueryParams } from '@/util/queryparams'
|
||||
import { CoursePopulationCreditGainTable } from './CoursePopulationCreditGainTable'
|
||||
import { CoursePopulationGradeDist } from './CoursePopulationGradeDist'
|
||||
import { CoursePopulationLanguageDist } from './CoursePopulationLanguageDist'
|
||||
@@ -42,7 +42,7 @@ export const CoursePopulation = () => {
|
||||
const [codes, setCodes] = useState([])
|
||||
useTitle('Course population')
|
||||
|
||||
const { coursecodes, from, to, separate, unifyCourses, years, years2, combineSubstitutions } = qs.parse(
|
||||
const { coursecodes, from, to, separate, unifyCourses, years, years2, combineSubstitutions } = parseQueryParams(
|
||||
location.search
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import moment from 'moment'
|
||||
import qs from 'query-string'
|
||||
import { useEffect, useState } from 'react'
|
||||
import Datetime from 'react-datetime'
|
||||
import { useLocation, useNavigate } from 'react-router'
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
useDeleteOpenUniCourseSearchMutation,
|
||||
useUpdateOpenUniCourseSearchMutation,
|
||||
} from '@/redux/openUniPopulations'
|
||||
import { parseQueryParams, queryParamsToString } from '@/util/queryparams'
|
||||
import { formatToArray } from '@oodikone/shared/util'
|
||||
|
||||
export const CustomOpenUniSearch = ({ setValues, savedSearches }) => {
|
||||
@@ -53,13 +53,13 @@ export const CustomOpenUniSearch = ({ setValues, savedSearches }) => {
|
||||
|
||||
const pushQueryToUrl = query => {
|
||||
setTimeout(() => {
|
||||
const searchString = qs.stringify(query)
|
||||
const searchString = queryParamsToString(query)
|
||||
navigate({ search: searchString }, { replace: true })
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const parseQueryFromUrl = () => {
|
||||
const { courseCode, startdate, enddate } = qs.parse(location.search)
|
||||
const { courseCode, startdate, enddate } = parseQueryParams(location.search)
|
||||
const courseCodes = formatToArray(courseCode)
|
||||
const query = {
|
||||
courseList: courseCodes,
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Popup, Button, Icon } from 'semantic-ui-react'
|
||||
|
||||
export const FilterToggle = ({ active, applyFilter, clearFilter, popupContent, disabled }) => {
|
||||
const toggleFilter = () => {
|
||||
if (active) {
|
||||
clearFilter()
|
||||
} else {
|
||||
applyFilter()
|
||||
}
|
||||
}
|
||||
|
||||
const Toggle = () => (
|
||||
<Button basic={!active} disabled={disabled} icon onClick={toggleFilter} primary={active} size="mini">
|
||||
<Icon name="filter" />
|
||||
</Button>
|
||||
)
|
||||
|
||||
// Trigger must be wrapped in <div> for the tooltip to work.
|
||||
// See https://stackoverflow.com/questions/63611315/semantics-popup-does-not-show-up-when-passing-a-custom-component-as-trigger
|
||||
return !disabled && popupContent ? (
|
||||
<Popup
|
||||
content={popupContent}
|
||||
size="mini"
|
||||
trigger={
|
||||
<div>
|
||||
<Toggle />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ cursor: disabled ? 'not-allowed' : 'default' }}>
|
||||
<Toggle />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Popup, Button, Icon } from 'semantic-ui-react'
|
||||
|
||||
export const FilterToggle = ({ active, applyFilter, clearFilter, popupContent, disabled }) => {
|
||||
const toggleFilter = () => (active ? clearFilter() : applyFilter())
|
||||
const Toggle = (
|
||||
<Button basic={!active} disabled={disabled} icon onClick={toggleFilter} primary={active} size="mini">
|
||||
<Icon name="filter" />
|
||||
</Button>
|
||||
)
|
||||
|
||||
return !disabled && popupContent ? (
|
||||
<Popup content={popupContent} size="mini" trigger={Toggle} />
|
||||
) : (
|
||||
<div style={{ cursor: disabled ? 'not-allowed' : 'default' }}>{Toggle}</div>
|
||||
)
|
||||
}
|
||||
+12
-6
@@ -1,15 +1,22 @@
|
||||
import { useContext } from 'react'
|
||||
import { Button, Header, Segment } from 'semantic-ui-react'
|
||||
|
||||
import { Student } from '.'
|
||||
import { FilterViewContext } from './context'
|
||||
import type { FilterContext } from './context'
|
||||
import { FilterCard } from './filters/common/FilterCard'
|
||||
import { FilterViewContext } from './FilterViewContext'
|
||||
|
||||
export type FilterTrayProps = {
|
||||
options: FilterContext['options']
|
||||
onOptionsChange: (options) => void
|
||||
students: Student[]
|
||||
}
|
||||
|
||||
export const FilterTray = () => {
|
||||
const {
|
||||
filteredStudents,
|
||||
allStudents,
|
||||
filters,
|
||||
withoutFilter,
|
||||
setFilterOptions,
|
||||
resetFilter,
|
||||
resetFilters,
|
||||
@@ -18,19 +25,18 @@ export const FilterTray = () => {
|
||||
} = useContext(FilterViewContext)
|
||||
|
||||
const haveOptionsBeenChanged = filters.some(({ key }) => areOptionsDirty(key))
|
||||
|
||||
const filterSet = filters
|
||||
.sort((a, b) => (a.title ?? a.key).localeCompare(b.title ?? b.key))
|
||||
.sort(({ title: a }, { title: b }) => a.localeCompare(b))
|
||||
.map(filter => {
|
||||
const { key, render } = filter
|
||||
const ctx = getContextByKey(key)
|
||||
|
||||
const props = {
|
||||
const props: FilterTrayProps = {
|
||||
options: ctx.options,
|
||||
onOptionsChange: options => {
|
||||
setFilterOptions(key, options)
|
||||
},
|
||||
withoutSelf: () => withoutFilter(key),
|
||||
students: allStudents.slice(), // Copy instead of move
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -1,23 +0,0 @@
|
||||
import { createContext } from 'react'
|
||||
|
||||
const defaultState = {
|
||||
viewName: '<Unset>',
|
||||
allStudents: [],
|
||||
filteredStudents: [],
|
||||
getContextByKey: () => ({
|
||||
students: [],
|
||||
precomputed: null,
|
||||
options: null,
|
||||
args: null,
|
||||
}),
|
||||
filterOptions: {},
|
||||
filters: [],
|
||||
precomputed: {},
|
||||
withoutFilter: () => [],
|
||||
setFilterOptions: () => {},
|
||||
resetFilter: () => {},
|
||||
resetFilters: () => {},
|
||||
}
|
||||
|
||||
export const FilterViewContext = createContext(defaultState)
|
||||
FilterViewContext.displayName = 'FilterView'
|
||||
@@ -0,0 +1,47 @@
|
||||
import { createContext } from 'react'
|
||||
|
||||
import type { Student } from '.'
|
||||
import type { Filter } from './filters/createFilter'
|
||||
|
||||
export type FilterContext = {
|
||||
students: Student[]
|
||||
precomputed: any // can be null
|
||||
options: Record<string, any>
|
||||
args: any // can be null
|
||||
}
|
||||
|
||||
export const getDefaultFilterContext = () => ({ ...defaultFilterContext })
|
||||
const defaultFilterContext: FilterContext = {
|
||||
students: [],
|
||||
precomputed: null,
|
||||
options: {},
|
||||
args: null,
|
||||
}
|
||||
|
||||
export type FilterViewContextState = {
|
||||
viewName: string
|
||||
allStudents: Student[]
|
||||
filters: Filter[]
|
||||
filteredStudents: Student[]
|
||||
getContextByKey: (key: string) => FilterContext
|
||||
/** Set filter options */
|
||||
setFilterOptions: (filter: string, options: any) => void
|
||||
resetFilter: (filter: string) => void
|
||||
resetFilters: () => void
|
||||
areOptionsDirty: (key: string) => boolean
|
||||
}
|
||||
|
||||
const defaultState: FilterViewContextState = {
|
||||
viewName: '<Unset>',
|
||||
allStudents: [],
|
||||
filters: [],
|
||||
filteredStudents: [],
|
||||
getContextByKey: () => getDefaultFilterContext(),
|
||||
setFilterOptions: () => {},
|
||||
resetFilter: () => {},
|
||||
resetFilters: () => {},
|
||||
areOptionsDirty: () => false,
|
||||
}
|
||||
|
||||
export const FilterViewContext = createContext<FilterViewContextState>(defaultState)
|
||||
FilterViewContext.displayName = 'FilterView'
|
||||
+9
-14
@@ -10,14 +10,6 @@ export const filter = code => value => student => {
|
||||
const programmeStudyRights = findAllStudyRightsForProgramme(student, code)
|
||||
const fixedValue = value !== 'Valintakoe' ? value : 'Koepisteet'
|
||||
|
||||
if (programmeStudyRights.length === 0) return false
|
||||
|
||||
if (programmeStudyRights.length === 1) {
|
||||
return value === null || value === 'Ei valintatapaa'
|
||||
? !programmeStudyRights[0].admissionType
|
||||
: programmeStudyRights[0].admissionType === fixedValue
|
||||
}
|
||||
|
||||
return programmeStudyRights.some(
|
||||
studyRight =>
|
||||
!studyRight.cancelled &&
|
||||
@@ -27,17 +19,18 @@ export const filter = code => value => student => {
|
||||
)
|
||||
}
|
||||
|
||||
const AdmissionTypeFilterCard = ({ options, onOptionsChange, withoutSelf, code }) => {
|
||||
const AdmissionTypeFilterCard = ({ options, onOptionsChange, students, code }) => {
|
||||
const { selected } = options
|
||||
const name = 'admissionTypeFilter'
|
||||
|
||||
const count = admissionType => withoutSelf().filter(filter(code)(admissionType)).length
|
||||
const count = admissionType => students.filter(filter(code)(admissionType)).length
|
||||
|
||||
const dropdownOptions = Object.entries(ADMISSION_TYPES)
|
||||
.filter(([_, admissionType]) => !!admissionType)
|
||||
.map(([key, admissionType]) => {
|
||||
const value = admissionType || 'Ei valintatapaa'
|
||||
const value = admissionType ?? 'Ei valintatapaa'
|
||||
const numberOfStudents = count(admissionType)
|
||||
if (numberOfStudents === 0) return null
|
||||
|
||||
return {
|
||||
key,
|
||||
text: `${value} (${numberOfStudents})`,
|
||||
@@ -45,7 +38,7 @@ const AdmissionTypeFilterCard = ({ options, onOptionsChange, withoutSelf, code }
|
||||
numberOfStudents,
|
||||
}
|
||||
})
|
||||
.filter(a => a !== null)
|
||||
.filter(a => a.numberOfStudents)
|
||||
.sort((a, b) => b.numberOfStudents - a.numberOfStudents)
|
||||
|
||||
return (
|
||||
@@ -80,7 +73,9 @@ export const admissionTypeFilter = createFilter({
|
||||
|
||||
isActive: ({ selected }) => !!selected,
|
||||
|
||||
filter(student, { selected }, { args }) {
|
||||
filter(student, { args, options }) {
|
||||
const { selected } = options
|
||||
|
||||
return filter(args.programme)(selected)(student)
|
||||
},
|
||||
|
||||
+3
-10
@@ -48,18 +48,11 @@ export const ageFilter = createFilter({
|
||||
}
|
||||
},
|
||||
|
||||
filter: (student, { min, max }) => {
|
||||
filter: (student, { options }) => {
|
||||
const { min, max } = options
|
||||
const age = getAge(student.birthdate)
|
||||
|
||||
if (min !== null && min > age) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (max !== null && max < age) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
return !(min !== null && min > age) && !(max !== null && max < age)
|
||||
},
|
||||
|
||||
render: (props, { precomputed }) => <AgeFilterCard {...props} bounds={precomputed} />,
|
||||
+21
-23
@@ -5,31 +5,27 @@ import { filterToolTips } from '@/common/InfoToolTips'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { createFilter } from './createFilter'
|
||||
|
||||
const CitizenshipFilterCard = ({ options, onOptionsChange, withoutSelf }) => {
|
||||
const CitizenshipFilterCard = ({ options, onOptionsChange, students }) => {
|
||||
const { getTextIn } = useLanguage()
|
||||
const { selected } = options
|
||||
|
||||
const dropdownOptions = withoutSelf().reduce((options, student) => {
|
||||
for (const citizenship of student.citizenships) {
|
||||
const countryName = citizenship.en
|
||||
if (options.some(option => option.value === countryName)) {
|
||||
continue
|
||||
}
|
||||
const count = withoutSelf().filter(student =>
|
||||
student.citizenships.some(citizenship => citizenship.en === countryName)
|
||||
).length
|
||||
const country = getTextIn(citizenship)
|
||||
const uniqueContryNames = new Map<string, string>(
|
||||
students.flatMap(({ citizenships }) => citizenships).map(citizenship => [citizenship.en, getTextIn(citizenship)])
|
||||
)
|
||||
|
||||
options.push({
|
||||
key: countryName,
|
||||
value: countryName,
|
||||
text: `${country} (${count})`,
|
||||
count,
|
||||
})
|
||||
}
|
||||
const dropdownOptions = [] as any[]
|
||||
for (const [countryName, country] of uniqueContryNames.entries()) {
|
||||
const count = students.filter(({ citizenships: otherCitizenships }) =>
|
||||
otherCitizenships.some(({ en: otherCountryName }) => otherCountryName === countryName)
|
||||
).length
|
||||
|
||||
return options
|
||||
}, [])
|
||||
dropdownOptions.push({
|
||||
key: countryName,
|
||||
value: countryName,
|
||||
text: `${country} (${count})`,
|
||||
count,
|
||||
})
|
||||
}
|
||||
|
||||
const sortedDropdownOptions = orderBy(dropdownOptions, ['count', 'text'], ['desc', 'asc'])
|
||||
|
||||
@@ -65,9 +61,11 @@ export const citizenshipFilter = createFilter({
|
||||
|
||||
isActive: ({ selected }) => selected !== '',
|
||||
|
||||
filter(student, { selected }) {
|
||||
return student.citizenships.some(citizenship => citizenship.en === selected)
|
||||
filter(student, { options }) {
|
||||
const { selected } = options
|
||||
|
||||
return student.citizenships.some(({ en: countryName }) => countryName === selected)
|
||||
},
|
||||
|
||||
component: CitizenshipFilterCard,
|
||||
render: CitizenshipFilterCard,
|
||||
})
|
||||
+16
-17
@@ -1,39 +1,38 @@
|
||||
import { isEqual } from 'lodash'
|
||||
import { useState, useRef } from 'react'
|
||||
import { FC, useState, useRef } from 'react'
|
||||
import { Icon, Header } from 'semantic-ui-react'
|
||||
|
||||
import { HoverableHelpPopup } from '@/components/common/HoverableHelpPopup'
|
||||
import './FilterCard.css'
|
||||
|
||||
const useChange = value => {
|
||||
const prevValue = useRef()
|
||||
|
||||
if (prevValue.current === undefined) {
|
||||
prevValue.current = value
|
||||
return true
|
||||
}
|
||||
|
||||
const change = !isEqual(value, prevValue.current)
|
||||
import { FilterContext, FilterViewContextState } from '../../context'
|
||||
import type { Filter } from '../createFilter'
|
||||
|
||||
const useChange = (value: FilterContext['options']) => {
|
||||
const prevValue = useRef<typeof value>()
|
||||
prevValue.current = value
|
||||
|
||||
return change
|
||||
return !isEqual(value, prevValue.current)
|
||||
}
|
||||
|
||||
export const FilterCard = ({ filter, options, children, onClear }) => {
|
||||
const title = filter.title ?? filter.key
|
||||
const active = filter.isActive(options)
|
||||
const { info, key } = filter
|
||||
export const FilterCard: FC<{
|
||||
filter: Filter
|
||||
options: FilterContext['options']
|
||||
children: ReturnType<Filter['render']>
|
||||
onClear: () => ReturnType<FilterViewContextState['resetFilter']>
|
||||
}> = ({ filter, options, children, onClear }) => {
|
||||
const { info, key, title, isActive } = filter
|
||||
const active = isActive(options)
|
||||
|
||||
const hasChanged = useChange(options)
|
||||
|
||||
const [manuallyOpened, setManuallyOpened] = useState(null)
|
||||
const [manuallyOpened, setManuallyOpened] = useState<boolean | null>(null)
|
||||
|
||||
if (hasChanged && manuallyOpened === false) {
|
||||
setManuallyOpened(null)
|
||||
}
|
||||
|
||||
const open = manuallyOpened !== null ? manuallyOpened : active
|
||||
const open = manuallyOpened ?? active
|
||||
|
||||
return (
|
||||
<div data-cy={`${key}-filter-card`} data-open={open} style={{ margin: '1rem 0' }}>
|
||||
@@ -1,73 +0,0 @@
|
||||
import { Button, Dropdown, Icon, Label, Popup } from 'semantic-ui-react'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { FilterType } from './filterType'
|
||||
|
||||
const filterTexts = {
|
||||
[FilterType.ALL]: { label: 'All' },
|
||||
[FilterType.PASSED]: { label: 'Passed' },
|
||||
[FilterType.FAILED]: { label: 'Failed' },
|
||||
[FilterType.ENROLLED_NO_GRADE]: { label: 'Enrolled, No Grade' },
|
||||
}
|
||||
// a bandaid solution to prevent oodikone crashing
|
||||
const translate = {
|
||||
ALL: 'all',
|
||||
PASSED: 'passed',
|
||||
FAILED: 'failed',
|
||||
ENROLLED_NO_GRADE: 'enrolledNoGrade',
|
||||
}
|
||||
|
||||
export const CourseCard = ({ course, filterType, onChange }) => {
|
||||
const { getTextIn } = useLanguage()
|
||||
const name = 'courseFilter'
|
||||
|
||||
const onClick = (_, { value }) => onChange(value)
|
||||
|
||||
const clear = () => {
|
||||
onChange(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<Label style={{ marginTop: '0.5rem' }}>
|
||||
{getTextIn(course?.course?.name)}
|
||||
|
||||
<Dropdown
|
||||
button
|
||||
className="mini"
|
||||
data-cy={`${name}-${course?.course?.code}-dropdown`}
|
||||
fluid
|
||||
style={{ marginTop: '0.5rem' }}
|
||||
text={filterTexts[filterType].label}
|
||||
value={filterType}
|
||||
>
|
||||
<Dropdown.Menu>
|
||||
{Object.entries(filterTexts).map(([type, { label, info }]) => {
|
||||
if (info) {
|
||||
return (
|
||||
<Popup
|
||||
basic
|
||||
content={info}
|
||||
key={label}
|
||||
trigger={<Dropdown.Item onClick={onClick} text={label} value={type} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (course?.students[translate[type]] && Object.keys(course?.students[translate[type]]).length === 0)
|
||||
return <Dropdown.Item disabled key={label} onClick={onClick} text={label} value={type} />
|
||||
return <Dropdown.Item key={label} onClick={onClick} text={label} value={type} />
|
||||
})}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
|
||||
<Button
|
||||
compact
|
||||
data-cy={`${name}-${course?.course?.code}-clear`}
|
||||
icon
|
||||
onClick={clear}
|
||||
size="tiny"
|
||||
style={{ marginTop: '0.5rem' }}
|
||||
>
|
||||
<Icon name="close" />
|
||||
</Button>
|
||||
</Label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { FC } from 'react'
|
||||
import { Button, Dropdown, Icon, Label } from 'semantic-ui-react'
|
||||
import type { DropdownProps } from 'semantic-ui-react'
|
||||
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { FilterType } from './filterType'
|
||||
|
||||
const filterTexts = {
|
||||
[FilterType.ALL]: {
|
||||
key: 'all',
|
||||
label: 'All',
|
||||
},
|
||||
[FilterType.PASSED]: {
|
||||
key: 'passed',
|
||||
label: 'Passed',
|
||||
},
|
||||
[FilterType.FAILED]: {
|
||||
key: 'failed',
|
||||
label: 'Failed',
|
||||
},
|
||||
[FilterType.ENROLLED_NO_GRADE]: {
|
||||
key: 'enrolledNoGrade',
|
||||
label: 'Enrolled, No Grade',
|
||||
},
|
||||
}
|
||||
|
||||
export const CourseCard: FC<{
|
||||
course: any
|
||||
filterType: keyof typeof FilterType
|
||||
onChange: (type) => any
|
||||
}> = ({ course, filterType, onChange }) => {
|
||||
const { getTextIn } = useLanguage()
|
||||
const name = 'courseFilter'
|
||||
|
||||
const onClick: NonNullable<DropdownProps['Item']['onClick']> = (_, { value }) => onChange(value)
|
||||
|
||||
const clear = () => {
|
||||
onChange(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<Label style={{ marginTop: '0.5rem' }}>
|
||||
{getTextIn(course?.course?.name)}
|
||||
|
||||
<Dropdown
|
||||
button
|
||||
className="mini"
|
||||
data-cy={`${name}-${course?.course?.code}-dropdown`}
|
||||
fluid
|
||||
style={{ marginTop: '0.5rem' }}
|
||||
text={filterTexts[filterType].label}
|
||||
value={filterType}
|
||||
>
|
||||
<Dropdown.Menu>
|
||||
{Object.entries(filterTexts).map(([type, { key, label }]) => (
|
||||
<Dropdown.Item
|
||||
disabled={!Object.keys(course?.students[key] ?? {}).length}
|
||||
key={label}
|
||||
onClick={onClick}
|
||||
text={label}
|
||||
value={type}
|
||||
/>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
|
||||
<Button
|
||||
compact
|
||||
data-cy={`${name}-${course?.course?.code}-clear`}
|
||||
icon
|
||||
onClick={clear}
|
||||
size="tiny"
|
||||
style={{ marginTop: '0.5rem' }}
|
||||
>
|
||||
<Icon name="close" />
|
||||
</Button>
|
||||
</Label>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -3,4 +3,4 @@ export const FilterType = {
|
||||
PASSED: 'PASSED',
|
||||
FAILED: 'FAILED',
|
||||
ENROLLED_NO_GRADE: 'ENROLLED_NO_GRADE',
|
||||
}
|
||||
} as const
|
||||
+23
-13
@@ -1,29 +1,38 @@
|
||||
import { produce } from 'immer'
|
||||
import { keyBy } from 'lodash'
|
||||
import { Dropdown } from 'semantic-ui-react'
|
||||
import { FC } from 'react'
|
||||
import { Dropdown, type DropdownProps } from 'semantic-ui-react'
|
||||
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import type { FilterContext } from '../../context'
|
||||
import type { FilterTrayProps } from '../../FilterTray'
|
||||
import { createFilter } from '../createFilter'
|
||||
import { CourseCard } from './CourseCard'
|
||||
import { FilterType } from './filterType'
|
||||
|
||||
const CourseFilterCard = ({ courseStats, options, onOptionsChange }) => {
|
||||
const { courseFilters } = options
|
||||
type CourseStats = Record<string, any>
|
||||
|
||||
const CourseFilterCard: FC<{
|
||||
courseStats: CourseStats
|
||||
options: FilterTrayProps['options']
|
||||
onOptionsChange: FilterContext['precomputed']
|
||||
}> = ({ courseStats, options, onOptionsChange }) => {
|
||||
const { courseFilters } = options ?? {}
|
||||
const { getTextIn } = useLanguage()
|
||||
|
||||
const name = 'course-filter-card'
|
||||
// Wrestle course stats into something semantic-ui eats without throwing up.
|
||||
const makeLabel = cs => `${cs.course.code} - ${getTextIn(cs.course.name)}`
|
||||
const dropdownOptions = Object.values(courseStats)
|
||||
.filter(cs => !courseFilters[cs.course.code])
|
||||
.sort((a, b) => makeLabel(a).localeCompare(makeLabel(b)))
|
||||
.sort((a, b) => a.course.code.localeCompare(b.course.code))
|
||||
.map(cs => ({
|
||||
key: `course-filter-option-${cs.course.code}`,
|
||||
text: makeLabel(cs),
|
||||
text: `${cs.course.code} - ${getTextIn(cs.course.name)}`,
|
||||
value: cs.course.code,
|
||||
}))
|
||||
|
||||
const setCourseFilter = (code, type) =>
|
||||
onOptionsChange(
|
||||
produce(options, draft => {
|
||||
produce(options ?? {}, draft => {
|
||||
if (type === null) {
|
||||
delete draft.courseFilters[code]
|
||||
} else {
|
||||
@@ -32,8 +41,8 @@ const CourseFilterCard = ({ courseStats, options, onOptionsChange }) => {
|
||||
})
|
||||
)
|
||||
|
||||
const onChange = (_, { value }) => {
|
||||
setCourseFilter(value[0], FilterType.ALL)
|
||||
const onChange: NonNullable<DropdownProps['onChange']> = (_, { value }) => {
|
||||
setCourseFilter(value?.[0], FilterType.ALL)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -89,11 +98,12 @@ export const courseFilter = createFilter({
|
||||
|
||||
isActive: ({ courseFilters }) => Object.entries(courseFilters).length > 0,
|
||||
|
||||
filter(student, { courseFilters }, { precomputed: courseMap }) {
|
||||
filter(student, { precomputed: courseMap, options }) {
|
||||
const { courseFilters = {} } = options ?? { courseFilters: null }
|
||||
|
||||
return Object.entries(courseFilters).reduce((result, [code, filterType]) => {
|
||||
const stats = courseMap[code]
|
||||
if (!stats) return false
|
||||
return result && filterFunctions[filterType](student, stats)
|
||||
return !!stats && result && filterFunctions[filterType as string](student, stats)
|
||||
}, true)
|
||||
},
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import { produce } from 'immer'
|
||||
import { mapValues } from 'lodash'
|
||||
|
||||
import { setFilterOptions } from '@/redux/filters'
|
||||
|
||||
export const createFilter = options => {
|
||||
const Component = options.component
|
||||
|
||||
const selectorFuncs = {
|
||||
...(options.selectors ?? {}),
|
||||
selectOptions: opts => opts,
|
||||
isActive: opts => options.isActive(opts),
|
||||
}
|
||||
|
||||
const actionFuncs = {
|
||||
...(options.actions ?? {}),
|
||||
setOptions: (_options, value) => value,
|
||||
reset: () => null,
|
||||
}
|
||||
|
||||
/**
|
||||
* Selectors are wrapped redux selectors that act on the filter's options.
|
||||
*/
|
||||
const selectors = mapValues(selectorFuncs, selector => {
|
||||
if (selector.length === 1) {
|
||||
const wrapper = options => selector(options)
|
||||
wrapper.filter = options.key
|
||||
return wrapper
|
||||
}
|
||||
return (...args) => {
|
||||
const wrapper = options => selector(options, ...args)
|
||||
wrapper.filter = options.key
|
||||
return wrapper
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Actions are wrapped redux actions that act on the filter's options.
|
||||
*
|
||||
* Note that these actions cannot be dispatched using the vanilla redux
|
||||
* dispatch function. You need to use the dipatch function obtained form
|
||||
* the useFilterDispatch hook.
|
||||
*/
|
||||
const actions = mapValues(actionFuncs, (action, name) => payload => (view, getContext) => {
|
||||
const ctx = getContext(options.key)
|
||||
|
||||
return setFilterOptions({
|
||||
view,
|
||||
filter: options.key,
|
||||
action: `${options.key}/${name}`,
|
||||
options: produce(ctx.options, draft => action(draft, payload, ctx)),
|
||||
})
|
||||
})
|
||||
|
||||
const factory = args => ({
|
||||
args,
|
||||
|
||||
/**
|
||||
* Non-user-visible key for differentiating the filter.
|
||||
*/
|
||||
key: options.key,
|
||||
|
||||
/**
|
||||
* User-visible label for the filter.
|
||||
*
|
||||
* Defaults to the filter key.
|
||||
*/
|
||||
title: options.title ?? options.key,
|
||||
|
||||
/**
|
||||
* Information tooltip displayed on hover.
|
||||
*/
|
||||
info: options.info,
|
||||
|
||||
/**
|
||||
* Default options with which the filter is initialized.
|
||||
*/
|
||||
defaultOptions: options.defaultOptions,
|
||||
|
||||
/**
|
||||
* Returns whether the filter is active or not, based on the current options.
|
||||
*
|
||||
* The filter is evaluated over the student list only when this function
|
||||
* returns true. Activity of the filter is also reflected in the user
|
||||
* interface.
|
||||
*/
|
||||
isActive: options.isActive,
|
||||
|
||||
/**
|
||||
* Evaluated for each student in the population.
|
||||
* Returns true if the student passes the filter.
|
||||
*
|
||||
* Parameters:
|
||||
* 1. student - The student being evaluated.
|
||||
* 2. options - Current options of the filter.
|
||||
* 3. precomputed - Value returned by the precomputetion function of
|
||||
* this filter, if one was defined.
|
||||
*/
|
||||
filter: options.filter,
|
||||
|
||||
/**
|
||||
* The precompute function is executes _once_ every time the student list
|
||||
* changes. It's return value is passed to the filter and render functions.
|
||||
*
|
||||
* This can be used to, for example, generate lookup-tables from the student
|
||||
* data for use in the filtering.
|
||||
*/
|
||||
precompute: options.precompute,
|
||||
|
||||
/**
|
||||
* The render function renders the UI for this filter displayed in the
|
||||
* filter tray.
|
||||
*
|
||||
* For the simplest use cases, a component property can be provided, for
|
||||
* which a simple default render funciton is genereated.
|
||||
*
|
||||
* Parameters:
|
||||
* 1. props - Props to be passed to the component.
|
||||
* props.options - Current options of the filter.
|
||||
* props.onOptionsChange - Callback used to update the options.
|
||||
* props.wihtoutSelf - Function used to evaluate the current filter-set
|
||||
* _without_ this filter.
|
||||
* 2. precomputed - The value passed from the precompute function.
|
||||
*/
|
||||
render: options.render ? options.render : props => <Component {...props} />,
|
||||
|
||||
/**
|
||||
* Filters' priority determines the order in which filters are evaluated.
|
||||
*
|
||||
* In the normal case, this should not matter. But especially filters which
|
||||
* modify the student data (ikr - sounds bad), this is important.
|
||||
*
|
||||
* The filters with lower priority are executed first.
|
||||
*/
|
||||
priority: options.priority ?? 0,
|
||||
|
||||
actions,
|
||||
|
||||
selectors,
|
||||
})
|
||||
|
||||
factory.key = options.key
|
||||
factory.actions = actions
|
||||
factory.selectors = selectors
|
||||
|
||||
return factory
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { produce } from 'immer'
|
||||
|
||||
import { ReactNode } from 'react'
|
||||
import { setFilterOptions } from '@/redux/filters'
|
||||
import { mapValues } from '@oodikone/shared/util'
|
||||
|
||||
import { Student } from '..'
|
||||
import type { FilterContext, FilterViewContextState } from '../context'
|
||||
import type { FilterTrayProps } from '../FilterTray'
|
||||
|
||||
/** TODO: Find acual types */
|
||||
type FilterOptions = {
|
||||
/**
|
||||
* Non-user visible (unique) identifier for the filter.
|
||||
*/
|
||||
key: string
|
||||
|
||||
/**
|
||||
* User visible identifier. Defaults to the key value.
|
||||
*/
|
||||
title?: string
|
||||
|
||||
/**
|
||||
* User visible tooltip.
|
||||
*/
|
||||
info?: string
|
||||
|
||||
/**
|
||||
* Default options with which the filter is initialized.
|
||||
*
|
||||
* Fallback for `resolveFilterOptions` if values are not stored
|
||||
* and initial values are not set.
|
||||
*/
|
||||
defaultOptions: Record<any, any>
|
||||
|
||||
/**
|
||||
* Function used to filter the students.
|
||||
*/
|
||||
filter: (students: Student, ctx: FilterContext) => boolean
|
||||
|
||||
isActive: (opts: FilterContext['options']) => boolean
|
||||
|
||||
/**
|
||||
* Redux selectors.
|
||||
* `selectOptions` and `isActive` will be overwriten.
|
||||
*/
|
||||
selectors?: Record<string, (options: any, ...args: any[]) => any>
|
||||
|
||||
/**
|
||||
* By default `setOptions` and `reset` are assigned.
|
||||
* NOTE: `reset` will set the value to null, this may not be desired!
|
||||
*/
|
||||
actions?: Record<string, (options: FilterContext['options'], payload: any) => void>
|
||||
|
||||
/**
|
||||
* Precompute filter;
|
||||
* This value is used instead of running the filter again for the population.
|
||||
*/
|
||||
precompute?: (ctx: FilterContext) => any
|
||||
|
||||
/**
|
||||
* Used to determine sort order.
|
||||
*/
|
||||
priority?: number
|
||||
|
||||
/**
|
||||
* Filter tray render component.
|
||||
*/
|
||||
render: (props: FilterTrayProps, ctx: FilterContext) => ReactNode
|
||||
}
|
||||
|
||||
export type Filter = {
|
||||
args?: any
|
||||
|
||||
key: FilterOptions['key']
|
||||
title: NonNullable<FilterOptions['title'] | FilterOptions['key']>
|
||||
info: FilterOptions['info']
|
||||
|
||||
defaultOptions: FilterOptions['defaultOptions']
|
||||
|
||||
isActive: FilterOptions['isActive']
|
||||
|
||||
filter: FilterOptions['filter']
|
||||
precompute: FilterOptions['precompute']
|
||||
|
||||
render: FilterOptions['render']
|
||||
priority: number
|
||||
}
|
||||
|
||||
export type FilterFactory = {
|
||||
key: Filter['key']
|
||||
actions: Record<
|
||||
string,
|
||||
(payload: any) => (
|
||||
view: string,
|
||||
getContext: FilterViewContextState['getContextByKey']
|
||||
) => {
|
||||
payload: any
|
||||
type: string
|
||||
}
|
||||
>
|
||||
selectors: Record<
|
||||
string,
|
||||
(...args: any[]) => {
|
||||
(opts: any): any
|
||||
filter: string
|
||||
}
|
||||
>
|
||||
(args?: any): Filter
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlike the name suggests, this function returns a filter factory.
|
||||
*/
|
||||
export const createFilter = (options: FilterOptions): FilterFactory => {
|
||||
const opt_selectors = options.selectors ?? {}
|
||||
const opt_actions = options.actions ?? {}
|
||||
|
||||
/**
|
||||
* Selectors are wrapped redux selectors that act on the filter's options.
|
||||
*/
|
||||
const selectors = mapValues(
|
||||
{
|
||||
...opt_selectors,
|
||||
selectOptions: (opts, _) => opts,
|
||||
isActive: options.isActive,
|
||||
},
|
||||
([key, selector]) => {
|
||||
const gift = (...args) => {
|
||||
const wrapper = opts => selector(opts, args)
|
||||
wrapper.filter = options.key
|
||||
|
||||
return wrapper
|
||||
}
|
||||
|
||||
return [key, gift]
|
||||
}
|
||||
)
|
||||
/**
|
||||
* Actions are wrapped redux actions that act on the filter's options.
|
||||
*
|
||||
* Note that these actions cannot be dispatched using the vanilla redux
|
||||
* dispatch function. You need to use the dipatch function obtained form
|
||||
* the useFilterDispatch hook.
|
||||
*/
|
||||
const actions = mapValues(
|
||||
{
|
||||
setOptions: (_, value) => value,
|
||||
reset: (..._) => null,
|
||||
...opt_actions,
|
||||
},
|
||||
([key, action]) => {
|
||||
return [
|
||||
key,
|
||||
payload => (view: string, getContext: FilterViewContextState['getContextByKey']) => {
|
||||
const ctx = getContext(options.key)
|
||||
|
||||
return setFilterOptions({
|
||||
view,
|
||||
filter: options.key,
|
||||
action: `${options.key}/${key}`,
|
||||
options: produce(ctx.options, (draft: FilterContext['options']) => action(draft, payload)),
|
||||
})
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* `filter`
|
||||
* Evaluated for each student in the population.
|
||||
* Returns true if the student passes the filter.
|
||||
*
|
||||
* Parameters:
|
||||
* 1. student - The student being evaluated.
|
||||
* 2. options - Current options of the filter.
|
||||
* 3. precomputed - Value returned by the precomputetion function of
|
||||
* this filter, if one was defined.
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* `render`
|
||||
* The render function renders the UI for this filter displayed in the
|
||||
* filter tray.
|
||||
*
|
||||
* For the simplest use cases, a component property can be provided, for
|
||||
* which a simple default render funciton is genereated.
|
||||
*
|
||||
* Parameters:
|
||||
* 1. props - Props to be passed to the component.
|
||||
* props.options - Current options of the filter.
|
||||
* props.onOptionsChange - Callback used to update the options.
|
||||
* props.wihtoutSelf - Function used to evaluate the current filter-set
|
||||
* _without_ this filter.
|
||||
* 2. precomputed - The value passed from the precompute function.
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* `precompute`
|
||||
* The precompute function is executes _once_ every time the student list
|
||||
* changes. It's return value is passed to the filter and render functions.
|
||||
*
|
||||
* This can be used to, for example, generate lookup-tables from the student
|
||||
* data for use in the filtering.
|
||||
*
|
||||
* `isActive`
|
||||
* Returns whether the filter is active or not, based on the current options.
|
||||
* The filter is evaluated over the student list only when this function returns true.
|
||||
* Activity of the filter is also reflected in the user interface.
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* `priority`
|
||||
* Filters' priority determines the order in which filters are evaluated.
|
||||
*
|
||||
* In the normal case, this should not matter. But especially filters which
|
||||
* modify the student data (ikr - sounds bad), this is important.
|
||||
*
|
||||
* The filters with lower priority are executed first.
|
||||
*
|
||||
* @dogamak
|
||||
*/
|
||||
const factory = (args?: any): Filter => ({
|
||||
args,
|
||||
|
||||
key: options.key,
|
||||
title: options.title ?? options.key,
|
||||
info: options.info,
|
||||
|
||||
defaultOptions: options.defaultOptions,
|
||||
|
||||
isActive: options.isActive,
|
||||
|
||||
filter: options.filter,
|
||||
precompute: options.precompute,
|
||||
|
||||
render: options.render,
|
||||
priority: options.priority ?? 0,
|
||||
})
|
||||
|
||||
factory.key = options.key
|
||||
factory.actions = actions
|
||||
factory.selectors = selectors
|
||||
|
||||
return factory
|
||||
}
|
||||
+4
-12
@@ -46,7 +46,7 @@ export const creditsEarnedFilter = createFilter({
|
||||
isActive: ({ min, max }) => min !== null || max !== null,
|
||||
|
||||
precompute: ({ students }) => {
|
||||
const credits = students.map(getStudentTotalCredits).filter(n => !Number.isNaN(n))
|
||||
const credits = students.map(student => getStudentTotalCredits(student)).filter(n => !Number.isNaN(n))
|
||||
|
||||
return {
|
||||
max: max(credits),
|
||||
@@ -54,18 +54,10 @@ export const creditsEarnedFilter = createFilter({
|
||||
}
|
||||
},
|
||||
|
||||
filter(student, { min, max }) {
|
||||
const { credits } = student
|
||||
filter({ credits }, { options }) {
|
||||
const { min, max } = options
|
||||
|
||||
if (min !== null && credits < min) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (max !== null && credits > max) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
return !(min !== null && credits < min) && !(max !== null && credits > max)
|
||||
},
|
||||
|
||||
render: (props, { precomputed }) => <CreditsEarnedFilterCard {...props} bounds={precomputed} />,
|
||||
+10
-10
@@ -3,19 +3,17 @@ import { Form, Dropdown } from 'semantic-ui-react'
|
||||
|
||||
import { createFilter } from './createFilter'
|
||||
|
||||
const CurriculumPeriodFilterCard = ({ options, onOptionsChange, withoutSelf }) => {
|
||||
const CurriculumPeriodFilterCard = ({ options, onOptionsChange, students }) => {
|
||||
const { selected } = options
|
||||
|
||||
const dropdownOptions = withoutSelf().reduce((curriculumPeriods, student) => {
|
||||
const curriculumPeriod = student.curriculumVersion
|
||||
|
||||
const dropdownOptions = students.reduce((curriculumPeriods, { curriculumPeriod }) => {
|
||||
if (curriculumPeriods.every(option => option.value !== curriculumPeriod)) {
|
||||
const count = withoutSelf().filter(student => student.curriculumVersion === curriculumPeriod).length
|
||||
const count = students.filter(({ curriculumVersion }) => curriculumVersion === curriculumPeriod).length
|
||||
|
||||
curriculumPeriods.push({
|
||||
key: curriculumPeriod,
|
||||
value: curriculumPeriod,
|
||||
text: `${curriculumPeriod || 'No period selected'} (${count})`,
|
||||
text: `${curriculumPeriod ?? 'No period selected'} (${count})`,
|
||||
count,
|
||||
})
|
||||
}
|
||||
@@ -53,11 +51,13 @@ export const curriculumPeriodFilter = createFilter({
|
||||
selected: '',
|
||||
},
|
||||
|
||||
isActive: ({ selected }) => selected !== '',
|
||||
isActive: ({ selected }) => !!selected,
|
||||
|
||||
filter(student, { selected }) {
|
||||
return selected === student.curriculumVersion
|
||||
filter({ curriculumVersion }, { options }) {
|
||||
const { selected } = options
|
||||
|
||||
return selected === curriculumVersion
|
||||
},
|
||||
|
||||
component: CurriculumPeriodFilterCard,
|
||||
render: CurriculumPeriodFilterCard,
|
||||
})
|
||||
+9
-12
@@ -44,18 +44,15 @@ export const creditDateFilter = createFilter({
|
||||
|
||||
isActive: ({ startDate, endDate }) => startDate !== null || endDate !== null,
|
||||
|
||||
filter(student, { startDate, endDate }) {
|
||||
student.courses = student.courses.filter(course => {
|
||||
if (startDate && startDate.isAfter(course.date, 'day')) {
|
||||
return false
|
||||
}
|
||||
/**
|
||||
* startDate and endDate are both moment.Moment
|
||||
*/
|
||||
filter(student, { options }) {
|
||||
const { startDate, endDate } = options
|
||||
|
||||
if (endDate && endDate.isBefore(course.date, 'day')) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
student.courses = student.courses.filter(
|
||||
course => !startDate?.isAfter(course.date, 'day') && !endDate?.isBefore(course.date, 'day')
|
||||
)
|
||||
|
||||
return true
|
||||
},
|
||||
@@ -64,5 +61,5 @@ export const creditDateFilter = createFilter({
|
||||
selectedStartDate: ({ startDate }, _) => startDate,
|
||||
},
|
||||
|
||||
component: CreditDateFilterCard,
|
||||
render: CreditDateFilterCard,
|
||||
})
|
||||
+25
-20
@@ -1,3 +1,4 @@
|
||||
import { FC } from 'react'
|
||||
import { Dropdown, Form } from 'semantic-ui-react'
|
||||
|
||||
import { filterToolTips } from '@/common/InfoToolTips'
|
||||
@@ -10,16 +11,20 @@ const STATUS_OPTIONS = [
|
||||
{ key: 'enrl-status-inactive', text: 'Passive', value: 3 },
|
||||
]
|
||||
|
||||
const EnrollmentStatusFilterCard = ({ options, onOptionsChange, allSemesters }) => {
|
||||
const EnrollmentStatusFilterCard: FC<{
|
||||
options: any
|
||||
onOptionsChange: any
|
||||
allSemesters: Record<string, any>
|
||||
}> = ({ options, onOptionsChange, allSemesters }) => {
|
||||
const name = 'enrollmentStatusFilter'
|
||||
const { getTextIn } = useLanguage()
|
||||
|
||||
const { semesters, status } = options
|
||||
|
||||
if (!Object.keys(allSemesters).length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { semesters, status } = options
|
||||
|
||||
const semesterOptions = Object.values(allSemesters)
|
||||
.filter(semester => new Date(semester.startdate) <= new Date())
|
||||
.sort((a, b) => b.semestercode - a.semestercode)
|
||||
@@ -88,29 +93,29 @@ export const enrollmentStatusFilter = createFilter({
|
||||
|
||||
isActive: ({ status }) => status !== null,
|
||||
|
||||
filter(student, { status, semesters }, { args }) {
|
||||
filter({ studyRights }, { args, options }) {
|
||||
const { status, semesters } = options
|
||||
|
||||
if (args.programme) {
|
||||
const correctStudyRight = student.studyRights.find(studyRight =>
|
||||
studyRight.studyRightElements.some(element => element.code === args.programme)
|
||||
const correctStudyRight = studyRights.find(({ studyRightElements }) =>
|
||||
studyRightElements.some(({ code }) => code === args.programme)
|
||||
)
|
||||
|
||||
return semesters.every(
|
||||
semester =>
|
||||
correctStudyRight.semesterEnrollments.find(
|
||||
({ semester: enrollmentSemester }) => enrollmentSemester === semester
|
||||
)?.type === status
|
||||
)
|
||||
return correctStudyRight
|
||||
? semesters.every(semester => {
|
||||
const enrollment = correctStudyRight.semesterEnrollments.find(
|
||||
enrollment => enrollment.semester === semester
|
||||
)
|
||||
return enrollment ? enrollment.type === status : false
|
||||
})
|
||||
: false
|
||||
}
|
||||
|
||||
const allEnrollments = student.studyRights
|
||||
.flatMap(studyRight => studyRight.semesterEnrollments)
|
||||
.filter(enrollment => enrollment != null)
|
||||
const allEnrollments = studyRights.flatMap(studyRight => studyRight.semesterEnrollments).filter(Boolean)
|
||||
|
||||
return semesters.every(semester => {
|
||||
const enrollments = allEnrollments.filter(enrl => enrl.semester === semester)
|
||||
// If enrollment info not found, return false. This may or may not be what we want?
|
||||
return enrollments.length ? enrollments.some(enrl => enrl.type === status) : false
|
||||
// HACK: If enrollment info not found, return false. This may or may not be what we want?
|
||||
return allEnrollments
|
||||
.filter(({ semester: enrollmentSemester }) => enrollmentSemester === semester)
|
||||
.some(({ type }) => type === status)
|
||||
})
|
||||
},
|
||||
|
||||
+11
-8
@@ -1,4 +1,5 @@
|
||||
import { Form, Dropdown } from 'semantic-ui-react'
|
||||
|
||||
import { createFilter } from './createFilter'
|
||||
|
||||
const GENDERS = {
|
||||
@@ -8,15 +9,15 @@ const GENDERS = {
|
||||
unknown: { label: 'Unknown', value: 0 },
|
||||
}
|
||||
|
||||
const GenderFilterCard = ({ options, onOptionsChange, withoutSelf }) => {
|
||||
const GenderFilterCard = ({ options, onOptionsChange, students }) => {
|
||||
const { selected } = options
|
||||
|
||||
const count = genderCode => withoutSelf().filter(student => Number(student.gender_code) === genderCode).length
|
||||
const count = (genderCode: number) => students.filter(student => Number(student.gender_code) === genderCode).length
|
||||
|
||||
const dropdownOptions = Object.entries(GENDERS).map(([key, gender]) => ({
|
||||
const dropdownOptions = Object.entries(GENDERS).map(([key, { label, value }]) => ({
|
||||
key,
|
||||
text: `${gender.label} (${count(gender.value)})`,
|
||||
value: gender.value,
|
||||
text: `${label} (${count(value)})`,
|
||||
value,
|
||||
}))
|
||||
|
||||
return (
|
||||
@@ -49,9 +50,11 @@ export const genderFilter = createFilter({
|
||||
|
||||
isActive: ({ selected }) => selected !== '',
|
||||
|
||||
filter(student, { selected }) {
|
||||
return selected === Number(student.gender_code)
|
||||
filter(student, { options }) {
|
||||
const { selected } = options
|
||||
|
||||
return Number(student.gender_code) === selected
|
||||
},
|
||||
|
||||
component: GenderFilterCard,
|
||||
render: GenderFilterCard,
|
||||
})
|
||||
+11
-10
@@ -8,11 +8,13 @@ import { createFilter } from './createFilter'
|
||||
* Grade filter.
|
||||
* Only applicable to a single course.
|
||||
*/
|
||||
const GradeFilterCard = ({ options, onOptionsChange, grades, withoutSelf }) => {
|
||||
const GradeFilterCard = ({ options, onOptionsChange, grades, students }) => {
|
||||
const { selected } = options
|
||||
const name = 'gradeFilter'
|
||||
|
||||
const choices = Object.keys(grades).sort((a, b) => b - a)
|
||||
// There are grades 1..5 (inclusive) and 'No grade'
|
||||
// Therefore this cannot be filtered by Number
|
||||
const choices = Object.keys(grades).sort((a, b) => +b - +a)
|
||||
|
||||
const checked = grade => selected.includes(grade)
|
||||
|
||||
@@ -30,11 +32,8 @@ const GradeFilterCard = ({ options, onOptionsChange, grades, withoutSelf }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const studentsWithoutSelf = withoutSelf()
|
||||
const gradesWithoutSelf = fp.mapValues(
|
||||
fp.filter(
|
||||
studentNumber => studentsWithoutSelf.find(student => student.studentNumber === studentNumber) !== undefined
|
||||
)
|
||||
fp.filter(studentNumber => students.some(student => student.studentNumber === studentNumber))
|
||||
)(grades)
|
||||
|
||||
return (
|
||||
@@ -70,10 +69,10 @@ export const gradeFilter = createFilter({
|
||||
|
||||
precompute: ({ students, args }) =>
|
||||
fp.flow(
|
||||
fp.map(student => [
|
||||
fp.map((student: any) => [
|
||||
student.studentNumber,
|
||||
fp.filter(course => args.courseCodes.includes(course.course_code))(student.courses),
|
||||
fp.filter(enrollment => args.courseCodes.includes(enrollment.course_code))(student.enrollments),
|
||||
fp.filter((course: any) => args.courseCodes.includes(course.course_code))(student.courses),
|
||||
fp.filter((enrollment: any) => args.courseCodes.includes(enrollment.course_code))(student.enrollments),
|
||||
]),
|
||||
/* fp.filter(
|
||||
fp.flow(
|
||||
@@ -92,7 +91,9 @@ export const gradeFilter = createFilter({
|
||||
grades => ({ grades })
|
||||
)(students),
|
||||
|
||||
filter(student, { selected }, { precomputed }) {
|
||||
filter(student, { precomputed, options }) {
|
||||
const { selected } = options
|
||||
|
||||
return selected.some(selectedGrade => precomputed.grades[selectedGrade].includes(student.studentNumber))
|
||||
},
|
||||
|
||||
+4
-2
@@ -55,7 +55,9 @@ export const graduatedFromProgrammeFilter = createFilter({
|
||||
|
||||
isActive: ({ mode }) => mode !== null,
|
||||
|
||||
filter(student, { mode }, { args }) {
|
||||
filter(student, { args, options }) {
|
||||
const { mode } = options
|
||||
|
||||
const { code, showBachelorAndMaster } = args
|
||||
const BACHELORS = 'urn:code:degree-program-type:bachelors-degree'
|
||||
const MASTERS = 'urn:code:degree-program-type:masters-degree'
|
||||
@@ -85,7 +87,7 @@ export const graduatedFromProgrammeFilter = createFilter({
|
||||
render: (props, { args }) => (
|
||||
<GraduatedFromProgrammeFilterCard
|
||||
{...props}
|
||||
isCombinedExtent={args.code && (args.combinedProgrammeCode || args.showBachelorAndMaster)}
|
||||
isCombinedExtent={!!args.code && (!!args.combinedProgrammeCode || !!args.showBachelorAndMaster)}
|
||||
isLicentiate={args.combinedProgrammeCode === 'MH90_001'}
|
||||
/>
|
||||
),
|
||||
+14
-8
@@ -1,4 +1,5 @@
|
||||
import moment from 'moment'
|
||||
import { FC } from 'react'
|
||||
import { Radio, Button, Form } from 'semantic-ui-react'
|
||||
|
||||
import { useFilters } from '@/components/FilterView/useFilters'
|
||||
@@ -29,7 +30,11 @@ const getCutStudyStart = ({ options, filterDispatch }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const HopsFilterCard = ({ options, onOptionsChange, combinedProgramme }) => {
|
||||
const HopsFilterCard: FC<{
|
||||
options: any
|
||||
onOptionsChange: any
|
||||
combinedProgramme: any
|
||||
}> = ({ options, onOptionsChange, combinedProgramme }) => {
|
||||
const { selectedStartDate } = creditDateFilter.selectors
|
||||
const { filterDispatch, useFilterSelector } = useFilters()
|
||||
const selectedCreditStartDate = useFilterSelector(selectedStartDate(''))
|
||||
@@ -162,12 +167,15 @@ export const hopsFilter = createFilter({
|
||||
return args.combinedProgrammeCode
|
||||
},
|
||||
|
||||
isActive: arg => arg?.activeProgramme || arg?.activeCombinedProgramme,
|
||||
isActive: arg => !!arg?.activeProgramme || !!arg?.activeCombinedProgramme,
|
||||
|
||||
filter: (student, { activeProgramme, activeCombinedProgramme }, { args }) => {
|
||||
const studyRights = student.studyRights.filter(studyRight => !studyRight.cancelled).map(({ id }) => id)
|
||||
filter: (student, { args, options }) => {
|
||||
const { activeProgramme, activeCombinedProgramme } = options
|
||||
|
||||
const studyRights = student.studyRights.filter(({ cancelled }) => !cancelled).map(({ id }) => id)
|
||||
const hops = student.studyplans.find(
|
||||
plan => plan.programme_code === args.programmeCode && studyRights.includes(plan.sis_study_right_id)
|
||||
({ programme_code, sis_study_right_id }) =>
|
||||
programme_code === args.programmeCode && studyRights.includes(sis_study_right_id)
|
||||
)
|
||||
const secondHops = args.combinedProgrammeCode
|
||||
? student.studyplans.find(
|
||||
@@ -183,7 +191,7 @@ export const hopsFilter = createFilter({
|
||||
const courses = new Set(hops && activeProgramme ? hops.included_courses : [])
|
||||
const secondProgrammeCourses = new Set(secondHops && activeCombinedProgramme ? secondHops.included_courses : [])
|
||||
const hopsCourses = student.courses.filter(
|
||||
course => courses.has(course.course_code) || secondProgrammeCourses.has(course.course_code)
|
||||
({ course_code }) => courses.has(course_code) || secondProgrammeCourses.has(course_code)
|
||||
)
|
||||
student.courses = [...new Set(hopsCourses)]
|
||||
return true
|
||||
@@ -214,7 +222,5 @@ export const hopsFilter = createFilter({
|
||||
},
|
||||
},
|
||||
|
||||
component: HopsFilterCard,
|
||||
|
||||
render: (props, { precomputed }) => <HopsFilterCard {...props} combinedProgramme={precomputed} />,
|
||||
})
|
||||
+29
-23
@@ -1,45 +1,49 @@
|
||||
import moment from 'moment'
|
||||
import { Dropdown } from 'semantic-ui-react'
|
||||
import { Dropdown, type DropdownProps } from 'semantic-ui-react'
|
||||
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { createFilter } from './createFilter'
|
||||
|
||||
const NO_PROGRAMME = { code: '00000', name: { en: 'No programme', fi: 'Ei ohjelmaa' } }
|
||||
|
||||
const ProgrammeFilterCard = ({ additionalModes, onOptionsChange, options, studentToProgrammeMap, withoutSelf }) => {
|
||||
const ProgrammeFilterCard = ({ additionalModes, onOptionsChange, options, studentToProgrammeMap, students }) => {
|
||||
const { getTextIn } = useLanguage()
|
||||
const { selectedProgrammes } = options
|
||||
|
||||
const visibleProgrammes = withoutSelf().reduce((acc, student) => {
|
||||
const studentsProgrammes = studentToProgrammeMap[student.studentNumber]
|
||||
for (const programme of studentsProgrammes) {
|
||||
const existingProgramme = acc.find(prog => prog.code === programme.code)
|
||||
if (existingProgramme) {
|
||||
existingProgramme.studentCount += 1
|
||||
} else {
|
||||
acc.push({ ...programme, studentCount: 1 })
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
// TODO: retype
|
||||
const visibleProgrammes: {
|
||||
code
|
||||
name
|
||||
studentCount
|
||||
}[] = []
|
||||
students.forEach(student => {
|
||||
studentToProgrammeMap[student.studentNumber].forEach(programme => {
|
||||
const prog = visibleProgrammes.find(prog => prog.code === programme.code)
|
||||
|
||||
if (!prog) {
|
||||
visibleProgrammes.push({ ...programme, studentCount: 1 })
|
||||
} else prog.studentCount += 1
|
||||
})
|
||||
})
|
||||
|
||||
const dropdownOptions = visibleProgrammes
|
||||
.map(({ code, name, studentCount }) => ({
|
||||
key: `programme-filter-value-${code}`,
|
||||
text: getTextIn(name),
|
||||
text: getTextIn(name) ?? '',
|
||||
value: code,
|
||||
content: (
|
||||
<>
|
||||
{getTextIn(name)}{' '}
|
||||
{getTextIn(name)}
|
||||
<br />
|
||||
<span style={{ color: '#888', whiteSpace: 'nowrap' }}>
|
||||
({studentCount} student{studentCount === 1 ? '' : 's'})
|
||||
({studentCount} student{studentCount !== 1 && 's'})
|
||||
</span>
|
||||
</>
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => a.text.localeCompare(b.text))
|
||||
|
||||
const handleChange = (_, { value }) => {
|
||||
const handleChange: NonNullable<DropdownProps['onChange']> = (_, { value }) => {
|
||||
onOptionsChange({
|
||||
...options,
|
||||
selectedProgrammes: value,
|
||||
@@ -127,8 +131,8 @@ const ProgrammeFilterCard = ({ additionalModes, onOptionsChange, options, studen
|
||||
}
|
||||
|
||||
const getStudentProgrammes = student =>
|
||||
(student?.studyRights ?? []).flatMap(studyRight =>
|
||||
studyRight.studyRightElements.map(element => ({ ...element, cancelled: studyRight.cancelled }))
|
||||
(student?.studyRights ?? []).flatMap(({ studyRightElements, cancelled }) =>
|
||||
studyRightElements.map(element => ({ ...element, cancelled }))
|
||||
)
|
||||
|
||||
const createStudentToProgrammeMap = (students, studyRightPredicate) => {
|
||||
@@ -168,12 +172,12 @@ export const programmeFilter = createFilter({
|
||||
key: 'Programme',
|
||||
|
||||
defaultOptions: {
|
||||
selectedProgrammes: [],
|
||||
selectedProgrammes: [] as string[],
|
||||
mode: 'active',
|
||||
},
|
||||
|
||||
precompute: ({ students, options, args }) => {
|
||||
let predicate = () => true
|
||||
let predicate = (..._: any[]) => true
|
||||
|
||||
if (args?.studyRightPredicate) {
|
||||
predicate = args.studyRightPredicate
|
||||
@@ -200,7 +204,9 @@ export const programmeFilter = createFilter({
|
||||
|
||||
isActive: ({ selectedProgrammes }) => selectedProgrammes.length > 0,
|
||||
|
||||
filter({ studentNumber }, { selectedProgrammes }, { precomputed: studentToProgrammeMap }) {
|
||||
filter({ studentNumber }, { precomputed: studentToProgrammeMap, options }) {
|
||||
const { selectedProgrammes } = options
|
||||
|
||||
return selectedProgrammes.every(pcode => studentToProgrammeMap[studentNumber].some(({ code }) => code === pcode))
|
||||
},
|
||||
|
||||
+16
-11
@@ -1,22 +1,23 @@
|
||||
import fp from 'lodash/fp'
|
||||
import { Form, Dropdown } from 'semantic-ui-react'
|
||||
|
||||
import { filterToolTips } from '@/common/InfoToolTips'
|
||||
import { createFilter } from './createFilter'
|
||||
|
||||
const StartYearAtUniFilterCard = ({ options, onOptionsChange, withoutSelf }) => {
|
||||
const StartYearAtUniFilterCard = ({ options, onOptionsChange, students }) => {
|
||||
const name = 'startYearAtUni'
|
||||
|
||||
const { selected } = options
|
||||
|
||||
const countsByYear = fp.flow(
|
||||
fp.groupBy(student => new Date(student.started).getFullYear()),
|
||||
fp.mapValues(students => students.length)
|
||||
)(withoutSelf())
|
||||
const countsByYear = {}
|
||||
for (const { started } of students) {
|
||||
const year = new Date(started).getFullYear()
|
||||
|
||||
const dropdownOptions = Object.keys(countsByYear).map(year => ({
|
||||
countsByYear[year] ??= 0
|
||||
countsByYear[year]++
|
||||
}
|
||||
|
||||
const dropdownOptions = Object.entries(countsByYear).map(([year, count]) => ({
|
||||
key: `year-${year}`,
|
||||
text: `${year} (${countsByYear[year]})`,
|
||||
text: `${year} (${count})`,
|
||||
value: Number(year),
|
||||
}))
|
||||
|
||||
@@ -57,7 +58,11 @@ export const startYearAtUniFilter = createFilter({
|
||||
|
||||
isActive: ({ selected }) => selected.length > 0,
|
||||
|
||||
filter: (student, { selected }) => selected.includes(new Date(student.started).getFullYear()),
|
||||
filter: (student, { options }) => {
|
||||
const { selected } = options
|
||||
|
||||
component: StartYearAtUniFilterCard,
|
||||
return selected.includes(new Date(student.started).getFullYear())
|
||||
},
|
||||
|
||||
render: StartYearAtUniFilterCard,
|
||||
})
|
||||
+7
-6
@@ -27,12 +27,13 @@ const EditableList = ({ value, onChange, renderLabel }) => {
|
||||
scrollbarWidth: 'thin',
|
||||
}}
|
||||
>
|
||||
{value.map((studentNumber, index) => (
|
||||
{value.map((studentNumber, index: number) => (
|
||||
<li
|
||||
className={'asdasd'}
|
||||
key={studentNumber}
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
borderTop: index > 0 && '0px solid #e6e6e7',
|
||||
borderTop: index ? '1px solid #e6e6e7' : undefined,
|
||||
display: 'flex',
|
||||
height: '1.75em',
|
||||
}}
|
||||
@@ -103,9 +104,7 @@ const EditableList = ({ value, onChange, renderLabel }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const StudentNumberFilterCard = ({ options, onOptionsChange, withoutSelf }) => {
|
||||
const students = withoutSelf()
|
||||
|
||||
const StudentNumberFilterCard = ({ options, onOptionsChange, students }) => {
|
||||
const swapLists = () => {
|
||||
onOptionsChange({
|
||||
allowlist: options.blocklist,
|
||||
@@ -169,7 +168,9 @@ export const studentNumberFilter = createFilter({
|
||||
|
||||
isActive: ({ allowlist, blocklist }) => allowlist.length > 0 || blocklist.length > 0,
|
||||
|
||||
filter(student, { allowlist, blocklist }) {
|
||||
filter(student, { options }) {
|
||||
const { allowlist, blocklist } = options
|
||||
|
||||
return (
|
||||
(allowlist.length === 0 || includes(allowlist, student.studentNumber)) &&
|
||||
(blocklist.length === 0 || !includes(blocklist, student.studentNumber))
|
||||
+2
-1
@@ -84,7 +84,8 @@ export const studyRightStatusFilter = createFilter({
|
||||
isActive: ({ activeProgramme, activeCombinedProgramme }) =>
|
||||
activeProgramme !== null || activeCombinedProgramme !== null,
|
||||
|
||||
filter: (student, { activeProgramme, activeCombinedProgramme }, { args }) => {
|
||||
filter: (student, { args, options }) => {
|
||||
const { activeProgramme, activeCombinedProgramme } = options
|
||||
const { code, currentSemester, showBachelorAndMaster } = args
|
||||
if (!currentSemester) {
|
||||
return true
|
||||
+4
-2
@@ -48,7 +48,9 @@ export const studyRightTypeFilter = createFilter({
|
||||
|
||||
isActive: ({ mode }) => mode !== 0,
|
||||
|
||||
filter(student, { mode }, { args }) {
|
||||
filter(student, { args, options }) {
|
||||
const { mode } = options
|
||||
|
||||
if (mode === 0) return true
|
||||
|
||||
const studyRight = student.studyRights.find(studyRight =>
|
||||
@@ -60,5 +62,5 @@ export const studyRightTypeFilter = createFilter({
|
||||
return mode === 1 ? studyRight.extentCode === 5 : studyRight.extentCode === 2
|
||||
},
|
||||
|
||||
component: StudyRightTypeFilterCard,
|
||||
render: StudyRightTypeFilterCard,
|
||||
})
|
||||
+11
-6
@@ -1,13 +1,14 @@
|
||||
import { Dropdown, Form } from 'semantic-ui-react'
|
||||
import type { DropdownProps } from 'semantic-ui-react'
|
||||
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { createFilter } from './createFilter'
|
||||
|
||||
const StudyTrackFilterCard = ({ code, onOptionsChange, options, withoutSelf }) => {
|
||||
const StudyTrackFilterCard = ({ code, onOptionsChange, options, students }) => {
|
||||
const { selected } = options
|
||||
const { getTextIn } = useLanguage()
|
||||
|
||||
const dropdownOptions = withoutSelf()
|
||||
const dropdownOptions = students
|
||||
.flatMap(student => student.studyRights)
|
||||
.flatMap(studyRight => studyRight.studyRightElements)
|
||||
.filter(element => element.code === code && element.studyTrack !== null)
|
||||
@@ -30,7 +31,7 @@ const StudyTrackFilterCard = ({ code, onOptionsChange, options, withoutSelf }) =
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
const handleChange = (_, { value }) => {
|
||||
const handleChange: NonNullable<DropdownProps['onChange']> = (_, { value }) => {
|
||||
onOptionsChange({
|
||||
selected: value,
|
||||
})
|
||||
@@ -63,10 +64,14 @@ export const studyTrackFilter = createFilter({
|
||||
selected: [],
|
||||
},
|
||||
isActive: ({ selected }) => (selected !== undefined ? selected.length > 0 : false),
|
||||
filter: (student, { selected }, { args }) =>
|
||||
student.studyRights
|
||||
filter: (student, { args, options }) => {
|
||||
const { selected } = options
|
||||
|
||||
return student.studyRights
|
||||
.flatMap(studyRight => studyRight.studyRightElements)
|
||||
.filter(element => element.code === args.code && element.studyTrack !== null)
|
||||
.some(element => selected.includes(element.studyTrack.code)),
|
||||
.some(element => selected.includes(element.studyTrack.code))
|
||||
},
|
||||
|
||||
render: (props, { args }) => <StudyTrackFilterCard {...props} code={args.code} />,
|
||||
})
|
||||
+20
-26
@@ -2,25 +2,22 @@ import { Form, Dropdown, Message } from 'semantic-ui-react'
|
||||
|
||||
import { createFilter } from './createFilter'
|
||||
|
||||
const TagsFilterCard = ({ options, onOptionsChange, withoutSelf }) => {
|
||||
const TagsFilterCard = ({ options, onOptionsChange, students }) => {
|
||||
const name = 'tagsFilter'
|
||||
const { includedTags, excludedTags } = options
|
||||
|
||||
const tagCounts = withoutSelf().reduce((acc, student) => {
|
||||
student.tags.forEach(tag => {
|
||||
const {
|
||||
tag_id: id,
|
||||
tag: { tagname },
|
||||
} = tag
|
||||
if (!acc[id]) {
|
||||
acc[id] = { count: 0, name: tagname }
|
||||
}
|
||||
acc[id].count += 1
|
||||
})
|
||||
return acc
|
||||
}, {})
|
||||
const tagCounts = new Map<string, { count: number; name: string }>()
|
||||
for (const student of students) {
|
||||
for (const {
|
||||
tag_id: id,
|
||||
tag: { tagname },
|
||||
} of student.tags) {
|
||||
tagCounts[id] ??= { count: 0, name: tagname }
|
||||
tagCounts[id].count++
|
||||
}
|
||||
}
|
||||
|
||||
const dropdownOptions = Object.entries(tagCounts).map(([tagId, { count, name }]) => ({
|
||||
const dropdownOptions = Array.from(tagCounts.entries()).map(([tagId, { count, name }]) => ({
|
||||
key: `tag-${tagId}`,
|
||||
text: `${name} (${count})`,
|
||||
value: tagId,
|
||||
@@ -29,13 +26,12 @@ const TagsFilterCard = ({ options, onOptionsChange, withoutSelf }) => {
|
||||
const includeOptions = dropdownOptions.filter(({ value }) => !excludedTags.includes(value))
|
||||
const excludeOptions = dropdownOptions.filter(({ value }) => !includedTags.includes(value))
|
||||
|
||||
if (Object.entries(tagCounts).length === 0) {
|
||||
if (!tagCounts.size)
|
||||
return (
|
||||
<Message color="orange" size="tiny">
|
||||
No tags have been defined for any of the selected students.
|
||||
</Message>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Form>
|
||||
@@ -77,20 +73,18 @@ export const tagsFilter = createFilter({
|
||||
excludedTags: [],
|
||||
},
|
||||
|
||||
isActive: ({ includedTags, excludedTags }) => includedTags.length > 0 || excludedTags.length > 0,
|
||||
isActive: ({ includedTags, excludedTags }) => !!includedTags.length || !!excludedTags.length,
|
||||
|
||||
filter(student, { includedTags, excludedTags }) {
|
||||
filter(student, { options }) {
|
||||
const { includedTags, excludedTags } = options
|
||||
const tags = (student.tags ?? []).map(tag => tag.tag_id)
|
||||
if (includedTags.length > 0 && excludedTags.length > 0) {
|
||||
if (includedTags.length && excludedTags.length)
|
||||
return includedTags.some(tag => tags.includes(tag)) && !excludedTags.some(tag => tags.includes(tag))
|
||||
}
|
||||
|
||||
if (includedTags.length > 0) return includedTags.some(tag => tags.includes(tag))
|
||||
|
||||
if (excludedTags.length > 0) return !excludedTags.some(tag => tags.includes(tag))
|
||||
else if (includedTags.length) return includedTags.some(tag => tags.includes(tag))
|
||||
else if (excludedTags.length) return !excludedTags.some(tag => tags.includes(tag))
|
||||
|
||||
return true
|
||||
},
|
||||
|
||||
component: TagsFilterCard,
|
||||
render: TagsFilterCard,
|
||||
})
|
||||
+6
-2
@@ -53,7 +53,11 @@ export const transferredToProgrammeFilter = createFilter({
|
||||
|
||||
isActive: ({ transferred }) => transferred !== null,
|
||||
|
||||
filter: (student, { transferred }) => student.transferredStudyright === transferred,
|
||||
filter: (student, { options }) => {
|
||||
const { transferred } = options
|
||||
|
||||
return student.transferredStudyright === transferred
|
||||
},
|
||||
|
||||
actions: {
|
||||
set: (options, value) => {
|
||||
@@ -61,5 +65,5 @@ export const transferredToProgrammeFilter = createFilter({
|
||||
},
|
||||
},
|
||||
|
||||
component: TransferredToProgrammeFilterCard,
|
||||
render: TransferredToProgrammeFilterCard,
|
||||
})
|
||||
@@ -1,122 +0,0 @@
|
||||
import { produce } from 'immer'
|
||||
import { find, get, keyBy } from 'lodash'
|
||||
import fp from 'lodash/fp'
|
||||
import { useMemo } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
|
||||
import { selectViewFilters, setFilterOptions, resetFilter, resetViewFilters } from '@/redux/filters'
|
||||
import { FilterTray } from './FilterTray'
|
||||
import { FilterViewContext } from './FilterViewContext'
|
||||
|
||||
const resolveFilterOptions = (store, filters, initialOptions) => {
|
||||
return fp.flow(
|
||||
fp.map(({ key, defaultOptions }) => [
|
||||
key,
|
||||
[store[key]?.options, !store[key] ? get(initialOptions, key) : null, defaultOptions],
|
||||
]),
|
||||
fp.fromPairs,
|
||||
fp.mapValues(values => find(values))
|
||||
)(filters)
|
||||
}
|
||||
|
||||
export const FilterView = ({
|
||||
children,
|
||||
name,
|
||||
filters: pFilters,
|
||||
students,
|
||||
displayTray: displayTrayProp,
|
||||
initialOptions,
|
||||
}) => {
|
||||
const storeFilterOptions = useSelector(state => selectViewFilters(state, name))
|
||||
const filters = pFilters.map(filter => (typeof filter === 'function' ? filter() : filter))
|
||||
const filtersByKey = keyBy(filters, 'key')
|
||||
const filterOptions = useMemo(
|
||||
() => resolveFilterOptions(storeFilterOptions, filters, initialOptions),
|
||||
[storeFilterOptions, filters, initialOptions]
|
||||
)
|
||||
const orderedFilters = filters.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0))
|
||||
|
||||
const displayTray = displayTrayProp !== undefined ? !!displayTrayProp : true
|
||||
|
||||
const precompute = fp.flow(
|
||||
fp.filter(({ precompute }) => precompute),
|
||||
fp.keyBy('key'),
|
||||
fp.mapValues(({ precompute, key }) =>
|
||||
precompute({
|
||||
students,
|
||||
options: filterOptions[key] ?? filtersByKey[key].defaultOptions,
|
||||
precomputed: null,
|
||||
args: filtersByKey[key].args,
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const precomputed = useMemo(() => precompute(orderedFilters), [precompute, orderedFilters])
|
||||
|
||||
const getFilterContext = key => {
|
||||
const filter = filtersByKey[key]
|
||||
|
||||
if (filter === undefined) {
|
||||
return { students, options: null, precomputed: null, args: null }
|
||||
}
|
||||
|
||||
return {
|
||||
students,
|
||||
options: filterOptions[key],
|
||||
precomputed: precomputed[key],
|
||||
args: filtersByKey[key].args,
|
||||
}
|
||||
}
|
||||
|
||||
const applyFilters = fp.flow(
|
||||
fp.map(filter => [filter, getFilterContext(filter.key)]),
|
||||
fp.filter(([{ key, isActive }, ctx]) => isActive(filterOptions[key], ctx)),
|
||||
fp.reduce((students, [{ filter }, ctx]) => {
|
||||
return students
|
||||
.map(student => {
|
||||
const res = []
|
||||
const newStudent = produce(student, s => {
|
||||
res.push(filter(s, ctx.options, ctx))
|
||||
})
|
||||
res.push(newStudent)
|
||||
return res
|
||||
})
|
||||
.filter(([keep]) => keep)
|
||||
.map(([, student]) => student)
|
||||
}, students)
|
||||
)
|
||||
|
||||
const filteredStudents = useMemo(() => applyFilters(orderedFilters), [applyFilters, orderedFilters])
|
||||
|
||||
const areOptionsDirty = key => !!storeFilterOptions[key]
|
||||
|
||||
const dispatch = useDispatch()
|
||||
|
||||
const value = {
|
||||
viewName: name,
|
||||
getContextByKey: getFilterContext,
|
||||
allStudents: students,
|
||||
filteredStudents,
|
||||
filterOptions,
|
||||
filters,
|
||||
precomputed,
|
||||
areOptionsDirty,
|
||||
withoutFilter: key => applyFilters(orderedFilters.filter(filter => filter.key !== key)),
|
||||
setFilterOptions: (filter, options) => dispatch(setFilterOptions({ view: name, filter, options })),
|
||||
resetFilter: filter => dispatch(resetFilter({ view: name, filter })),
|
||||
resetFilters: () => dispatch(resetViewFilters({ view: name })),
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterViewContext.Provider value={value}>
|
||||
<div style={{ display: 'flex', alignItems: 'start', justifyContent: 'center', gap: '0.75rem' }}>
|
||||
<div style={{ zIndex: 1, alignSelf: 'flex-start', position: 'sticky', top: '1rem' }}>
|
||||
{displayTray && <FilterTray />}
|
||||
</div>
|
||||
<div style={{ flexGrow: 1, minWidth: 0 }}>
|
||||
{typeof children === 'function' ? children(filteredStudents) : children}
|
||||
</div>
|
||||
</div>
|
||||
</FilterViewContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { FC, useMemo } from 'react'
|
||||
|
||||
import { selectViewFilters, setFilterOptions, resetFilter, resetViewFilters } from '@/redux/filters'
|
||||
import { useAppDispatch, useAppSelector } from '@/redux/hooks'
|
||||
import { useGetPopulationStatisticsByCourseQuery } from '@/redux/populations'
|
||||
import { keyBy } from '@oodikone/shared/util'
|
||||
|
||||
import { FilterViewContext } from './context'
|
||||
import type { FilterContext, FilterViewContextState } from './context'
|
||||
|
||||
import type { Filter, FilterFactory } from './filters/createFilter'
|
||||
import { FilterTray } from './FilterTray'
|
||||
|
||||
// TODO: Use acual Student type when available
|
||||
export type Student = ReturnType<typeof useGetPopulationStatisticsByCourseQuery>['data']['students']
|
||||
|
||||
const resolveFilterOptions = <T,>(
|
||||
store: Record<Filter['key'], { options: T }>,
|
||||
filters: Filter[],
|
||||
initialOptions?: Record<Filter['key'], T>
|
||||
): Record<Filter['key'], any> =>
|
||||
Object.fromEntries(
|
||||
filters.map(({ key, defaultOptions }) => [key, store[key]?.options ?? initialOptions?.[key] ?? defaultOptions])
|
||||
)
|
||||
|
||||
export const FilterView: FC<{
|
||||
children: (filteredStudents: Student[]) => any
|
||||
name: string
|
||||
filters: (FilterFactory | Filter)[]
|
||||
students: Student[]
|
||||
displayTray?: boolean
|
||||
initialOptions?: Record<Filter['key'], any>
|
||||
}> = ({ children, name, filters: pFilters, students, displayTray: displayTrayProp, initialOptions }) => {
|
||||
const storeFilterOptions = useAppSelector(state => selectViewFilters(state, name))
|
||||
const filters: Filter[] = pFilters.map(filter => (typeof filter === 'function' ? filter() : filter))
|
||||
const filtersByKey = keyBy(filters, 'key')
|
||||
const filterOptions = useMemo(
|
||||
() => resolveFilterOptions(storeFilterOptions, filters, initialOptions),
|
||||
[storeFilterOptions, filters, initialOptions]
|
||||
)
|
||||
const orderedFilters = filters.sort((a, b) => a.priority - b.priority)
|
||||
|
||||
const displayTray = displayTrayProp === undefined || !!displayTrayProp
|
||||
const precomputed = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
orderedFilters
|
||||
.filter(({ precompute }) => precompute)
|
||||
.map(({ precompute, key }) => [
|
||||
key,
|
||||
precompute!({
|
||||
students,
|
||||
options: filterOptions[key],
|
||||
precomputed: null,
|
||||
args: filtersByKey[key].args,
|
||||
}),
|
||||
])
|
||||
),
|
||||
[orderedFilters]
|
||||
)
|
||||
|
||||
const getFilterContext = (key: string): FilterContext => ({
|
||||
students,
|
||||
options: filterOptions[key] ?? null,
|
||||
precomputed: precomputed[key] ?? null,
|
||||
args: filtersByKey[key]?.args ?? null,
|
||||
})
|
||||
|
||||
const applyFilters = (filters: Filter[]) =>
|
||||
filters
|
||||
.map(filter => ({ filter, ctx: getFilterContext(filter.key) }))
|
||||
.filter(({ filter: { key, isActive } }) => isActive(filterOptions[key]))
|
||||
.reduce((students, { filter: { filter }, ctx }) => {
|
||||
return students
|
||||
.map(student => {
|
||||
const newStudent = structuredClone(student)
|
||||
return filter(newStudent, ctx) ? newStudent : null
|
||||
})
|
||||
.filter(Boolean)
|
||||
}, students)
|
||||
|
||||
const filteredStudents = useMemo(() => applyFilters(orderedFilters), [orderedFilters])
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const value: FilterViewContextState = {
|
||||
viewName: name,
|
||||
allStudents: students,
|
||||
filters,
|
||||
filteredStudents,
|
||||
getContextByKey: getFilterContext,
|
||||
areOptionsDirty: key => !!storeFilterOptions[key],
|
||||
setFilterOptions: (filter, options) => dispatch(setFilterOptions({ view: name, filter, options })),
|
||||
resetFilter: filter => dispatch(resetFilter({ view: name, filter })),
|
||||
resetFilters: () => dispatch(resetViewFilters({ view: name })),
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterViewContext.Provider value={value}>
|
||||
<div style={{ display: 'flex', alignItems: 'start', justifyContent: 'center', gap: '0.75rem' }}>
|
||||
<div style={{ zIndex: 1, alignSelf: 'flex-start', position: 'sticky', top: '1rem' }}>
|
||||
{displayTray && <FilterTray />}
|
||||
</div>
|
||||
<div style={{ flexGrow: 1, minWidth: 0 }}>
|
||||
{typeof children === 'function' ? children(filteredStudents) : children}
|
||||
</div>
|
||||
</div>
|
||||
</FilterViewContext.Provider>
|
||||
)
|
||||
}
|
||||
+5
-5
@@ -1,10 +1,10 @@
|
||||
import { useContext } from 'react'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { FilterViewContext } from './FilterViewContext'
|
||||
import { useAppDispatch } from '@/redux/hooks'
|
||||
import { FilterViewContext } from './context'
|
||||
|
||||
export const useFilters = () => {
|
||||
const dispatch = useDispatch()
|
||||
const { getContextByKey, viewName, filterOptions } = useContext(FilterViewContext)
|
||||
const dispatch = useAppDispatch()
|
||||
const { getContextByKey, viewName } = useContext(FilterViewContext)
|
||||
|
||||
const filterDispatch = filterAction => {
|
||||
dispatch({
|
||||
@@ -14,7 +14,7 @@ export const useFilters = () => {
|
||||
}
|
||||
|
||||
const useFilterSelector = filterSelector => {
|
||||
const options = filterOptions[filterSelector.filter]
|
||||
const { options } = getContextByKey(filterSelector.filter)
|
||||
return filterSelector(options)
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,13 +1,13 @@
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { Tab } from 'semantic-ui-react'
|
||||
|
||||
import { clearCourseStats } from '@/redux/courseStats'
|
||||
import { useAppDispatch } from '@/redux/hooks'
|
||||
import { GradeDistribution } from './GradeDistribution'
|
||||
import { PassFailEnrollments } from './PassFailEnrollments'
|
||||
import { PopulationCourseContext } from './PopulationCourseContext'
|
||||
|
||||
export const PopulationCourseStatsFlat = ({ courses, studentAmountLimit }) => {
|
||||
const dispatch = useDispatch()
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
if (!courses) return null
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { orderBy } from 'lodash'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { Tab } from 'semantic-ui-react'
|
||||
|
||||
import { useTabChangeAnalytics } from '@/hooks/tabChangeAnalytics'
|
||||
import { clearCourseStats } from '@/redux/courseStats'
|
||||
import { useAppDispatch } from '@/redux/hooks'
|
||||
import { GradeDistribution } from './GradeDistribution'
|
||||
import { PassFailEnrollments } from './PassFailEnrollments'
|
||||
import { PassingSemesters } from './PassingSemesters'
|
||||
@@ -17,9 +17,8 @@ const visibleCoursesFilter = ({ course }, mandatoryCourses) =>
|
||||
mandatoryCourses.secondProgrammeCourses?.some(
|
||||
programmeCourse => programmeCourse.code === course.code && programmeCourse.visible.visibility
|
||||
)
|
||||
|
||||
export const PopulationCourseStats = ({ mandatoryCourses, courses, pending, onlyIamRights }) => {
|
||||
const dispatch = useDispatch()
|
||||
const dispatch = useAppDispatch()
|
||||
const [modules, setModules] = useState([])
|
||||
const [expandedGroups, setExpandedGroups] = useState(new Set())
|
||||
const { handleTabChange } = useTabChangeAnalytics()
|
||||
|
||||
+2
-2
@@ -3,7 +3,6 @@ import exportData from 'highcharts/modules/export-data'
|
||||
import exporting from 'highcharts/modules/exporting'
|
||||
import { chain, range, sortBy } from 'lodash'
|
||||
import moment from 'moment'
|
||||
import qs from 'query-string'
|
||||
import { useState } from 'react'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
import { useLocation } from 'react-router'
|
||||
@@ -16,6 +15,7 @@ import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { useDeepMemo } from '@/hooks/deepMemo'
|
||||
import { useGetSemestersQuery } from '@/redux/semesters'
|
||||
import { generateGradientColors } from '@/util/color'
|
||||
import { parseQueryParams } from '@/util/queryparams'
|
||||
|
||||
exporting(ReactHighcharts.Highcharts)
|
||||
exportData(ReactHighcharts.Highcharts)
|
||||
@@ -148,7 +148,7 @@ export const CreditDistributionDevelopment = ({ students, programme, combinedPro
|
||||
const [timeDivision, setTimeDivision] = useState(TimeDivision.SEMESTER)
|
||||
const [stackOrdering, setStackOrdering] = useState(StackOrdering.DESCENDING)
|
||||
const location = useLocation()
|
||||
const { months } = qs.parse(location.search)
|
||||
const { months } = parseQueryParams(location.search)
|
||||
const { data: semestersAndYears = {} } = useGetSemestersQuery()
|
||||
const { semesters = {} } = semestersAndYears
|
||||
const { getTextIn } = useLanguage()
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@ import { CollapsibleCreditRow } from './CollapsibleCreditRow'
|
||||
|
||||
export const CreditsGainedTable = ({ filteredStudents, programmeGoalTime, type, year }) => {
|
||||
const { useFilterSelector } = useFilters()
|
||||
const creditDateFilterOptions = useFilterSelector(creditDateFilter.selectors.selectOptions)
|
||||
const studyPlanFilterIsActive = useFilterSelector(studyPlanFilter.selectors.isActive)
|
||||
const creditDateFilterOptions = useFilterSelector(creditDateFilter.selectors.selectOptions())
|
||||
const studyPlanFilterIsActive = useFilterSelector(studyPlanFilter.selectors.isActive())
|
||||
|
||||
if (!filteredStudents || !filteredStudents.length || !type) return null
|
||||
let creditList = []
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { useFilters } from '@/components/FilterView/useFilters'
|
||||
export const ExternalCreditFilterToggle = ({ students, helpText }) => {
|
||||
const { filterDispatch, useFilterSelector } = useFilters()
|
||||
const currentFilterIsActive = useFilterSelector(studentNumberFilter.selectors.studentListIsEqualToAllowlist(students))
|
||||
const someFilterIsActive = useFilterSelector(studentNumberFilter.selectors.isActive)
|
||||
const someFilterIsActive = useFilterSelector(studentNumberFilter.selectors.isActive())
|
||||
|
||||
return (
|
||||
<FilterToggle
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { isEqual } from 'lodash'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useSelector, useDispatch } from 'react-redux'
|
||||
import { Segment } from 'semantic-ui-react'
|
||||
|
||||
import { populationStatisticsToolTips } from '@/common/InfoToolTips'
|
||||
@@ -9,22 +8,37 @@ import { PopulationCourseStats } from '@/components/PopulationCourseStats'
|
||||
import { PopulationCourseStatsFlat } from '@/components/PopulationCourseStats/PopulationCourseStatsFlat'
|
||||
import { SegmentDimmer } from '@/components/SegmentDimmer'
|
||||
import { useDeepMemo } from '@/hooks/deepMemo'
|
||||
import { useAppSelector, useAppDispatch } from '@/redux/hooks'
|
||||
import { getPopulationSelectedStudentCourses } from '@/redux/populationSelectedStudentCourses'
|
||||
import { FilterDegreeCoursesModal } from './FilterDegreeCoursesModal'
|
||||
|
||||
const mapStudentNumbersToStartingYears = samples => {
|
||||
const years = [...new Set(samples.map(student => new Date(student.studyrightStart).getFullYear()))]
|
||||
const studentsToYears = Object.fromEntries(years.map(y => [y, []]))
|
||||
|
||||
samples.forEach(student => {
|
||||
studentsToYears[new Date(student.studyrightStart).getFullYear()].push(student.studentNumber)
|
||||
})
|
||||
|
||||
return studentsToYears
|
||||
}
|
||||
|
||||
export const PopulationCourses = ({
|
||||
query = {},
|
||||
query,
|
||||
filteredStudents,
|
||||
selectedStudentsByYear,
|
||||
onlyIamRights,
|
||||
curriculum: mandatoryCourses,
|
||||
courseTableMode,
|
||||
studentAmountLimit,
|
||||
}) => {
|
||||
const dispatch = useDispatch()
|
||||
const populationSelectedStudentCourses = useSelector(
|
||||
({ populationSelectedStudentCourses }) => populationSelectedStudentCourses
|
||||
)
|
||||
const dispatch = useAppDispatch()
|
||||
const selectedStudentsByYear = mapStudentNumbersToStartingYears(filteredStudents ?? [])
|
||||
|
||||
const {
|
||||
query: courseQuery,
|
||||
data,
|
||||
pending,
|
||||
} = useAppSelector(({ populationSelectedStudentCourses }) => populationSelectedStudentCourses)
|
||||
|
||||
const fetch = useCallback(
|
||||
courses => {
|
||||
@@ -53,10 +67,10 @@ export const PopulationCourses = ({
|
||||
}, [mandatoryCourses])
|
||||
|
||||
useEffect(() => {
|
||||
if (programmeCourses == null || populationSelectedStudentCourses?.query == null) {
|
||||
if (programmeCourses == null || courseQuery == null) {
|
||||
return
|
||||
}
|
||||
const { courses, selectedStudents } = populationSelectedStudentCourses.query
|
||||
const { courses, selectedStudents } = courseQuery
|
||||
if (
|
||||
!isEqual(programmeCourses, courses) ||
|
||||
selectedStudents.length !== filteredStudents.length ||
|
||||
@@ -67,9 +81,9 @@ export const PopulationCourses = ({
|
||||
) {
|
||||
fetch(programmeCourses)
|
||||
}
|
||||
}, [programmeCourses, filteredStudents, populationSelectedStudentCourses.query, onlyIamRights, fetch])
|
||||
}, [programmeCourses, filteredStudents, courseQuery, onlyIamRights, fetch])
|
||||
|
||||
const pending = populationSelectedStudentCourses.pending || !mandatoryCourses
|
||||
const isPending = pending || !mandatoryCourses
|
||||
|
||||
return (
|
||||
<Segment basic>
|
||||
@@ -89,18 +103,18 @@ export const PopulationCourses = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SegmentDimmer isLoading={pending} />
|
||||
<SegmentDimmer isLoading={isPending} />
|
||||
{courseTableMode === 'curriculum' ? (
|
||||
<PopulationCourseStats
|
||||
courses={populationSelectedStudentCourses.data ?? []}
|
||||
key={populationSelectedStudentCourses.query.uuid}
|
||||
courses={data ?? []}
|
||||
key={courseQuery?.uuid}
|
||||
mandatoryCourses={mandatoryCourses}
|
||||
onlyIamRights={onlyIamRights}
|
||||
pending={pending}
|
||||
pending={isPending}
|
||||
/>
|
||||
) : (
|
||||
<PopulationCourseStatsFlat
|
||||
courses={pending ? null : (populationSelectedStudentCourses.data ?? [])}
|
||||
courses={isPending ? null : (data ?? [])}
|
||||
filteredStudents={filteredStudents}
|
||||
studentAmountLimit={studentAmountLimit}
|
||||
/>
|
||||
|
||||
@@ -15,36 +15,26 @@ import { CourseTableModeSelector } from './CourseTableModeSelector'
|
||||
import { CreditGainStats } from './CreditGainStats'
|
||||
import { PopulationCourses } from './PopulationCourses'
|
||||
|
||||
export const PopulationDetails = ({
|
||||
filteredStudents,
|
||||
isLoading,
|
||||
programmeCodes,
|
||||
query,
|
||||
queryIsSet,
|
||||
selectedStudentsByYear,
|
||||
}) => {
|
||||
export const PopulationDetails = ({ filteredStudents, isLoading, programmeCodes, query }) => {
|
||||
const { isLoading: authLoading, programmeRights, fullAccessToStudentData } = useGetAuthorizedUserQuery()
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
const { useFilterSelector } = useFilters()
|
||||
const [studentAmountLimit, setStudentAmountLimit] = useState(0)
|
||||
const [curriculum, setCurriculum] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
setStudentAmountLimit(Math.round(filteredStudents.length ? filteredStudents.length * 0.3 : 0))
|
||||
}, [filteredStudents.length])
|
||||
const filteredStudentsLength = filteredStudents?.length ?? 0
|
||||
useEffect(() => setStudentAmountLimit(filteredStudentsLength * 0.3), [filteredStudentsLength])
|
||||
|
||||
const criteria = useGetProgressCriteriaQuery(
|
||||
{ programmeCode: query?.studyRights?.programme },
|
||||
{ skip: !query?.studyRights?.programme }
|
||||
)
|
||||
const programme = programmeCodes[0]
|
||||
const criteria = useGetProgressCriteriaQuery({ programmeCode: programme }, { skip: !programme })
|
||||
const [courseTableMode, setCourseTableMode] = useState('curriculum')
|
||||
const studyPlanFilterIsActive = useFilterSelector(studyPlanFilter.selectors.isActive)
|
||||
const studyPlanFilterIsActive = useFilterSelector(studyPlanFilter.selectors.isActive())
|
||||
|
||||
const onStudentAmountLimitChange = value => {
|
||||
setStudentAmountLimit(Number.isNaN(Number(value)) ? studentAmountLimit : Number(value))
|
||||
}
|
||||
|
||||
if (isLoading || !queryIsSet) {
|
||||
if (isLoading || !Object.keys(query).length) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -62,7 +52,7 @@ export const PopulationDetails = ({
|
||||
<InfoBox content={populationStatisticsToolTips.creditAccumulation} />
|
||||
{filteredStudents.length > 0 && (
|
||||
<CreditAccumulationGraphHighCharts
|
||||
programmeCodes={programmeCodes}
|
||||
programmeCodes={programmeCodes.filter(Boolean)}
|
||||
showBachelorAndMaster={query?.showBachelorAndMaster === 'true'}
|
||||
students={filteredStudents}
|
||||
studyPlanFilterIsActive={studyPlanFilterIsActive}
|
||||
@@ -95,7 +85,7 @@ export const PopulationDetails = ({
|
||||
setCurriculum={setCurriculum}
|
||||
setStudentAmountLimit={setStudentAmountLimit}
|
||||
studentAmountLimit={studentAmountLimit}
|
||||
studyProgramme={query?.studyRights?.programme}
|
||||
studyProgramme={programme}
|
||||
year={query?.year}
|
||||
/>
|
||||
<PopulationCourses
|
||||
@@ -104,31 +94,33 @@ export const PopulationDetails = ({
|
||||
filteredStudents={filteredStudents}
|
||||
onlyIamRights={onlyIamRights}
|
||||
query={query}
|
||||
selectedStudentsByYear={selectedStudentsByYear}
|
||||
studentAmountLimit={studentAmountLimit}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
alwaysRender: true,
|
||||
},
|
||||
]
|
||||
!onlyIamRights
|
||||
? {
|
||||
title: `Students (${filteredStudents.length})`,
|
||||
content: (
|
||||
<div>
|
||||
<PopulationStudents
|
||||
criteria={criteria?.data}
|
||||
curriculum={curriculum}
|
||||
filteredStudents={filteredStudents}
|
||||
months={query?.months ?? 0}
|
||||
programmeCode={programme}
|
||||
showBachelorAndMaster={query?.showBachelorAndMaster}
|
||||
studyRights={query?.studyRights}
|
||||
variant="population"
|
||||
year={query?.year}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
: null,
|
||||
].filter(item => !!item)
|
||||
|
||||
if (!onlyIamRights) {
|
||||
panels.push({
|
||||
title: `Students (${filteredStudents.length})`,
|
||||
content: (
|
||||
<div>
|
||||
<PopulationStudents
|
||||
criteria={criteria?.data}
|
||||
curriculum={curriculum}
|
||||
filteredStudents={filteredStudents}
|
||||
programmeCode={query?.studyRights?.programme}
|
||||
variant="population"
|
||||
year={query?.year}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
}
|
||||
return <PanelView panels={panels} viewTitle="classstatistics" />
|
||||
}
|
||||
|
||||
@@ -2,16 +2,19 @@ import { minBy } from 'lodash'
|
||||
import { Card } from 'semantic-ui-react'
|
||||
|
||||
import { DISPLAY_DATE_FORMAT } from '@/constants/date'
|
||||
import { useGetPopulationStatisticsQuery } from '@/redux/populations'
|
||||
import { useGetTagsByStudyTrackQuery } from '@/redux/tags'
|
||||
import { reformatDate } from '@/util/timeAndDate'
|
||||
|
||||
export const PopulationQueryCard = ({ population, query }) => {
|
||||
export const PopulationQueryCard = ({ query, skipQuery }) => {
|
||||
const { studentStatuses, tag } = query
|
||||
const { data: population } = useGetPopulationStatisticsQuery(query, { skip: skipQuery })
|
||||
const students = population?.students ?? []
|
||||
|
||||
const { data: tags = [] } = useGetTagsByStudyTrackQuery(query?.studyRights?.programme, {
|
||||
skip: !query?.studyRights?.programme,
|
||||
})
|
||||
const tagName = tag ? tags.find(currentTag => currentTag.id === tag)?.name : ''
|
||||
const { students } = population
|
||||
|
||||
if (!students.length) return null
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Stack } from '@mui/material'
|
||||
import { isEqual, sortBy } from 'lodash'
|
||||
import Stack from '@mui/material/Stack'
|
||||
|
||||
import { sortBy } from 'lodash'
|
||||
import moment from 'moment'
|
||||
import qs from 'query-string'
|
||||
import { useEffect, useState } from 'react'
|
||||
import Datetime from 'react-datetime'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { useNavigate, useLocation } from 'react-router'
|
||||
import { Button, Form, Message, Radio } from 'semantic-ui-react'
|
||||
|
||||
@@ -17,34 +16,28 @@ import { YEAR_DATE_FORMAT } from '@/constants/date'
|
||||
import { useDegreeProgrammeTypes } from '@/hooks/degreeProgrammeTypes'
|
||||
import { useSearchHistory } from '@/hooks/searchHistory'
|
||||
import { useGetAuthorizedUserQuery } from '@/redux/auth'
|
||||
import { getPopulationStatistics, clearPopulations, useGetProgrammesQuery } from '@/redux/populations'
|
||||
import { clearSelected } from '@/redux/populationSelectedStudentCourses'
|
||||
import { useGetProgrammesQuery } from '@/redux/populations'
|
||||
import { useGetStudyTracksQuery } from '@/redux/studyProgramme'
|
||||
import { useGetStudyProgrammePinsQuery } from '@/redux/studyProgrammePins'
|
||||
import { createPinnedFirstComparator } from '@/util/comparator'
|
||||
import { queryParamsToString } from '@/util/queryparams'
|
||||
import { momentFromFormat, reformatDate } from '@/util/timeAndDate'
|
||||
import { formatQueryParamsToArrays } from '@oodikone/shared/util'
|
||||
import { getMonths } from './common'
|
||||
import './populationSearch.css'
|
||||
|
||||
const initialQuery = () => ({
|
||||
year: '2017',
|
||||
semesters: ['FALL', 'SPRING'],
|
||||
studentStatuses: [],
|
||||
studyRights: {},
|
||||
months: getMonths('2017', 'FALL'),
|
||||
showBachelorAndMaster: false,
|
||||
})
|
||||
|
||||
export const PopulationSearchForm = ({ onProgress }) => {
|
||||
export const PopulationSearchForm = () => {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const dispatch = useDispatch()
|
||||
const populations = useSelector(state => state.populations)
|
||||
const previousQuery = populations.query || {}
|
||||
const { getTextIn } = useLanguage()
|
||||
const { fullAccessToStudentData } = useGetAuthorizedUserQuery()
|
||||
const [query, setQuery] = useState(initialQuery())
|
||||
const [query, setQuery] = useState({
|
||||
year: '2017',
|
||||
semesters: ['FALL', 'SPRING'],
|
||||
studentStatuses: [],
|
||||
studyRights: {},
|
||||
months: getMonths('2017', 'FALL'),
|
||||
showBachelorAndMaster: false,
|
||||
})
|
||||
const [showBachelorAndMaster, setShowBachelorAndMaster] = useState(false)
|
||||
const [searchHistory, addItemToSearchHistory, updateItemInSearchHistory] = useSearchHistory('populationSearch', 8)
|
||||
const [filterProgrammes, setFilterProgrammes] = useState(fullAccessToStudentData)
|
||||
@@ -76,61 +69,6 @@ export const PopulationSearchForm = ({ onProgress }) => {
|
||||
'urn:code:degree-program-type:masters-degree',
|
||||
].includes(degreeProgrammeType[query.studyRights.programme])
|
||||
|
||||
const parseQueryFromUrl = () => {
|
||||
const initial = initialQuery()
|
||||
const { studyRights, months, ...rest } = qs.parse(location.search)
|
||||
const query = {
|
||||
...initial,
|
||||
...rest,
|
||||
studyRights: JSON.parse(studyRights),
|
||||
months: JSON.parse(months),
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
const checkPreviousQuery = (query, previousQuery) => {
|
||||
const sameMonths = query.months === previousQuery.months
|
||||
const sameYear = query.year === previousQuery.year
|
||||
const sameSemesters = isEqual(previousQuery.semesters, query.semesters)
|
||||
const sameStudentStatuses = isEqual(previousQuery.studentStatuses, query.studentStatuses)
|
||||
const sameYears = isEqual(previousQuery.years, query.years)
|
||||
const sameStudyRights = isEqual(previousQuery.studyRights, query.studyRights)
|
||||
const sameTag = query.tag === previousQuery.tag
|
||||
return sameStudyRights && sameMonths && sameYear && sameSemesters && sameStudentStatuses && sameYears && sameTag
|
||||
}
|
||||
|
||||
const fetchPopulation = async query => {
|
||||
const uuid = crypto.randomUUID()
|
||||
dispatch(clearSelected())
|
||||
dispatch(getPopulationStatistics({ ...query, uuid, onProgress }))
|
||||
}
|
||||
|
||||
// HACK: This function doesn't work as intended
|
||||
const fetchPopulationFromUrlParams = () => {
|
||||
const query = parseQueryFromUrl()
|
||||
const formattedQuery = formatQueryParamsToArrays(query, ['semesters', 'studentStatuses', 'years'])
|
||||
if (!checkPreviousQuery(formattedQuery, previousQuery)) {
|
||||
setQuery(query)
|
||||
fetchPopulation(formattedQuery)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!studyProgrammes || Object.values(studyProgrammes).length === 0) {
|
||||
setQuery(initialQuery())
|
||||
}
|
||||
if (location.search) {
|
||||
fetchPopulationFromUrlParams()
|
||||
}
|
||||
if (!location.search) {
|
||||
setQuery({
|
||||
...query,
|
||||
studentStatuses: [],
|
||||
semesters: ['FALL', 'SPRING'],
|
||||
})
|
||||
}
|
||||
}, [location.search])
|
||||
|
||||
const handleProgrammeChange = (_event, { value: programme }) => {
|
||||
setQuery({
|
||||
...query,
|
||||
@@ -156,22 +94,16 @@ export const PopulationSearchForm = ({ onProgress }) => {
|
||||
}, [])
|
||||
|
||||
const pushQueryToUrl = query => {
|
||||
if (!checkPreviousQuery(query, previousQuery)) {
|
||||
dispatch(clearPopulations())
|
||||
}
|
||||
// Just to be sure that the previous population's data has been cleared
|
||||
setTimeout(() => {
|
||||
const { studyRights, ...rest } = query
|
||||
studyRights.combinedProgramme =
|
||||
studyRights.programme && studyRights.programme.includes('+') ? studyRights.programme.split('+')[1] : ''
|
||||
studyRights.programme =
|
||||
studyRights.programme && studyRights.programme.includes('+')
|
||||
? studyRights.programme.split('+')[0]
|
||||
: studyRights.programme
|
||||
const queryObject = { ...rest, studyRights: JSON.stringify(studyRights) }
|
||||
const searchString = qs.stringify(queryObject)
|
||||
navigate({ search: searchString })
|
||||
}, 0)
|
||||
const { studyRights, ...rest } = query
|
||||
|
||||
const [programme, combinedProgramme] = studyRights.programme.split('+')
|
||||
studyRights.programme = programme
|
||||
studyRights.combinedProgramme = combinedProgramme ?? ''
|
||||
|
||||
const queryObject = { ...rest, studyRights: JSON.stringify(studyRights) }
|
||||
const searchString = queryParamsToString(queryObject)
|
||||
|
||||
navigate({ search: searchString })
|
||||
}
|
||||
|
||||
const getSearchHistoryTextFromQuery = () => {
|
||||
@@ -198,6 +130,7 @@ export const PopulationSearchForm = ({ onProgress }) => {
|
||||
text: getSearchHistoryTextFromQuery(),
|
||||
params: query,
|
||||
})
|
||||
|
||||
pushQueryToUrl(query)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +1,32 @@
|
||||
import qs from 'query-string'
|
||||
import { useState } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { Button, Form } from 'semantic-ui-react'
|
||||
|
||||
import { populationStatisticsToolTips } from '@/common/InfoToolTips'
|
||||
import { InfoBox } from '@/components/InfoBox'
|
||||
import { PopulationQueryCard } from '@/components/PopulationQueryCard'
|
||||
import { queryParamsToString } from '@/util/queryparams'
|
||||
import { formatToArray } from '@oodikone/shared/util'
|
||||
import { getMonths } from './common'
|
||||
import { FilterActiveNote } from './FilterActiveNote'
|
||||
import './populationSearch.css'
|
||||
|
||||
export const PopulationSearchHistory = () => {
|
||||
export const PopulationSearchHistory = ({ query, skipQuery }) => {
|
||||
const navigate = useNavigate()
|
||||
const populations = useSelector(state => state.populations)
|
||||
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false)
|
||||
const [semesters, setSemesters] = useState(populations.query?.semesters || ['FALL', 'SPRING'])
|
||||
const [studentStatuses, setStudentStatus] = useState(populations.query?.studentStatuses || [])
|
||||
const [months, setMonths] = useState(populations.query?.months || 0)
|
||||
const [semesters, setSemesters] = useState(
|
||||
query.semesters.length ? formatToArray(query.semesters) : ['FALL', 'SPRING']
|
||||
)
|
||||
const [studentStatuses, setStudentStatus] = useState(query.studentStatuses)
|
||||
const [months, setMonths] = useState(query.months ?? 0)
|
||||
|
||||
const handleSemesterSelection = (_event, { value }) => {
|
||||
const newSemesters = semesters.includes(value)
|
||||
? semesters.filter(semester => semester !== value)
|
||||
: [...semesters, value]
|
||||
if (!populations.query.tag) {
|
||||
if (!query.tag) {
|
||||
setSemesters(newSemesters)
|
||||
setMonths(getMonths(populations.query.year, semesters.includes('FALL') ? 'FALL' : 'SPRING'))
|
||||
setMonths(getMonths(query.year, semesters.includes('FALL') ? 'FALL' : 'SPRING'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +37,7 @@ export const PopulationSearchHistory = () => {
|
||||
}
|
||||
|
||||
const pushQueryToUrl = () => {
|
||||
const { studyRights, tag, year } = populations.query
|
||||
const { studyRights, tag, year } = query
|
||||
|
||||
const queryObject = {
|
||||
tag,
|
||||
@@ -46,7 +47,7 @@ export const PopulationSearchHistory = () => {
|
||||
semesters,
|
||||
studyRights: JSON.stringify(studyRights),
|
||||
}
|
||||
const searchString = qs.stringify(queryObject)
|
||||
const searchString = queryParamsToString(queryObject)
|
||||
navigate({ search: searchString })
|
||||
}
|
||||
|
||||
@@ -57,7 +58,7 @@ export const PopulationSearchHistory = () => {
|
||||
|
||||
return (
|
||||
<Form.Group style={{ flexDirection: 'column' }}>
|
||||
{!populations.query.tag ? (
|
||||
{!query.tag && (
|
||||
<Form.Field style={{ marginTop: '15px' }}>
|
||||
<b>Starting semesters</b>
|
||||
<Form.Checkbox
|
||||
@@ -81,7 +82,7 @@ export const PopulationSearchHistory = () => {
|
||||
value="SPRING"
|
||||
/>
|
||||
</Form.Field>
|
||||
) : null}
|
||||
)}
|
||||
<Form.Field style={{ marginTop: '15px' }}>
|
||||
<b>Include</b>
|
||||
<Form.Checkbox
|
||||
@@ -122,7 +123,7 @@ export const PopulationSearchHistory = () => {
|
||||
}
|
||||
|
||||
const renderQueryCards = () => {
|
||||
if (!populations.query || !populations.data.students) {
|
||||
if (!Object.keys(query).length) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -130,13 +131,13 @@ export const PopulationSearchHistory = () => {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'row', width: '100%' }}>
|
||||
<div>
|
||||
<PopulationQueryCard population={populations.data} query={populations.query} />
|
||||
<PopulationQueryCard query={query} skipQuery={skipQuery} />
|
||||
<div style={{ marginLeft: '5px', marginTop: '15px' }}>
|
||||
<InfoBox content={populationStatisticsToolTips.advanced} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginLeft: '100px' }}>
|
||||
{populations.query.year !== 'All' && (
|
||||
{query.year !== 'All' && (
|
||||
<Form.Group>
|
||||
<Form.Field>
|
||||
<Form.Radio
|
||||
|
||||
@@ -1,39 +1,30 @@
|
||||
import { useSelector } from 'react-redux'
|
||||
import { Link, useLocation } from 'react-router'
|
||||
import { Link } from 'react-router'
|
||||
import { Button, Divider, Form, Header, Icon, Segment } from 'semantic-ui-react'
|
||||
|
||||
import { populationStatisticsToolTips } from '@/common/InfoToolTips'
|
||||
import { hopsFilter } from '@/components/FilterView/filters'
|
||||
import { useFilters } from '@/components/FilterView/useFilters'
|
||||
import { InfoBox } from '@/components/InfoBox'
|
||||
import { ProgressBar } from '@/components/ProgressBar'
|
||||
import { useProgress } from '@/hooks/progress'
|
||||
import { PopulationSearchForm } from './PopulationSearchForm'
|
||||
import { PopulationSearchHistory } from './PopulationSearchHistory'
|
||||
|
||||
export const PopulationSearch = ({ combinedProgrammeCode }) => {
|
||||
const location = useLocation()
|
||||
const populations = useSelector(state => state.populations)
|
||||
const populationFound = populations.data.students !== undefined
|
||||
const loading = !!populations.pending
|
||||
const { onProgress, progress } = useProgress(loading)
|
||||
export const PopulationSearch = ({ query, skipQuery, isLoading, populationFound, combinedProgrammeCode }) => {
|
||||
const { filterDispatch, useFilterSelector } = useFilters()
|
||||
const onlyHopsCredit = useFilterSelector(hopsFilter.selectors.isActive)
|
||||
const onlyHopsCredit = useFilterSelector(hopsFilter.selectors.isActive())
|
||||
const combinedHopsSelected = useFilterSelector(hopsFilter.selectors.isCombinedSelected(combinedProgrammeCode))
|
||||
const bothHopsSelected = useFilterSelector(hopsFilter.selectors.isBothSelected(combinedProgrammeCode))
|
||||
const title = populationFound && location.search ? null : 'Search for class'
|
||||
|
||||
return (
|
||||
<Segment>
|
||||
{title && <Header size="medium">{title}</Header>}
|
||||
{skipQuery && <Header size="medium">{'Search for class'}</Header>}
|
||||
{!populationFound && (
|
||||
<>
|
||||
<InfoBox content={populationStatisticsToolTips.search} cypress="PopulationSearch" />
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
<PopulationSearchForm onProgress={onProgress} />
|
||||
{location.search !== '' && !loading && (
|
||||
<PopulationSearchForm />
|
||||
{!skipQuery && !isLoading && (
|
||||
<Form>
|
||||
<Form.Field>
|
||||
<Link to="/populations">
|
||||
@@ -65,10 +56,9 @@ export const PopulationSearch = ({ combinedProgrammeCode }) => {
|
||||
/>
|
||||
</Form.Field>
|
||||
)}
|
||||
<PopulationSearchHistory />
|
||||
<PopulationSearchHistory query={query} skipQuery={skipQuery} />
|
||||
</Form>
|
||||
)}
|
||||
<ProgressBar progress={progress} />
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { useLocation } from 'react-router'
|
||||
import { Header, Message, Segment } from 'semantic-ui-react'
|
||||
|
||||
import { getStudentTotalCredits } from '@/common'
|
||||
import { FilterView } from '@/components/FilterView'
|
||||
import {
|
||||
admissionTypeFilter,
|
||||
ageFilter,
|
||||
citizenshipFilter,
|
||||
courseFilter,
|
||||
creditDateFilter,
|
||||
creditsEarnedFilter,
|
||||
curriculumPeriodFilter,
|
||||
enrollmentStatusFilter,
|
||||
genderFilter,
|
||||
graduatedFromProgrammeFilter,
|
||||
hopsFilter,
|
||||
startYearAtUniFilter,
|
||||
studentNumberFilter,
|
||||
studyRightStatusFilter,
|
||||
studyRightTypeFilter,
|
||||
studyTrackFilter,
|
||||
tagsFilter,
|
||||
transferredToProgrammeFilter,
|
||||
} from '@/components/FilterView/filters'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { PopulationDetails } from '@/components/PopulationDetails'
|
||||
import { PopulationSearch } from '@/components/PopulationSearch'
|
||||
import { useCurrentSemester } from '@/hooks/currentSemester'
|
||||
import { useDegreeProgrammeTypes } from '@/hooks/degreeProgrammeTypes'
|
||||
import { useTitle } from '@/hooks/title'
|
||||
import { useGetAuthorizedUserQuery } from '@/redux/auth'
|
||||
import { useGetProgrammesQuery } from '@/redux/populations'
|
||||
import { useGetSemestersQuery } from '@/redux/semesters'
|
||||
import { makePopulationsToData } from '@/selectors/populationDetails'
|
||||
import { getFullStudyProgrammeRights } from '@/util/access'
|
||||
import { getCombinedProgrammeName } from '@/util/combinedProgramme'
|
||||
|
||||
const getYearText = year => {
|
||||
if (year === 'All') return ''
|
||||
return `${year} - ${Number(year) + 1}`
|
||||
}
|
||||
|
||||
export const PopulationStatistics = () => {
|
||||
const location = useLocation()
|
||||
const { language, getTextIn } = useLanguage()
|
||||
const courses = useSelector(store => store.populationSelectedStudentCourses.data?.coursestatistics)
|
||||
const { query, queryIsSet, isLoading, selectedStudentsByYear, samples } = useSelector(makePopulationsToData)
|
||||
const currentSemester = useCurrentSemester()
|
||||
|
||||
const { fullAccessToStudentData, programmeRights } = useGetAuthorizedUserQuery()
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
const onlyIamRights = !fullAccessToStudentData && fullStudyProgrammeRights.length === 0
|
||||
useTitle('Class statistics')
|
||||
|
||||
const { data: allSemesters } = useGetSemestersQuery()
|
||||
const programmeCode = query?.studyRights?.programme
|
||||
const combinedProgrammeCode = query?.studyRights?.combinedProgramme ? query?.studyRights?.combinedProgramme : ''
|
||||
const { data: studyProgrammes } = useGetProgrammesQuery()
|
||||
const programmes = {
|
||||
...studyProgrammes,
|
||||
'KH90_001+MH90_001': {
|
||||
code: 'KH90_001+MH90_001',
|
||||
name: {
|
||||
fi: 'Eläinlääketieteen kandiohjelma ja lisensiaatin koulutusohjelma',
|
||||
en: "Bachelor's and Degree Programme in Vetenary Medicine",
|
||||
sv: 'Kandidats- och Utbildningsprogrammet i veterinärmedicin',
|
||||
},
|
||||
},
|
||||
}
|
||||
const programmeName = programmes && programmeCode ? programmes[programmeCode]?.name : ''
|
||||
const combinedProgrammeName = programmes && combinedProgrammeCode ? programmes[combinedProgrammeCode]?.name : ''
|
||||
const showBachelorAndMaster = query?.showBachelorAndMaster === 'true' || combinedProgrammeCode !== ''
|
||||
|
||||
const filters = [
|
||||
!onlyIamRights ? ageFilter : null,
|
||||
citizenshipFilter,
|
||||
courseFilter({ courses }),
|
||||
creditDateFilter,
|
||||
creditsEarnedFilter,
|
||||
curriculumPeriodFilter,
|
||||
enrollmentStatusFilter({
|
||||
allSemesters: allSemesters?.semesters ?? [],
|
||||
programme: programmeCode,
|
||||
}),
|
||||
genderFilter,
|
||||
graduatedFromProgrammeFilter({
|
||||
code: programmeCode,
|
||||
combinedProgrammeCode,
|
||||
showBachelorAndMaster,
|
||||
}),
|
||||
hopsFilter({ programmeCode, combinedProgrammeCode }),
|
||||
studentNumberFilter,
|
||||
startYearAtUniFilter,
|
||||
studyTrackFilter({ code: programmeCode }),
|
||||
studyRightStatusFilter({ code: programmeCode, combinedProgrammeCode, currentSemester, showBachelorAndMaster }),
|
||||
tagsFilter,
|
||||
transferredToProgrammeFilter,
|
||||
].filter(Boolean)
|
||||
|
||||
const degreeProgrammeType = useDegreeProgrammeTypes([programmeCode])
|
||||
|
||||
if (programmeCode && degreeProgrammeType[programmeCode] === 'urn:code:degree-program-type:masters-degree') {
|
||||
filters.push(
|
||||
studyRightTypeFilter({
|
||||
programme: programmeCode,
|
||||
year: query?.year,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// For combined programme admission type is the same as they started in bachelor programme
|
||||
if (parseInt(query?.year, 10) >= 2020) {
|
||||
filters.push(
|
||||
admissionTypeFilter({
|
||||
programme: programmeCode,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const programmeCodes = []
|
||||
if (programmeCode) programmeCodes.push(programmeCode)
|
||||
if (combinedProgrammeCode) programmeCodes.push(combinedProgrammeCode)
|
||||
|
||||
const students = useMemo(() => {
|
||||
if (!samples) {
|
||||
return []
|
||||
}
|
||||
return samples.map(student => {
|
||||
const hopsCredits =
|
||||
student.studyplans?.find(plan => plan.programme_code === programmeCode)?.completed_credits ?? 0
|
||||
const hopsCombinedProgrammeCredits =
|
||||
student.studyplans?.find(plan => plan.programme_code === combinedProgrammeCode)?.completed_credits ?? 0
|
||||
const studyrightStartDate = new Date(student.studyrightStart)
|
||||
const courses = student.courses.filter(({ date }) => new Date(date) >= studyrightStartDate)
|
||||
const credits = getStudentTotalCredits({ courses })
|
||||
return {
|
||||
...student,
|
||||
allCredits: student.credits,
|
||||
hopsCredits,
|
||||
hopsCombinedProgrammeCredits,
|
||||
credits,
|
||||
}
|
||||
})
|
||||
}, [samples, programmeCode, combinedProgrammeCode])
|
||||
|
||||
const programmeText =
|
||||
query?.studyRights?.combinedProgramme !== '' && query?.studyRights?.combinedProgramme !== undefined
|
||||
? getCombinedProgrammeName(getTextIn(programmeName), getTextIn(combinedProgrammeName), language)
|
||||
: getTextIn(programmeName)
|
||||
const title = location.search === '' ? 'Class statistics' : `${programmeText} ${getYearText(query?.year)}`
|
||||
|
||||
const noStudentsMessage = () => (
|
||||
<div style={{ maxWidth: '80%' }}>
|
||||
<Message
|
||||
content={`
|
||||
Choose “Advanced settings” below and make sure you have the correct student groups included
|
||||
in the class. For example, if you are looking for students of a specialist training in
|
||||
medicine or dentistry, you must choose “Students with non-degree study right”.`}
|
||||
header="Not seeing any students?"
|
||||
icon="question"
|
||||
info
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<FilterView
|
||||
displayTray={location.search !== ''}
|
||||
filters={filters}
|
||||
initialOptions={{
|
||||
[transferredToProgrammeFilter.key]: {
|
||||
transferred: false,
|
||||
},
|
||||
[hopsFilter.key]: {
|
||||
studyStart: (students[0] || {}).studyrightStart,
|
||||
},
|
||||
[studyTrackFilter.key]: {
|
||||
selected: query?.studyRights?.studyTrack ? [query?.studyRights?.studyTrack] : [],
|
||||
},
|
||||
[tagsFilter.key]: {
|
||||
includedTags: query?.tag ? [query.tag] : [],
|
||||
excludedTags: [],
|
||||
},
|
||||
}}
|
||||
name="PopulationStatistics"
|
||||
students={students}
|
||||
>
|
||||
{filteredStudents => (
|
||||
<div className="segmentContainer" style={{ flexGrow: 1 }}>
|
||||
<Header align="center" className="segmentTitle" size="large">
|
||||
{title} {query?.showBachelorAndMaster === 'true' && ' (Bachelor + Master view)'}
|
||||
{location.search !== '' && query?.studyRights?.studyTrack && (
|
||||
<Header.Subheader>studytrack {query.studyRights.studyTrack}</Header.Subheader>
|
||||
)}
|
||||
{location.search !== '' && (
|
||||
<Header.Subheader>
|
||||
studytime {query?.months} months, class size {students.length} students
|
||||
</Header.Subheader>
|
||||
)}
|
||||
</Header>
|
||||
{students?.length === 0 && location.search !== '' && !isLoading && noStudentsMessage()}
|
||||
<Segment className="contentSegment">
|
||||
<PopulationSearch combinedProgrammeCode={combinedProgrammeCode} />
|
||||
{location.search !== '' && (
|
||||
<PopulationDetails
|
||||
filteredStudents={filteredStudents}
|
||||
isLoading={isLoading}
|
||||
programmeCodes={programmeCodes}
|
||||
query={query}
|
||||
queryIsSet={queryIsSet}
|
||||
selectedStudentsByYear={selectedStudentsByYear}
|
||||
/>
|
||||
)}
|
||||
</Segment>
|
||||
</div>
|
||||
)}
|
||||
</FilterView>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useLocation } from 'react-router'
|
||||
import { Header, Message, Segment } from 'semantic-ui-react'
|
||||
|
||||
import { getStudentTotalCredits } from '@/common'
|
||||
import { FilterView } from '@/components/FilterView'
|
||||
import {
|
||||
admissionTypeFilter,
|
||||
ageFilter,
|
||||
citizenshipFilter,
|
||||
courseFilter,
|
||||
creditDateFilter,
|
||||
creditsEarnedFilter,
|
||||
curriculumPeriodFilter,
|
||||
enrollmentStatusFilter,
|
||||
genderFilter,
|
||||
graduatedFromProgrammeFilter,
|
||||
hopsFilter,
|
||||
startYearAtUniFilter,
|
||||
studentNumberFilter,
|
||||
studyRightStatusFilter,
|
||||
studyRightTypeFilter,
|
||||
studyTrackFilter,
|
||||
tagsFilter,
|
||||
transferredToProgrammeFilter,
|
||||
} from '@/components/FilterView/filters'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { PopulationDetails } from '@/components/PopulationDetails'
|
||||
import { PopulationSearch } from '@/components/PopulationSearch'
|
||||
import { useCurrentSemester } from '@/hooks/currentSemester'
|
||||
import { useDegreeProgrammeTypes } from '@/hooks/degreeProgrammeTypes'
|
||||
import { useTitle } from '@/hooks/title'
|
||||
import { useGetAuthorizedUserQuery } from '@/redux/auth'
|
||||
import { useAppSelector } from '@/redux/hooks'
|
||||
import { useGetProgrammesQuery, useGetPopulationStatisticsQuery } from '@/redux/populations'
|
||||
import { useGetSemestersQuery } from '@/redux/semesters'
|
||||
import { DegreeProgramme } from '@/types/api/faculty'
|
||||
import { getFullStudyProgrammeRights } from '@/util/access'
|
||||
import { getCombinedProgrammeName } from '@/util/combinedProgramme'
|
||||
|
||||
type Query = {
|
||||
months: string
|
||||
year: string
|
||||
years?: string[]
|
||||
semesters: string[]
|
||||
studentStatuses: string[]
|
||||
studyRights: {
|
||||
programme: string
|
||||
combinedProgramme: string
|
||||
studyTrack?: string
|
||||
}
|
||||
showBachelorAndMaster?: string
|
||||
tag?: any
|
||||
}
|
||||
|
||||
const getYearText = (year: string) => {
|
||||
const yearIsNumber = !isNaN(+year)
|
||||
return yearIsNumber ? `${year} - ${+year + 1}` : ''
|
||||
}
|
||||
|
||||
import { parseQueryParams } from '@/util/queryparams'
|
||||
import { getMonths } from '../PopulationSearch/common'
|
||||
const parseQueryFromUrl = (location): [boolean, Query] => {
|
||||
const skipQuery = !location.search
|
||||
|
||||
const { studyRights, ...rest } = parseQueryParams(location.search)
|
||||
|
||||
if (studyRights === null || Array.isArray(studyRights)) throw Error()
|
||||
|
||||
const query: Query = {
|
||||
months: getMonths('2017', 'FALL').toString(),
|
||||
year: '2017',
|
||||
semesters: ['FALL', 'SPRING'],
|
||||
studentStatuses: [],
|
||||
studyRights: studyRights ? JSON.parse(studyRights) : { programme: '', combinedProgramme: '' },
|
||||
showBachelorAndMaster: 'false',
|
||||
|
||||
...rest,
|
||||
}
|
||||
|
||||
return [skipQuery, query]
|
||||
}
|
||||
|
||||
const mapStudentDataToStudents = (samples, programmeCode, combinedProgrammeCode) =>
|
||||
samples.map(student => {
|
||||
const hopsCredits = student.studyplans?.find(plan => plan.programme_code === programmeCode)?.completed_credits ?? 0
|
||||
const hopsCombinedProgrammeCredits =
|
||||
student.studyplans?.find(plan => plan.programme_code === combinedProgrammeCode)?.completed_credits ?? 0
|
||||
const studyrightStartDate = new Date(student.studyrightStart)
|
||||
const courses = student.courses.filter(({ date }) => studyrightStartDate <= new Date(date))
|
||||
const credits = getStudentTotalCredits({ courses })
|
||||
return {
|
||||
...student,
|
||||
allCredits: student.credits,
|
||||
hopsCredits,
|
||||
hopsCombinedProgrammeCredits,
|
||||
credits,
|
||||
}
|
||||
})
|
||||
|
||||
const useUserHasRestrictedAccess = (): boolean => {
|
||||
const { fullAccessToStudentData, programmeRights } = useGetAuthorizedUserQuery()
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
|
||||
return !fullAccessToStudentData && !fullStudyProgrammeRights.length
|
||||
}
|
||||
|
||||
const useGetProgrammes = (): Record<string, DegreeProgramme> => {
|
||||
const { data: studyProgrammes } = useGetProgrammesQuery()
|
||||
|
||||
return {
|
||||
...studyProgrammes,
|
||||
'KH90_001+MH90_001': {
|
||||
code: 'KH90_001+MH90_001',
|
||||
curriculumPeriodIds: [],
|
||||
degreeProgrammeType: '',
|
||||
progId: '',
|
||||
name: {
|
||||
fi: 'Eläinlääketieteen kandiohjelma ja lisensiaatin koulutusohjelma',
|
||||
en: "Bachelor's and Degree Programme in Vetenary Medicine",
|
||||
sv: 'Kandidats- och Utbildningsprogrammet i veterinärmedicin',
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const useGetProgrammeText = (programmeCode: string, combinedProgrammeCode: string): string => {
|
||||
const { language, getTextIn } = useLanguage()
|
||||
|
||||
const programmes = useGetProgrammes()
|
||||
const programmeName = getTextIn(programmes[programmeCode]?.name) ?? ''
|
||||
const combinedProgrammeName = getTextIn(programmes[combinedProgrammeCode]?.name) ?? ''
|
||||
|
||||
return combinedProgrammeCode
|
||||
? getCombinedProgrammeName(programmeName, combinedProgrammeName, language)
|
||||
: programmeName
|
||||
}
|
||||
|
||||
export const PopulationStatistics = () => {
|
||||
useTitle('Class statistics')
|
||||
const [skipQuery, query] = parseQueryFromUrl(useLocation())
|
||||
|
||||
const { data: allSemesters } = useGetSemestersQuery()
|
||||
const currentSemester = useCurrentSemester()
|
||||
|
||||
const { data: population, isFetching } = useGetPopulationStatisticsQuery(query, { skip: skipQuery })
|
||||
const courses = useAppSelector(store => store.populationSelectedStudentCourses.data?.coursestatistics)
|
||||
|
||||
const { programme: programmeCode, combinedProgramme: combinedProgrammeCode, studyTrack } = query.studyRights
|
||||
|
||||
const students = useMemo(
|
||||
() => mapStudentDataToStudents(population?.students ?? [], programmeCode, combinedProgrammeCode),
|
||||
[population, programmeCode, combinedProgrammeCode]
|
||||
)
|
||||
|
||||
const showBachelorAndMaster = !!combinedProgrammeCode || query?.showBachelorAndMaster === 'true'
|
||||
const programmeText = useGetProgrammeText(programmeCode, combinedProgrammeCode)
|
||||
|
||||
const filters = [
|
||||
!useUserHasRestrictedAccess() ? ageFilter : null,
|
||||
citizenshipFilter,
|
||||
courseFilter({ courses }),
|
||||
creditDateFilter,
|
||||
creditsEarnedFilter,
|
||||
curriculumPeriodFilter,
|
||||
enrollmentStatusFilter({
|
||||
allSemesters: allSemesters?.semesters ?? [],
|
||||
programme: programmeCode,
|
||||
}),
|
||||
genderFilter,
|
||||
graduatedFromProgrammeFilter({
|
||||
code: programmeCode,
|
||||
combinedProgrammeCode,
|
||||
showBachelorAndMaster,
|
||||
}),
|
||||
hopsFilter({ programmeCode, combinedProgrammeCode }),
|
||||
studentNumberFilter,
|
||||
startYearAtUniFilter,
|
||||
studyTrackFilter({ code: programmeCode }),
|
||||
studyRightStatusFilter({ code: programmeCode, combinedProgrammeCode, currentSemester, showBachelorAndMaster }),
|
||||
tagsFilter,
|
||||
transferredToProgrammeFilter,
|
||||
|
||||
useDegreeProgrammeTypes([programmeCode])?.[programmeCode] === 'urn:code:degree-program-type:masters-degree'
|
||||
? studyRightTypeFilter({ programme: programmeCode, year: query.year })
|
||||
: null,
|
||||
|
||||
// For combined programme admission type is the same as they started in bachelor programme
|
||||
parseInt(query.year, 10) >= 2020 ? admissionTypeFilter({ programme: programmeCode }) : null,
|
||||
].filter(item => !!item)
|
||||
|
||||
const initialOptions = {
|
||||
[transferredToProgrammeFilter.key]: {
|
||||
transferred: false,
|
||||
},
|
||||
[hopsFilter.key]: {
|
||||
studyStart: (students[0] ?? {})?.studyrightStart,
|
||||
},
|
||||
[studyTrackFilter.key]: {
|
||||
selected: studyTrack ? [studyTrack] : [],
|
||||
},
|
||||
[tagsFilter.key]: {
|
||||
includedTags: query.tag ? [query.tag] : [],
|
||||
excludedTags: [],
|
||||
},
|
||||
}
|
||||
|
||||
const showNoStudentsMessage = students?.length === 0 && !isFetching
|
||||
const noStudentsMessage = () => (
|
||||
<div style={{ maxWidth: '80%' }}>
|
||||
<Message
|
||||
content={`
|
||||
Choose “Advanced settings” below and make sure you have the correct student groups included
|
||||
in the class. For example, if you are looking for students of a specialist training in
|
||||
medicine or dentistry, you must choose “Students with non-degree study right”.`}
|
||||
header="Not seeing any students?"
|
||||
icon="question"
|
||||
info
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
const title = skipQuery ? 'Class statistics' : `${programmeText} ${getYearText(query.year)}`
|
||||
|
||||
return (
|
||||
<FilterView
|
||||
displayTray={!skipQuery}
|
||||
filters={filters}
|
||||
initialOptions={initialOptions}
|
||||
name="PopulationStatistics"
|
||||
students={students}
|
||||
>
|
||||
{filteredStudents => (
|
||||
<div className="segmentContainer" style={{ flexGrow: 1 }}>
|
||||
<Header align="center" className="segmentTitle" size="large">
|
||||
{title} {!skipQuery && showBachelorAndMaster && '(Bachelor + Master view)'}
|
||||
{!skipQuery && studyTrack && <Header.Subheader>studytrack {studyTrack}</Header.Subheader>}
|
||||
{!skipQuery && (
|
||||
<Header.Subheader>
|
||||
studytime {query.months} months, class size {students.length} students
|
||||
</Header.Subheader>
|
||||
)}
|
||||
</Header>
|
||||
{!skipQuery && showNoStudentsMessage && noStudentsMessage()}
|
||||
<Segment className="contentSegment" loading={isFetching}>
|
||||
<PopulationSearch
|
||||
combinedProgrammeCode={combinedProgrammeCode}
|
||||
isLoading={isFetching}
|
||||
populationFound={!!students.length}
|
||||
query={query}
|
||||
skipQuery={skipQuery}
|
||||
/>
|
||||
{!skipQuery && (
|
||||
<PopulationDetails
|
||||
filteredStudents={filteredStudents}
|
||||
isLoading={isFetching}
|
||||
programmeCodes={[programmeCode, combinedProgrammeCode]}
|
||||
query={query}
|
||||
/>
|
||||
)}
|
||||
</Segment>
|
||||
</div>
|
||||
)}
|
||||
</FilterView>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FormControlLabel, Switch } from '@mui/material'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
import Switch from '@mui/material/Switch'
|
||||
|
||||
export const IncludeSubstitutionsToggle = ({ includeSubstitutions, toggleIncludeSubstitutions }) => (
|
||||
<FormControlLabel
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { useSelector } from 'react-redux'
|
||||
import { Tab } from 'semantic-ui-react'
|
||||
import { useAppSelector } from '@/redux/hooks'
|
||||
|
||||
import { useGetStudyGuidanceGroupPopulationCoursesQuery } from '@/redux/studyGuidanceGroups'
|
||||
|
||||
@@ -11,7 +11,7 @@ export const CoursesTabContainer = ({ curriculum, includeSubstitutions, students
|
||||
year: studyGuidanceGroup?.tags?.year,
|
||||
})
|
||||
|
||||
const { data: populationCourses } = useSelector(state => state?.populationSelectedStudentCourses)
|
||||
const { data: populationCourses } = useAppSelector(state => state.populationSelectedStudentCourses)
|
||||
|
||||
const studyGuidanceGroupLoading = isLoading || !curriculum || !students
|
||||
const loading = !curriculum || !students || !populationCourses
|
||||
|
||||
+116
-110
@@ -1,19 +1,24 @@
|
||||
import { Tooltip, Typography } from '@mui/material'
|
||||
import { type MRT_ColumnDef } from 'material-react-table'
|
||||
import CheckIcon from '@mui/icons-material/Check'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
import Typography from '@mui/material/Typography'
|
||||
|
||||
import { createMRTColumnHelper } from 'material-react-table'
|
||||
import { useMemo } from 'react'
|
||||
import { StudentInfoItem } from '@/components/material/StudentInfoItem'
|
||||
import { TableHeaderWithTooltip } from '@/components/material/TableHeaderWithTooltip'
|
||||
import { muiTableBodyCellPropsDefaultSx } from '@/util/getDefaultMRTOptions'
|
||||
import { FormattedStudentData } from '.'
|
||||
import { DynamicColumnTitles } from './GeneralTab'
|
||||
|
||||
export const useColumnDefinitions = (dynamicTitles: DynamicColumnTitles) => {
|
||||
return useMemo<MRT_ColumnDef<any>[]>(
|
||||
const columnHelper = createMRTColumnHelper<FormattedStudentData>()
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'studentNumber',
|
||||
columnHelper.accessor('studentNumber', {
|
||||
header: 'Student number',
|
||||
Header: () => <Typography fontWeight="bold">Student number</Typography>,
|
||||
Cell: ({ cell }) => {
|
||||
const studentNumber = cell.getValue<string>()
|
||||
const studentNumber = cell.getValue()
|
||||
return studentNumber === 'Hidden' ? (
|
||||
'Hidden'
|
||||
) : (
|
||||
@@ -22,27 +27,22 @@ export const useColumnDefinitions = (dynamicTitles: DynamicColumnTitles) => {
|
||||
},
|
||||
filterFn: 'startsWith',
|
||||
enableClickToCopy: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'lastName',
|
||||
}),
|
||||
columnHelper.accessor('lastName', {
|
||||
header: 'Last name',
|
||||
},
|
||||
{
|
||||
accessorKey: 'firstNames',
|
||||
}),
|
||||
columnHelper.accessor('firstNames', {
|
||||
header: 'Given names',
|
||||
},
|
||||
{
|
||||
accessorKey: 'email',
|
||||
header: 'Email',
|
||||
enableClickToCopy: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'phoneNumber',
|
||||
header: 'Phone number',
|
||||
},
|
||||
{
|
||||
accessorKey: 'primaryProgramme',
|
||||
}),
|
||||
columnHelper.accessor('primaryProgramme', {
|
||||
header: 'Primary study programme',
|
||||
muiTableBodyCellProps: {
|
||||
sx: {
|
||||
...muiTableBodyCellPropsDefaultSx,
|
||||
width: '250px',
|
||||
maxWidth: '380px',
|
||||
},
|
||||
},
|
||||
Header: (
|
||||
<TableHeaderWithTooltip
|
||||
header="Primary study programme"
|
||||
@@ -50,52 +50,58 @@ export const useColumnDefinitions = (dynamicTitles: DynamicColumnTitles) => {
|
||||
/>
|
||||
),
|
||||
Cell: ({ cell }) => {
|
||||
const value = cell.getValue<string>()
|
||||
const formattedValue = value.length > 45 ? `${value.substring(0, 43)}...` : value
|
||||
const programmeName = cell.getValue()
|
||||
if (!programmeName) return null
|
||||
const indexToSplitAt = programmeName.lastIndexOf(' ')
|
||||
const formattedValue = ['(Graduated)', '(Inactive)'].includes(programmeName.slice(indexToSplitAt + 1)) ? (
|
||||
(() => {
|
||||
const start = programmeName.slice(0, indexToSplitAt)
|
||||
const end = programmeName.slice(indexToSplitAt + 1)
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ textOverflow: 'ellipsis', overflow: 'hidden' }}>{start}</span>
|
||||
<span style={{ paddingLeft: '0.5em' }}>{end}</span>
|
||||
</div>
|
||||
)
|
||||
})()
|
||||
) : (
|
||||
<span style={{ textOverflow: 'ellipsis', overflow: 'hidden' }}>{programmeName}</span>
|
||||
)
|
||||
return (
|
||||
<Tooltip arrow title={value}>
|
||||
<span>{formattedValue}</span>
|
||||
<Tooltip arrow title={programmeName}>
|
||||
{formattedValue}
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'creditsTotal',
|
||||
}),
|
||||
columnHelper.accessor('creditsTotal', {
|
||||
header: 'All credits',
|
||||
},
|
||||
{
|
||||
accessorKey: 'creditsHops',
|
||||
}),
|
||||
columnHelper.accessor('creditsHops', {
|
||||
header: 'Credits in HOPS',
|
||||
},
|
||||
{
|
||||
accessorKey: 'creditsCombinedProg',
|
||||
}),
|
||||
columnHelper.accessor('creditsCombinedProg', {
|
||||
header: `${dynamicTitles.creditsCombinedProg}`,
|
||||
},
|
||||
{
|
||||
accessorKey: 'creditsSince',
|
||||
}),
|
||||
columnHelper.accessor('creditsSince', {
|
||||
header: `${dynamicTitles.creditsSince}`,
|
||||
},
|
||||
{
|
||||
accessorKey: 'grade',
|
||||
}),
|
||||
columnHelper.accessor('grade', {
|
||||
header: 'Grade',
|
||||
},
|
||||
{
|
||||
accessorKey: 'studyTrack',
|
||||
}),
|
||||
columnHelper.accessor('studyTrack', {
|
||||
header: 'Study track',
|
||||
},
|
||||
{
|
||||
accessorKey: 'studyRightStart',
|
||||
}),
|
||||
columnHelper.accessor('studyRightStart', {
|
||||
header: 'Start of study right',
|
||||
},
|
||||
{
|
||||
accessorKey: 'programmeStart',
|
||||
}),
|
||||
columnHelper.accessor('programmeStart', {
|
||||
header: 'Started in programme',
|
||||
},
|
||||
{
|
||||
accessorKey: 'option',
|
||||
}),
|
||||
columnHelper.accessor('option', {
|
||||
header: `${dynamicTitles.option}`,
|
||||
Cell: ({ cell }) => {
|
||||
const value = cell.getValue<string>()
|
||||
const value = cell.getValue()
|
||||
const formattedValue = value.length > 45 ? `${value.substring(0, 43)}...` : value
|
||||
return (
|
||||
<Tooltip arrow title={value}>
|
||||
@@ -103,30 +109,24 @@ export const useColumnDefinitions = (dynamicTitles: DynamicColumnTitles) => {
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'semesterEnrollments',
|
||||
}),
|
||||
columnHelper.accessor('semesterEnrollments', {
|
||||
header: 'Semesters present',
|
||||
Cell: ({ cell }) => {
|
||||
// @ts-expect-error add typing
|
||||
const { content } = cell.getValue()
|
||||
return content ?? null
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'graduationDate',
|
||||
}),
|
||||
columnHelper.accessor('graduationDate', {
|
||||
header: `${dynamicTitles.primaryEndDate}`,
|
||||
},
|
||||
{
|
||||
accessorKey: 'graduationDateCombinedProg',
|
||||
}),
|
||||
columnHelper.accessor('graduationDateCombinedProg', {
|
||||
header: `${dynamicTitles.secondaryEndDate}`,
|
||||
},
|
||||
{
|
||||
accessorKey: 'startYearAtUniversity',
|
||||
}),
|
||||
columnHelper.accessor('startYearAtUniversity', {
|
||||
header: 'Start year at uni',
|
||||
},
|
||||
{
|
||||
accessorKey: 'programmes',
|
||||
}),
|
||||
columnHelper.accessor('programmes', {
|
||||
header: `${dynamicTitles.programmes}`,
|
||||
Header: (
|
||||
<TableHeaderWithTooltip
|
||||
@@ -135,7 +135,6 @@ export const useColumnDefinitions = (dynamicTitles: DynamicColumnTitles) => {
|
||||
/>
|
||||
),
|
||||
Cell: ({ cell }) => {
|
||||
// @ts-expect-error add typing
|
||||
const { programmes, programmeList } = cell.getValue()
|
||||
if (programmes.length === 0) return null
|
||||
|
||||
@@ -148,25 +147,20 @@ export const useColumnDefinitions = (dynamicTitles: DynamicColumnTitles) => {
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'attainmentDate',
|
||||
}),
|
||||
columnHelper.accessor('attainmentDate', {
|
||||
header: 'Attainment date',
|
||||
},
|
||||
{
|
||||
accessorKey: 'enrollmentDate',
|
||||
}),
|
||||
columnHelper.accessor('enrollmentDate', {
|
||||
header: 'Enrollment date',
|
||||
},
|
||||
{
|
||||
accessorKey: 'language',
|
||||
}),
|
||||
columnHelper.accessor('language', {
|
||||
header: 'Language',
|
||||
},
|
||||
{
|
||||
accessorKey: 'transferredFrom',
|
||||
}),
|
||||
columnHelper.accessor('transferredFrom', {
|
||||
header: 'Transferred from',
|
||||
},
|
||||
{
|
||||
accessorKey: 'admissionType',
|
||||
}),
|
||||
columnHelper.accessor('admissionType', {
|
||||
header: 'Admission type',
|
||||
Header: (
|
||||
<TableHeaderWithTooltip
|
||||
@@ -174,21 +168,36 @@ export const useColumnDefinitions = (dynamicTitles: DynamicColumnTitles) => {
|
||||
tooltipText="Not available for study rights granted prior to 2020"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'gender',
|
||||
}),
|
||||
columnHelper.accessor('gender', {
|
||||
header: 'Gender',
|
||||
},
|
||||
{
|
||||
accessorKey: 'citizenships',
|
||||
}),
|
||||
columnHelper.accessor('citizenships', {
|
||||
header: 'Citizenships',
|
||||
},
|
||||
{
|
||||
accessorKey: 'curriculumPeriod',
|
||||
}),
|
||||
columnHelper.accessor('tvex', {
|
||||
header: 'TVEX',
|
||||
Header: (
|
||||
<TableHeaderWithTooltip
|
||||
header="TVEX"
|
||||
tooltipText="Student is enrolled to a bilingual programme (kaksikielinen tutkinto, tvåspråkig examen)"
|
||||
/>
|
||||
),
|
||||
Cell: ({ cell }) => {
|
||||
const isTVEX = cell.getValue()
|
||||
return (
|
||||
isTVEX && (
|
||||
<span style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<CheckIcon />
|
||||
</span>
|
||||
)
|
||||
)
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor('curriculumPeriod', {
|
||||
header: 'Curriculum period',
|
||||
},
|
||||
{
|
||||
accessorKey: 'mostRecentAttainment',
|
||||
}),
|
||||
columnHelper.accessor('mostRecentAttainment', {
|
||||
header: 'Latest attainment date',
|
||||
Header: (
|
||||
<TableHeaderWithTooltip
|
||||
@@ -196,19 +205,16 @@ export const useColumnDefinitions = (dynamicTitles: DynamicColumnTitles) => {
|
||||
tooltipText="Date of the most recent course completion that is included in the HOPS"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'extent',
|
||||
}),
|
||||
columnHelper.accessor('extent', {
|
||||
header: 'Extent',
|
||||
},
|
||||
{
|
||||
accessorKey: 'tags',
|
||||
}),
|
||||
columnHelper.accessor('tags', {
|
||||
header: 'Tags',
|
||||
},
|
||||
{
|
||||
accessorKey: 'updatedAt',
|
||||
}),
|
||||
columnHelper.accessor('updatedAt', {
|
||||
header: 'Last updated at',
|
||||
},
|
||||
}),
|
||||
],
|
||||
[dynamicTitles]
|
||||
)
|
||||
|
||||
+33
-32
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
type MRT_VisibilityState,
|
||||
type MRT_ColumnDef,
|
||||
MaterialReactTable,
|
||||
createMRTColumnHelper,
|
||||
useMaterialReactTable,
|
||||
} from 'material-react-table'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
@@ -54,8 +54,8 @@ export const GeneralTab = ({
|
||||
const [columnVisibility, setColumnVisibility] = useState<MRT_VisibilityState>({
|
||||
lastName: namesVisible,
|
||||
firstNames: namesVisible,
|
||||
email: namesVisible,
|
||||
phoneNumber: namesVisible,
|
||||
email: false,
|
||||
phoneNumber: false,
|
||||
studyTrack: studyTrackVisible,
|
||||
admissionType: admissionTypeVisible,
|
||||
semesterEnrollmentExport: false,
|
||||
@@ -66,8 +66,8 @@ export const GeneralTab = ({
|
||||
setColumnVisibility({
|
||||
lastName: namesVisible,
|
||||
firstNames: namesVisible,
|
||||
email: namesVisible,
|
||||
phoneNumber: namesVisible,
|
||||
email: false,
|
||||
phoneNumber: false,
|
||||
studyTrack: studyTrackVisible,
|
||||
admissionType: admissionTypeVisible,
|
||||
semesterEnrollmentExport: false,
|
||||
@@ -75,16 +75,7 @@ export const GeneralTab = ({
|
||||
})
|
||||
}, [namesVisible, studyTrackVisible, admissionTypeVisible])
|
||||
|
||||
const baseColumns = [
|
||||
'lastName',
|
||||
'firstNames',
|
||||
'email',
|
||||
'phoneNumber',
|
||||
'creditsTotal',
|
||||
'studentNumber',
|
||||
'tags',
|
||||
'updatedAt',
|
||||
]
|
||||
const baseColumns = ['lastName', 'firstNames', 'creditsTotal', 'studentNumber', 'tvex', 'tags', 'updatedAt']
|
||||
|
||||
const adminColumns = ['extent', 'updatedAt']
|
||||
|
||||
@@ -189,22 +180,32 @@ export const GeneralTab = ({
|
||||
return isIncluded
|
||||
})
|
||||
|
||||
const exportExclusiveColumns = [
|
||||
{
|
||||
accessorKey: 'semesterEnrollments.exportValue',
|
||||
id: 'semesterEnrollmentExport',
|
||||
header: 'Semester enrollment amount',
|
||||
visibleInShowHideMenu: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'programmes.exportValue',
|
||||
id: 'programmesExport',
|
||||
header: `${dynamicTitles.programmes}`,
|
||||
visibleInShowHideMenu: false,
|
||||
},
|
||||
]
|
||||
const columnHelper = createMRTColumnHelper<FormattedStudentData>()
|
||||
const exportExclusiveColumns = useMemo(
|
||||
() => [
|
||||
columnHelper.accessor('semesterEnrollments.exportValue', {
|
||||
id: 'semesterEnrollmentExport',
|
||||
header: 'Semester enrollment amount',
|
||||
visibleInShowHideMenu: false,
|
||||
}),
|
||||
columnHelper.accessor('programmes.exportValue', {
|
||||
id: 'programmesExport',
|
||||
header: `${dynamicTitles.programmes}`,
|
||||
visibleInShowHideMenu: false,
|
||||
}),
|
||||
columnHelper.accessor('email', {
|
||||
header: 'Email',
|
||||
visibleInShowHideMenu: false,
|
||||
}),
|
||||
columnHelper.accessor('phoneNumber', {
|
||||
header: 'Phone number',
|
||||
visibleInShowHideMenu: false,
|
||||
}),
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
const columns = useMemo<MRT_ColumnDef<any>[]>(
|
||||
const columns = useMemo(
|
||||
() => [...visibleColumns, ...exportExclusiveColumns],
|
||||
[
|
||||
exportExclusiveColumns,
|
||||
@@ -217,7 +218,7 @@ export const GeneralTab = ({
|
||||
]
|
||||
)
|
||||
|
||||
const defaultOptions = getDefaultMRTOptions(setExportData, setExportModalOpen, language)
|
||||
const defaultOptions = getDefaultMRTOptions<FormattedStudentData>(setExportData, setExportModalOpen, language)
|
||||
|
||||
const table = useMaterialReactTable({
|
||||
...defaultOptions,
|
||||
@@ -236,7 +237,7 @@ export const GeneralTab = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<ExportToExcelDialog
|
||||
<ExportToExcelDialog<FormattedStudentData>
|
||||
exportColumns={exportCols}
|
||||
exportData={exportData}
|
||||
featureName="general_student_information"
|
||||
|
||||
+27
-24
@@ -1,4 +1,3 @@
|
||||
import { useSelector } from 'react-redux'
|
||||
import { getStudentTotalCredits, getHighestGradeOfCourseBetweenRange } from '@/common'
|
||||
import { creditDateFilter } from '@/components/FilterView/filters'
|
||||
import { useFilters } from '@/components/FilterView/useFilters'
|
||||
@@ -49,6 +48,7 @@ export type FormattedStudentData = {
|
||||
attainmentDate?: string
|
||||
enrollmentDate?: string
|
||||
language?: string
|
||||
tvex?: boolean
|
||||
}
|
||||
|
||||
export const GeneralTabContainer = ({
|
||||
@@ -60,6 +60,8 @@ export const GeneralTabContainer = ({
|
||||
courseCode,
|
||||
from,
|
||||
to,
|
||||
studyRights,
|
||||
showBachelorAndMaster,
|
||||
}) => {
|
||||
const { getTextIn } = useLanguage()
|
||||
const { isAdmin } = useGetAuthorizedUserQuery()
|
||||
@@ -75,17 +77,12 @@ export const GeneralTabContainer = ({
|
||||
|
||||
const { useFilterSelector } = useFilters()
|
||||
|
||||
// @ts-expect-error add typing
|
||||
const { query } = useSelector(({ populations }) => populations)
|
||||
|
||||
const queryStudyrights = Object.values(query?.studyRights ?? {}).filter(studyright => !!studyright) as string[]
|
||||
const queryStudyrights = Object.values(studyRights ?? {}).filter(studyright => !!studyright) as string[]
|
||||
const degreeProgrammeTypes = useDegreeProgrammeTypes(queryStudyrights)
|
||||
const creditDateFilterOptions = useFilterSelector(creditDateFilter.selectors.selectOptions)
|
||||
const creditDateFilterOptions = useFilterSelector(creditDateFilter.selectors.selectOptions())
|
||||
|
||||
const studyGuidanceGroupProgrammes = group?.tags?.studyProgramme?.includes('+')
|
||||
? group?.tags?.studyProgramme.split('+')
|
||||
: [group?.tags?.studyProgramme]
|
||||
const programmeCode = query?.studyRights?.programme || studyGuidanceGroupProgrammes[0] || customPopulationProgramme
|
||||
const [sggProgramme, sggCombinedProgramme] = group?.tags?.studyProgramme?.split('+') ?? []
|
||||
const programmeCode = studyRights?.programme ?? sggProgramme ?? customPopulationProgramme
|
||||
|
||||
const isMastersProgramme = degreeProgrammeTypes[programmeCode] === 'urn:code:degree-program-type:masters-degree'
|
||||
|
||||
@@ -104,7 +101,7 @@ export const GeneralTabContainer = ({
|
||||
const getTransferredFrom = (student: any) =>
|
||||
getTextIn(programmes[student.transferSource]?.name) ?? student.transferSource
|
||||
|
||||
const showBachelorAndMaster = query?.showBachelorAndMaster === 'true'
|
||||
const shouldShowBachelorAndMaster = showBachelorAndMaster === 'true'
|
||||
|
||||
const getStudyRight = student => {
|
||||
const code = programmeCode ?? studentToPrimaryProgrammeMap[student.studentNumber]?.code
|
||||
@@ -133,11 +130,7 @@ export const GeneralTabContainer = ({
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const combinedProgrammeCode = query?.studyRights?.combinedProgramme
|
||||
? query.studyRights.combinedProgramme
|
||||
: studyGuidanceGroupProgrammes.length > 1
|
||||
? studyGuidanceGroupProgrammes[1]
|
||||
: null
|
||||
const combinedProgrammeCode = studyRights?.combinedProgramme ?? sggCombinedProgramme ?? null
|
||||
|
||||
const getCombinedProgrammeCredits = student =>
|
||||
student.studyplans?.find(plan => {
|
||||
@@ -151,7 +144,7 @@ export const GeneralTabContainer = ({
|
||||
})?.completed_credits ?? 0
|
||||
|
||||
const shouldShowAdmissionType =
|
||||
parseInt(query?.year, 10) >= 2020 || parseInt(group?.tags?.year, 10) >= 2020 || variant === 'customPopulation'
|
||||
parseInt(year, 10) >= 2020 || parseInt(group?.tags?.year, 10) >= 2020 || variant === 'customPopulation'
|
||||
const getAdmissiontype = student => {
|
||||
const studyRight = getStudyRight(student)
|
||||
const admissionType = studyRight?.admissionType ?? 'Ei valintatapaa'
|
||||
@@ -183,7 +176,7 @@ export const GeneralTabContainer = ({
|
||||
}
|
||||
|
||||
const parseTags = tags => {
|
||||
const studentTags = tags?.map(tag => tag.ta.tagname)
|
||||
const studentTags = tags?.map(studentTag => studentTag.tag.tagname)
|
||||
return studentTags.join(', ')
|
||||
}
|
||||
|
||||
@@ -346,7 +339,7 @@ export const GeneralTabContainer = ({
|
||||
year,
|
||||
currentSemester?.semestercode,
|
||||
getTextIn,
|
||||
showBachelorAndMaster
|
||||
shouldShowBachelorAndMaster
|
||||
)
|
||||
|
||||
const { getSemesterEnrollmentsContent, getSemesterEnrollmentsVal } = getSemestersPresentFunctions({
|
||||
@@ -358,10 +351,11 @@ export const GeneralTabContainer = ({
|
||||
studentToSecondStudyrightEndMap,
|
||||
studentToStudyrightEndMap,
|
||||
year,
|
||||
semestersToAddToStart: showBachelorAndMaster && isMastersProgramme ? 6 : 0,
|
||||
semestersToAddToStart: shouldShowBachelorAndMaster && isMastersProgramme ? 6 : 0,
|
||||
})
|
||||
|
||||
const formatStudent = (student: any): FormattedStudentData => {
|
||||
const correctStudyRight = getStudyRight(student)
|
||||
const result: FormattedStudentData = {
|
||||
firstNames: student.firstnames,
|
||||
lastName: student.lastname,
|
||||
@@ -384,17 +378,18 @@ export const GeneralTabContainer = ({
|
||||
startYearAtUniversity: getStartingYear(student),
|
||||
programmes: getStudyProgrammes(student),
|
||||
transferredFrom: student.transferredStudyRight ?? getTransferredFrom(student),
|
||||
admissionType: shouldShowAdmissionType && getAdmissiontype(student),
|
||||
admissionType: shouldShowAdmissionType ? getAdmissiontype(student) : null,
|
||||
gender: getGender(student.gender_code),
|
||||
citizenships: student.citizenships?.map(getTextIn).sort().join(', ') ?? null,
|
||||
curriculumPeriod: student.curriculumVersion,
|
||||
mostRecentAttainment: getMostRecentAttainment(student),
|
||||
tvex: !!correctStudyRight?.tvex,
|
||||
tags: parseTags(student.tags) ?? null,
|
||||
extent: isAdmin && getExtent(student),
|
||||
updatedAt: isAdmin && formatDate(student.updatedAt, DateFormat.ISO_DATE_DEV),
|
||||
}
|
||||
|
||||
if (combinedProgrammeCode || showBachelorAndMaster) {
|
||||
if (combinedProgrammeCode || shouldShowBachelorAndMaster) {
|
||||
const secondaryStudyRightEnd = studentToSecondStudyrightEndMap[student.studentNumber]
|
||||
result.creditsCombinedProg = getCombinedProgrammeCredits(student)
|
||||
result.graduationDateCombinedProg = secondaryStudyRightEnd
|
||||
@@ -403,7 +398,15 @@ export const GeneralTabContainer = ({
|
||||
}
|
||||
|
||||
if (variant === 'customPopulation' && !programmeCode) {
|
||||
result.primaryProgramme = getTextIn(studentToPrimaryProgrammeMap[student.studentNumber]?.name) ?? ''
|
||||
const primaryProgramme = studentToPrimaryProgrammeMap[student.studentNumber]
|
||||
if (primaryProgramme) {
|
||||
const primaryProgrammeName = getTextIn(primaryProgramme.name) ?? ''
|
||||
result.primaryProgramme = primaryProgramme.graduated
|
||||
? `${primaryProgrammeName} (Graduated)`
|
||||
: !primaryProgramme.active
|
||||
? `${primaryProgrammeName} (Inactive)`
|
||||
: primaryProgrammeName
|
||||
}
|
||||
}
|
||||
|
||||
if (variant === 'coursePopulation') {
|
||||
@@ -437,7 +440,7 @@ export const GeneralTabContainer = ({
|
||||
}}
|
||||
formattedData={formattedData}
|
||||
group={group}
|
||||
isCombinedProg={!!combinedProgrammeCode || showBachelorAndMaster}
|
||||
isCombinedProg={!!combinedProgrammeCode || shouldShowBachelorAndMaster}
|
||||
showAdminColumns={isAdmin}
|
||||
studyTrackVisible={containsStudyTracks}
|
||||
variant={variant}
|
||||
|
||||
+100
-78
@@ -1,9 +1,14 @@
|
||||
import { Check as CheckIcon, CropSquare as CropSquareIcon } from '@mui/icons-material'
|
||||
import { Box, Divider, Tooltip, Typography } from '@mui/material'
|
||||
import CheckIcon from '@mui/icons-material/Check'
|
||||
import CropSquareIcon from '@mui/icons-material/CropSquare'
|
||||
import Box from '@mui/material/Box'
|
||||
import Divider from '@mui/material/Divider'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
import Typography from '@mui/material/Typography'
|
||||
|
||||
import {
|
||||
type MRT_ColumnDef,
|
||||
type MRT_VisibilityState,
|
||||
MaterialReactTable,
|
||||
createMRTColumnHelper,
|
||||
useMaterialReactTable,
|
||||
} from 'material-react-table'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
@@ -58,104 +63,121 @@ export const ModulesTab = ({
|
||||
|
||||
const isLoading = Object.keys(formattedModules).length === 0
|
||||
|
||||
const staticColumns = useMemo<MRT_ColumnDef<any>[]>(
|
||||
const columnHelper = createMRTColumnHelper<FormattedStudent>()
|
||||
const staticColumns = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'lastName',
|
||||
columnHelper.accessor('lastName', {
|
||||
header: 'Last name',
|
||||
Footer: () => <DividedTableCell />,
|
||||
},
|
||||
{
|
||||
accessorKey: 'firstNames',
|
||||
}),
|
||||
columnHelper.accessor('firstNames', {
|
||||
header: 'First names',
|
||||
Footer: () => <DividedTableCell />,
|
||||
},
|
||||
{
|
||||
accessorKey: 'studentNumber',
|
||||
}),
|
||||
columnHelper.accessor('studentNumber', {
|
||||
header: 'Student number',
|
||||
Header: () => <Typography fontWeight="bold">Student number</Typography>,
|
||||
Cell: ({ cell }) => (
|
||||
<StudentInfoItem sisPersonId={cell.row.original.sis_person_id} studentNumber={cell.getValue<string>()} />
|
||||
<StudentInfoItem sisPersonId={cell.row.original.sisPersonID} studentNumber={cell.getValue()} />
|
||||
),
|
||||
filterFn: 'startsWith',
|
||||
Footer: () => <DividedTableCell bottom="Completed" top="Planned" />,
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor('studentNumber', {
|
||||
header: 'Student number',
|
||||
Header: () => <Typography fontWeight="bold">Student number</Typography>,
|
||||
Cell: ({ cell }) => (
|
||||
<StudentInfoItem sisPersonId={cell.row.original.sisPersonID} studentNumber={cell.getValue()} />
|
||||
),
|
||||
filterFn: 'startsWith',
|
||||
Footer: () => <DividedTableCell bottom="Completed" top="Planned" />,
|
||||
}),
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
const dynamicColumns = useMemo<MRT_ColumnDef<any>[]>(() => {
|
||||
const dynamicColumns = useMemo(() => {
|
||||
if (!formattedModules) return []
|
||||
return Object.keys(formattedModules).map(code => ({
|
||||
accessorFn: (row: FormattedStudent) => {
|
||||
const studyModule = getModuleIfExists(row, code)
|
||||
return studyModule ? (studyModule.completed ? 1 : 0) : null // 1 for completed, 0 for in HOPS
|
||||
},
|
||||
Footer: ({ table, column }) => {
|
||||
const { planned, completed } = table.getFilteredRowModel().rows.reduce(
|
||||
(acc, row) => {
|
||||
const value = row.getValue<number>(column.id)
|
||||
if (value === 0) {
|
||||
acc.planned += 1
|
||||
} else if (value === 1) {
|
||||
acc.completed += 1
|
||||
}
|
||||
return acc
|
||||
return Object.keys(formattedModules).map(code =>
|
||||
columnHelper.accessor(
|
||||
row => {
|
||||
const studyModule = getModuleIfExists(row, code)
|
||||
return studyModule ? (studyModule.completed ? 1 : 0) : null // 1 for completed, 0 for in HOPS
|
||||
},
|
||||
{
|
||||
Footer: ({ table, column }) => {
|
||||
const { planned, completed } = table.getFilteredRowModel().rows.reduce(
|
||||
(acc, row) => {
|
||||
const value = row.getValue(column.id)
|
||||
if (value === 0) {
|
||||
acc.planned += 1
|
||||
} else if (value === 1) {
|
||||
acc.completed += 1
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{ planned: 0, completed: 0 }
|
||||
)
|
||||
return <DividedTableCell bottom={completed} top={planned} />
|
||||
},
|
||||
{ planned: 0, completed: 0 }
|
||||
)
|
||||
return <DividedTableCell bottom={completed} top={planned} />
|
||||
},
|
||||
header: `${code} – ${getTextIn(formattedModules[code])}`,
|
||||
Header: () => (
|
||||
<Tooltip title={`${code} – ${getTextIn(formattedModules[code])}`}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline flow-root',
|
||||
writingMode: 'vertical-rl',
|
||||
textOrientation: 'mixed',
|
||||
whiteSpace: 'normal',
|
||||
overflow: 'visible',
|
||||
maxWidth: '15em',
|
||||
minHeight: '160px',
|
||||
maxHeight: '240px',
|
||||
padding: '0.4em 0',
|
||||
}}
|
||||
>
|
||||
<>
|
||||
<Typography fontWeight="bold">{code}</Typography>
|
||||
<Typography
|
||||
header: `${code} – ${getTextIn(formattedModules[code])}`,
|
||||
Header: () => (
|
||||
<Tooltip title={`${code} – ${getTextIn(formattedModules[code])}`}>
|
||||
<Box
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
fontWeight: 'normal',
|
||||
display: 'inline flow-root',
|
||||
writingMode: 'vertical-rl',
|
||||
textOrientation: 'mixed',
|
||||
whiteSpace: 'normal',
|
||||
overflow: 'visible',
|
||||
maxWidth: '15em',
|
||||
minHeight: '160px',
|
||||
maxHeight: '240px',
|
||||
padding: '0.4em 0',
|
||||
}}
|
||||
>
|
||||
{getTextIn(formattedModules[code])}
|
||||
</Typography>
|
||||
</>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
),
|
||||
Cell: ({ cell }) => {
|
||||
const studyModule = getModuleIfExists(cell.row.original, code)
|
||||
if (!studyModule) return <Box />
|
||||
return studyModule.completed ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center' }} title={`Completed on ${studyModule.completionDate}`}>
|
||||
<CheckIcon color="success" />
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center' }} title="Has the module in their primary study plan">
|
||||
<CropSquareIcon sx={{ color: 'grey.500' }} />
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
filterFn: 'empty',
|
||||
}))
|
||||
<>
|
||||
<Typography fontWeight="bold">{code}</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
fontWeight: 'normal',
|
||||
}}
|
||||
>
|
||||
{getTextIn(formattedModules[code])}
|
||||
</Typography>
|
||||
</>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
),
|
||||
Cell: ({ cell }) => {
|
||||
const studyModule = getModuleIfExists(cell.row.original, code)
|
||||
if (!studyModule) return <Box />
|
||||
return studyModule.completed ? (
|
||||
<Box
|
||||
sx={{ display: 'flex', justifyContent: 'center' }}
|
||||
title={`Completed on ${studyModule.completionDate}`}
|
||||
>
|
||||
<CheckIcon color="success" />
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{ display: 'flex', justifyContent: 'center' }}
|
||||
title="Has the module in their primary study plan"
|
||||
>
|
||||
<CropSquareIcon sx={{ color: 'grey.500' }} />
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
filterFn: 'empty',
|
||||
}
|
||||
)
|
||||
)
|
||||
}, [formattedModules, getTextIn])
|
||||
|
||||
const columns = useMemo(() => [...staticColumns, ...dynamicColumns], [staticColumns, dynamicColumns])
|
||||
|
||||
const defaultOptions = getDefaultMRTOptions(setExportData, setExportModalOpen, language)
|
||||
const defaultOptions = getDefaultMRTOptions<FormattedStudent>(setExportData, setExportModalOpen, language)
|
||||
|
||||
const table = useMaterialReactTable({
|
||||
...defaultOptions,
|
||||
@@ -200,7 +222,7 @@ export const ModulesTab = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<ExportToExcelDialog
|
||||
<ExportToExcelDialog<FormattedStudent>
|
||||
exportColumns={columns}
|
||||
exportData={exportData}
|
||||
featureName="modules_of_students"
|
||||
|
||||
+3
-3
@@ -9,7 +9,7 @@ export type FormattedStudent = {
|
||||
lastName: string
|
||||
studentNumber: string
|
||||
studyModulesInHOPS: StudyModuleData[]
|
||||
sis_person_id: string
|
||||
sisPersonID: string
|
||||
}
|
||||
|
||||
export type FormattedModules = Record<string, Record<string, string>>
|
||||
@@ -52,7 +52,7 @@ const formatStudent = (student: any, degreeProgrammeCodes: string[]): FormattedS
|
||||
lastName: student.lastname,
|
||||
studentNumber: student.studentNumber,
|
||||
studyModulesInHOPS: getModulesFromRelevantStudyPlan(student, degreeProgrammeCodes),
|
||||
sis_person_id: student.sis_person_id,
|
||||
sisPersonID: student.sis_person_id,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,5 +91,5 @@ export const ModulesTabContainer = ({ curriculum, students }) => {
|
||||
[students, degreeProgrammeCodes]
|
||||
)
|
||||
|
||||
return ModulesTab({ formattedModules, formattedStudents })
|
||||
return <ModulesTab formattedModules={formattedModules} formattedStudents={formattedStudents} />
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import moment from 'moment'
|
||||
import { useRef } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { Tab } from 'semantic-ui-react'
|
||||
|
||||
import { coursePopulationToolTips, populationStatisticsToolTips } from '@/common/InfoToolTips'
|
||||
@@ -35,6 +34,8 @@ const Panes = ({
|
||||
variant,
|
||||
visiblePanes,
|
||||
year,
|
||||
studyRights,
|
||||
showBachelorAndMaster,
|
||||
}) => {
|
||||
const { handleTabChange, showSubstitutionToggle } = useTabChangeAnalytics()
|
||||
const [includeSubstitutions, toggleIncludeSubstitutions] = useToggle(false)
|
||||
@@ -53,6 +54,8 @@ const Panes = ({
|
||||
filteredStudents={filteredStudents}
|
||||
from={from}
|
||||
group={studyGuidanceGroup}
|
||||
showBachelorAndMaster={showBachelorAndMaster}
|
||||
studyRights={studyRights}
|
||||
to={to}
|
||||
variant={variant}
|
||||
year={year}
|
||||
@@ -133,15 +136,18 @@ const PopulationStudents = ({
|
||||
filteredStudents,
|
||||
from,
|
||||
to,
|
||||
months: initMonths,
|
||||
studyRights,
|
||||
studyGuidanceGroup,
|
||||
variant,
|
||||
year,
|
||||
showBachelorAndMaster,
|
||||
}) => {
|
||||
const studentRef = useRef()
|
||||
const { query } = useSelector(({ populations }) => populations)
|
||||
let mainProgramme = query?.studyRights?.programme || ''
|
||||
let combinedProgramme = query?.studyRights?.combinedProgramme || ''
|
||||
let months = query ? query.months : 0
|
||||
let mainProgramme = studyRights?.programme || ''
|
||||
let combinedProgramme = studyRights?.combinedProgramme || ''
|
||||
|
||||
let months = initMonths
|
||||
if (studyGuidanceGroup && studyGuidanceGroup?.tags?.year) {
|
||||
months = moment().diff(moment(`${studyGuidanceGroup?.tags?.year}-08-01`), 'months')
|
||||
}
|
||||
@@ -178,7 +184,9 @@ const PopulationStudents = ({
|
||||
from={from}
|
||||
mainProgramme={mainProgramme}
|
||||
months={months}
|
||||
showBachelorAndMaster={showBachelorAndMaster}
|
||||
studyGuidanceGroup={studyGuidanceGroup}
|
||||
studyRights={studyRights}
|
||||
to={to}
|
||||
variant={variant}
|
||||
visiblePanes={contentToInclude.panesToInclude}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { DoNotDisturb as DoNotDisturbIcon } from '@mui/icons-material'
|
||||
import { Alert, AlertTitle, Box, Container, Link } from '@mui/material'
|
||||
import DoNotDisturbIcon from '@mui/icons-material/DoNotDisturb'
|
||||
import Alert from '@mui/material/Alert'
|
||||
import AlertTitle from '@mui/material/AlertTitle'
|
||||
import Box from '@mui/material/Box'
|
||||
import Container from '@mui/material/Container'
|
||||
import Link from '@mui/material/Link'
|
||||
|
||||
import { PageTitle } from '@/components/material/PageTitle'
|
||||
import { useTitle } from '@/hooks/title'
|
||||
|
||||
@@ -59,8 +59,8 @@ const SingleStudyGroupContent = ({ filteredStudents, group }) => {
|
||||
|
||||
const coursesAreLoading = isLoading || isFetching
|
||||
|
||||
const creditDateFilterActive = useFilterSelector(creditDateFilter.selectors.isActive)
|
||||
const studyPlanFilterIsActive = useFilterSelector(studyPlanFilter.selectors.isActive)
|
||||
const creditDateFilterActive = useFilterSelector(creditDateFilter.selectors.isActive())
|
||||
const studyPlanFilterIsActive = useFilterSelector(studyPlanFilter.selectors.isActive())
|
||||
|
||||
const toggleCreditDateFilter = () => {
|
||||
if (creditDateFilterActive) {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { Logout as LogoutIcon, Refresh as RefreshIcon } from '@mui/icons-material'
|
||||
import { Backdrop, Box, Button, Container, Stack, Typography } from '@mui/material'
|
||||
import LogoutIcon from '@mui/icons-material/Logout'
|
||||
import RefreshIcon from '@mui/icons-material/Refresh'
|
||||
import Backdrop from '@mui/material/Backdrop'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Container from '@mui/material/Container'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Typography from '@mui/material/Typography'
|
||||
|
||||
import { random } from 'lodash'
|
||||
import { useEffect, useState } from 'react'
|
||||
import ReactHighchart from 'react-highcharts'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Check as CheckIcon } from '@mui/icons-material'
|
||||
import { Box } from '@mui/material'
|
||||
import CheckIcon from '@mui/icons-material/Check'
|
||||
import Box from '@mui/material/Box'
|
||||
|
||||
export const CheckIconWithTitle = ({ visible, title }: { visible: boolean; title?: string }) => (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center' }} title={title}>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { FormControl, MenuItem, Select } from '@mui/material'
|
||||
import FormControl from '@mui/material/FormControl'
|
||||
import MenuItem from '@mui/material/MenuItem'
|
||||
import Select from '@mui/material/Select'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useGetCurriculumsQuery, useGetCurriculumOptionsQuery } from '@/redux/curriculum'
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { TableBody, TableCell, TableContainer, TableHead, TableRow, Paper } from '@mui/material'
|
||||
import Paper from '@mui/material/Paper'
|
||||
import TableBody from '@mui/material/TableBody'
|
||||
import TableCell from '@mui/material/TableCell'
|
||||
import TableContainer from '@mui/material/TableContainer'
|
||||
import TableHead from '@mui/material/TableHead'
|
||||
import TableRow from '@mui/material/TableRow'
|
||||
|
||||
import { StyledTable } from '@/components/material/StyledTable'
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
KeyboardArrowDown as KeyboardArrowDownIcon,
|
||||
KeyboardArrowRight as KeyboardArrowRightIcon,
|
||||
} from '@mui/icons-material'
|
||||
import { Box, IconButton, TableCell, TableRow, Typography } from '@mui/material'
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'
|
||||
import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'
|
||||
import Box from '@mui/material/Box'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import TableCell from '@mui/material/TableCell'
|
||||
import TableRow from '@mui/material/TableRow'
|
||||
import Typography from '@mui/material/Typography'
|
||||
|
||||
const roundValue = (value: number | string | undefined) => {
|
||||
return typeof value === 'number' && !Number.isInteger(value) ? value.toFixed(1) : value
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Box,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogActions,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogContent from '@mui/material/DialogContent'
|
||||
import DialogContentText from '@mui/material/DialogContentText'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import Typography from '@mui/material/Typography'
|
||||
|
||||
import { pick, sampleSize } from 'lodash'
|
||||
import { MaterialReactTable, MRT_ColumnDef, useMaterialReactTable } from 'material-react-table'
|
||||
import { MaterialReactTable, MRT_ColumnDef, MRT_RowData, useMaterialReactTable } from 'material-react-table'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { utils, writeFile } from 'xlsx'
|
||||
|
||||
import { ISO_DATE_FORMAT } from '@/constants/date'
|
||||
import { getTimestamp, reformatDate } from '@/util/timeAndDate'
|
||||
|
||||
export const ExportToExcelDialog = ({
|
||||
export const ExportToExcelDialog = <T extends MRT_RowData>({
|
||||
open,
|
||||
onClose,
|
||||
exportColumns,
|
||||
@@ -26,7 +25,7 @@ export const ExportToExcelDialog = ({
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
exportColumns: MRT_ColumnDef<any>[]
|
||||
exportColumns: MRT_ColumnDef<T, any>[]
|
||||
exportData: Record<string, unknown>[]
|
||||
featureName: string
|
||||
}) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { OpenInNew as OpenInNewIcon } from '@mui/icons-material'
|
||||
import { Link, LinkOwnProps } from '@mui/material'
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew'
|
||||
import Link, { LinkOwnProps } from '@mui/material/Link'
|
||||
|
||||
/**
|
||||
* Link that opens an external website in a new tab
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Stack } from '@mui/material'
|
||||
import Stack from '@mui/material/Stack'
|
||||
|
||||
import { GetAllGraduationStatsResponse } from '@/types/api/university'
|
||||
import { GraduationTimes } from './GraduationTimes'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Stack } from '@mui/material'
|
||||
import Stack from '@mui/material/Stack'
|
||||
|
||||
import { facultyToolTips } from '@/common/InfoToolTips'
|
||||
import { FacultyProgressTable } from '@/components/material/FacultyProgressTable'
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable react/no-this-in-sfc */
|
||||
import { Typography } from '@mui/material'
|
||||
import Typography from '@mui/material/Typography'
|
||||
|
||||
import HighCharts from 'highcharts'
|
||||
import accessibility from 'highcharts/modules/accessibility'
|
||||
import exportData from 'highcharts/modules/export-data'
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { Collapse, TableBody, TableCell, TableContainer, TableHead, TableRow } from '@mui/material'
|
||||
import Collapse from '@mui/material/Collapse'
|
||||
import TableBody from '@mui/material/TableBody'
|
||||
import TableCell from '@mui/material/TableCell'
|
||||
import TableContainer from '@mui/material/TableContainer'
|
||||
import TableHead from '@mui/material/TableHead'
|
||||
import TableRow from '@mui/material/TableRow'
|
||||
|
||||
import { Fragment, useState } from 'react'
|
||||
|
||||
import { ExpandableRow } from '@/components/material/ExpandableRow'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FormControlLabel, Switch } from '@mui/material'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
import Switch from '@mui/material/Switch'
|
||||
|
||||
export const FilterOldProgrammesToggle = ({ checked, onChange }: { checked: boolean; onChange: () => void }) => {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Link } from '@mui/material'
|
||||
import Link from '@mui/material/Link'
|
||||
|
||||
import { NavLink } from 'react-router'
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Stack, Typography } from '@mui/material'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Typography from '@mui/material/Typography'
|
||||
|
||||
import { images } from '@/common'
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user