mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-13 03:13:40 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa6d2b83f5 | ||
|
|
ff36a0e8f7 | ||
|
|
71c1ce703a | ||
|
|
c94711234f | ||
|
|
563d2cb4b0 | ||
|
|
3727bd03dd | ||
|
|
efb0b98cdc | ||
|
|
cdbbf06c18 | ||
|
|
e471b1e73a | ||
|
|
1d7f9d250a | ||
|
|
0207e58fa3 | ||
|
|
aecca8426d | ||
|
|
ef0d0a5eb7 | ||
|
|
284c5b270d | ||
|
|
51f2a7573e | ||
|
|
0837e1d972 | ||
|
|
e468186af2 | ||
|
|
3fcfe56bd0 | ||
|
|
85ad68c97d | ||
|
|
ede3b8efac | ||
|
|
cd1d65b29b | ||
|
|
456fe927e7 | ||
|
|
df36d58288 | ||
|
|
918869b307 | ||
|
|
2ba06ae787 | ||
|
|
ac0dc90661 | ||
|
|
8622bd8e6d | ||
|
|
76988e3ec5 | ||
|
|
b0a9fb48d7 | ||
|
|
16d603538f | ||
|
|
7dd810cad6 | ||
|
|
42afea5e0c | ||
|
|
6d0c5e552b | ||
|
|
897c2e4c88 | ||
|
|
673ce9f9c0 | ||
|
|
77054187a0 | ||
|
|
a9c244b1f6 | ||
|
|
ed5e2da875 | ||
|
|
25d5599466 | ||
|
|
33c0aa71d9 | ||
|
|
2e4572c871 | ||
|
|
f6b14777fe | ||
|
|
f251f9456e | ||
|
|
f364eb4f67 | ||
|
|
4896f9e458 | ||
|
|
096535e470 | ||
|
|
7c949b47a7 | ||
|
|
2a6653c700 | ||
|
|
c3b7d96b06 | ||
|
|
9a3783bc45 | ||
|
|
4882d1d9d3 | ||
|
|
9575d86d9c | ||
|
|
85c7e86ed9 | ||
|
|
d527c6ba5f | ||
|
|
6d5b91782c | ||
|
|
b8762aae34 | ||
|
|
4da78df1e6 | ||
|
|
93258ad12a | ||
|
|
0028b69661 | ||
|
|
ef902b1c0c | ||
|
|
d8e3ee092f | ||
|
|
36e8c2f7f8 | ||
|
|
8763b1868e | ||
|
|
700d799b00 | ||
|
|
b0ecc3d4f5 | ||
|
|
e681d5a94f | ||
|
|
980cd8ecf0 | ||
|
|
357c0b3f44 | ||
|
|
6467f5046e | ||
|
|
21ff440ebd | ||
|
|
73228fb26f | ||
|
|
2c1807fe10 | ||
|
|
50091a3098 | ||
|
|
3ba7965402 | ||
|
|
067a53a072 | ||
|
|
9a3956832d | ||
|
|
831b638c99 | ||
|
|
38c343b8d2 | ||
|
|
c05346ad9a | ||
|
|
dd12cd3626 | ||
|
|
55eebe32a6 | ||
|
|
58592df2a0 | ||
|
|
dd110d21e3 | ||
|
|
88443f0117 | ||
|
|
accd5c9120 | ||
|
|
c11f5d78ba | ||
|
|
9f4466d246 | ||
|
|
5504f02c31 | ||
|
|
863e84e226 | ||
|
|
0ce965abe3 | ||
|
|
e9242b3063 | ||
|
|
6ccf320bb2 | ||
|
|
4e9b579843 | ||
|
|
985084864c | ||
|
|
8099dff1b6 | ||
|
|
1528ff3d80 | ||
|
|
71c61c3393 | ||
|
|
f3b1f639b1 | ||
|
|
e599e620c0 | ||
|
|
f5b081656f | ||
|
|
7af8e1a4df | ||
|
|
8c092c8d9f | ||
|
|
e0789186ba | ||
|
|
c798a4747f | ||
|
|
8f396a9913 | ||
|
|
148a5acc25 | ||
|
|
9cf1b87b3f | ||
|
|
82ed55e1f9 | ||
|
|
82041e673d | ||
|
|
1e4405aac9 | ||
|
|
dcefdd12cb | ||
|
|
fd775252b8 | ||
|
|
acd7dc65f6 | ||
|
|
a82d272a27 | ||
|
|
1bb5ddba96 | ||
|
|
ae14a55885 | ||
|
|
9308016cf3 | ||
|
|
b5252a01f8 | ||
|
|
4b22902fa4 | ||
|
|
267ebce457 | ||
|
|
ee8013cba2 | ||
|
|
4ce07eaf31 | ||
|
|
e0d4e4072d | ||
|
|
d4aeece78e | ||
|
|
02adcbc4f8 | ||
|
|
a9d44b3d76 | ||
|
|
3d310ee184 | ||
|
|
0adf1eae76 | ||
|
|
e10d3dd3c7 | ||
|
|
fce0e482d3 | ||
|
|
4d57d00ddb | ||
|
|
7cc90a4378 | ||
|
|
ff0f1a545f | ||
|
|
6eb69a036a | ||
|
|
3c8a47fc76 | ||
|
|
735ec7efa0 | ||
|
|
c33f842c9a | ||
|
|
2340e39fb4 | ||
|
|
0221d1f70e | ||
|
|
c6fe105b46 | ||
|
|
6ec1bc118d | ||
|
|
098dd5425e | ||
|
|
9eeed5094f |
@@ -43,8 +43,8 @@ What different CLI options do:
|
||||
- Cleans up any previous real data databases
|
||||
- Downloads needed database dumps from production servers.
|
||||
- Creates real data databases and populates them with downloaded dumps.
|
||||
3. Reset sis-importer data
|
||||
- Like option 2, but only for sis-importer database. Useful when developing updater microservice.
|
||||
3. Reset single database
|
||||
- Like option 2, but only for single database. Leaves other databases untouched.
|
||||
|
||||
## ⌨️ Development
|
||||
|
||||
@@ -124,7 +124,7 @@ Continuous integration (CI) works with Github actions and is defined in workflow
|
||||
|
||||
### Modules are missing after updating package.json
|
||||
|
||||
You should always install the dependencies **inside** the container to have the application **inside** the container access them. This might be the case when someone else installs a new library and you only pull the changes in package.json. Use `docker exec -it <service> sh` to get inside the container and run `npm ci` to install modules.
|
||||
You should always install the dependencies **inside** the container to have the application **inside** the container access them. Module might be missing for example when someone else installs a new library and you only pull the changes in package.json. Use `docker exec <service> npm ci` to install modules inside the container.
|
||||
|
||||
### I get the "You are not authorized to use Oodikone" -message when I try to work with anon-data
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ describe('Population Statistics', () => {
|
||||
runTestStepWithPreAndPostParts('ageFilter-header', () => {
|
||||
cy.cs('ageFilter-min').type('20')
|
||||
cy.cs('ageFilter-max').type('40')
|
||||
checkFilteringResult(43)
|
||||
checkFilteringResult(42)
|
||||
cy.cs('ageFilter-min').find('input').clear()
|
||||
cy.cs('ageFilter-max').find('input').clear()
|
||||
})
|
||||
@@ -190,7 +190,7 @@ describe('Course Statistics', () => {
|
||||
runTestStepWithPreAndPostParts('ageFilter-header', () => {
|
||||
cy.cs('ageFilter-min').type('20')
|
||||
cy.cs('ageFilter-max').type('40')
|
||||
checkFilteringResult(33)
|
||||
checkFilteringResult(32)
|
||||
cy.cs('ageFilter-min').find('input').clear()
|
||||
cy.cs('ageFilter-max').find('input').clear()
|
||||
})
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
/// <reference types="Cypress" />
|
||||
|
||||
const moment = require('moment')
|
||||
const _ = require('lodash')
|
||||
|
||||
const setPopStatsUntil = (until, includeSettings = []) => {
|
||||
cy.contains('Advanced settings').siblings().get('[data-cy=advanced-toggle]').click()
|
||||
includeSettings.forEach(setting => {
|
||||
@@ -13,6 +16,8 @@ const setPopStatsUntil = (until, includeSettings = []) => {
|
||||
describe('Population Statistics tests', () => {
|
||||
const pathToCSBach2017 =
|
||||
'/populations?months=36&semesters=FALL&semesters=SPRING&studyRights={%22programme%22%3A%22KH50_005%22}&tag&year=2017'
|
||||
const pathToCSMaster2019 =
|
||||
'/populations?months=27&semesters=FALL&semesters=SPRING&studyRights=%7B%22programme%22%3A%22MH50_009%22%7D&tag&year=2019'
|
||||
describe('when using basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/populations')
|
||||
@@ -59,6 +64,7 @@ describe('Population Statistics tests', () => {
|
||||
|
||||
it('Population statistics is usable on general level', () => {
|
||||
cy.visit(pathToCSBach2017)
|
||||
cy.cs('filtered-students')
|
||||
setPopStatsUntil('toukokuu 2020')
|
||||
|
||||
cy.get('.card').within(() => {
|
||||
@@ -69,7 +75,8 @@ describe('Population Statistics tests', () => {
|
||||
cy.contains('Excludes students with non-degree study right')
|
||||
cy.contains('Excludes students who have transferred out of this programme')
|
||||
})
|
||||
cy.contains('Courses of population').click({ force: true })
|
||||
cy.cs('filtered-students')
|
||||
cy.contains('Courses of population').click()
|
||||
|
||||
cy.intercept('/api/v3/courseyearlystats**').as('coursePage')
|
||||
// eslint-disable-next-line cypress/no-unnecessary-waiting
|
||||
@@ -123,9 +130,56 @@ describe('Population Statistics tests', () => {
|
||||
cy.contains('Credit accumulation (for 170 students)')
|
||||
})
|
||||
|
||||
it('Credit Statistics, Statistics pane works', () => {
|
||||
it('Credit Statistics, Credits Gained tab works', () => {
|
||||
cy.selectStudyProgramme('Tietojenkäsittelytieteen kandiohjelma')
|
||||
cy.contains('Credit statistics').click()
|
||||
cy.get("[data-cy='credits-gained-main-table']").should('contain', 'All students of the population')
|
||||
|
||||
const months = Math.ceil(moment.duration(moment().diff(moment('2017-08-1'))).asMonths())
|
||||
|
||||
const limits = [1, ..._.range(1, 4).map(p => Math.ceil(months * ((p * 15) / 12))), null]
|
||||
const ranges = _.range(1, limits.length).map(i => _.slice(limits, i - 1, i + 1))
|
||||
|
||||
cy.get('.credits-gained-table').should('contain', '(n=149)')
|
||||
|
||||
for (const [start, end] of ranges) {
|
||||
let value = 'credits'
|
||||
|
||||
if (start !== null) {
|
||||
value = `${start} ≤ ${value}`
|
||||
}
|
||||
|
||||
if (end !== null) {
|
||||
value = `${value} < ${end}`
|
||||
}
|
||||
|
||||
cy.get('.credits-gained-table').should('contain', value)
|
||||
}
|
||||
|
||||
cy.get("[data-cy='credits-gained-table-body'] td:nth-child(3)").then($els => {
|
||||
const sum = [...$els].map($el => parseInt($el.innerText)).reduce((a, b) => a + b, 0)
|
||||
|
||||
expect(sum).to.equal(149)
|
||||
})
|
||||
|
||||
cy.get("[data-cy='credits-gained-table-Ei valintatapaa']").should('not.exist')
|
||||
})
|
||||
|
||||
it('Credit Statistics, Credits Gained tab shows stats by admissions', () => {
|
||||
cy.visit(pathToCSMaster2019)
|
||||
cy.cs('filtered-students')
|
||||
cy.contains('Credit statistics')
|
||||
cy.get("[data-cy='credit-statistics']").click()
|
||||
cy.get('.credits-gained-divider').click()
|
||||
cy.get("[data-cy='credits-gained-table-Avoin väylä']").should('exist')
|
||||
cy.get("[data-cy='credits-gained-table-Yhteispisteet']").should('exist')
|
||||
cy.get("[data-cy='credits-gained-table-Muu']").should('exist')
|
||||
cy.get("[data-cy='credits-gained-table-Ei valintatapaa']").should('exist')
|
||||
})
|
||||
|
||||
it('Credit Statistics, Statistics pane works', () => {
|
||||
cy.selectStudyProgramme('Tietojenkäsittelytieteen kandiohjelma')
|
||||
cy.contains('Credit statistics').click({ force: true })
|
||||
cy.get("[data-cy='credit-stats-tab'] > .menu > :nth-child(2)").click()
|
||||
|
||||
cy.get("[data-cy='credit-stats-table-name-header']").should('contain', 'Statistic for n = 149 Students')
|
||||
|
||||
Generated
+34
-6593
File diff suppressed because it is too large
Load Diff
@@ -49,5 +49,9 @@
|
||||
"stylelint-config-prettier": "^8.0.2",
|
||||
"stylelint-config-standard": "^22.0.0",
|
||||
"stylelint-prettier": "^1.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.1"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+3
-3
@@ -3353,9 +3353,9 @@
|
||||
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A=="
|
||||
},
|
||||
"validator": {
|
||||
"version": "13.6.0",
|
||||
"resolved": "https://registry.npmjs.org/validator/-/validator-13.6.0.tgz",
|
||||
"integrity": "sha512-gVgKbdbHgtxpRyR8K0O6oFZPhhB5tT1jeEHZR0Znr9Svg03U0+r9DXWMrnRAB+HtCStDQKlaIZm42tVsVjqtjg=="
|
||||
"version": "13.7.0",
|
||||
"resolved": "https://registry.npmjs.org/validator/-/validator-13.7.0.tgz",
|
||||
"integrity": "sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw=="
|
||||
},
|
||||
"vary": {
|
||||
"version": "1.1.2",
|
||||
|
||||
@@ -79,7 +79,7 @@ const initializeDatabaseConnection = async () => {
|
||||
const migrations = await migrator.up()
|
||||
logger.info({ message: 'Kone Migrations up to date', meta: migrations })
|
||||
} catch (e) {
|
||||
logger.error('Kone Migration error')
|
||||
logger.error({ message: 'Kone Migration error: ', meta: e })
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
const { BIGINT, STRING, DATE } = require('sequelize')
|
||||
|
||||
module.exports = {
|
||||
up: async queryInterface => {
|
||||
await queryInterface.createTable(
|
||||
'study_guidance_group_tags',
|
||||
{
|
||||
id: {
|
||||
primaryKey: true,
|
||||
type: BIGINT,
|
||||
autoIncrement: true,
|
||||
},
|
||||
study_guidance_group_id: {
|
||||
type: STRING,
|
||||
unique: true,
|
||||
},
|
||||
study_programme: {
|
||||
type: STRING,
|
||||
},
|
||||
year: {
|
||||
type: STRING,
|
||||
},
|
||||
created_at: {
|
||||
type: DATE,
|
||||
},
|
||||
updated_at: {
|
||||
type: DATE,
|
||||
},
|
||||
},
|
||||
{ underscored: true, timestamps: true }
|
||||
)
|
||||
},
|
||||
down: async () => {},
|
||||
}
|
||||
@@ -135,6 +135,28 @@ const CustomPopulationSearch = sequelizeKone.define('custom_population_searches'
|
||||
},
|
||||
})
|
||||
|
||||
const StudyGuidanceGroupTag = sequelizeKone.define(
|
||||
'study_guidance_group_tags',
|
||||
{
|
||||
id: {
|
||||
primaryKey: true,
|
||||
type: Sequelize.BIGINT,
|
||||
autoIncrement: true,
|
||||
},
|
||||
studyGuidanceGroupId: {
|
||||
type: Sequelize.STRING,
|
||||
unique: true,
|
||||
},
|
||||
studyProgramme: {
|
||||
type: Sequelize.STRING,
|
||||
},
|
||||
year: {
|
||||
type: Sequelize.STRING,
|
||||
},
|
||||
},
|
||||
{ underscored: true, timestamps: true }
|
||||
)
|
||||
|
||||
MandatoryCourse.belongsTo(MandatoryCourseLabels, {
|
||||
foreignKey: 'label',
|
||||
sourceKey: 'id',
|
||||
@@ -157,4 +179,5 @@ module.exports = {
|
||||
CustomPopulationSearch,
|
||||
sequelizeKone,
|
||||
ExcludedCourse,
|
||||
StudyGuidanceGroupTag,
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ const customPopulationSearch = require('./routes/customPopulationSearch')
|
||||
const trends = require('./routes/trends')
|
||||
const programmeModules = require('./routes/programmeModules')
|
||||
const studyGuidanceGroups = require('./routes/studyGuidanceGroups')
|
||||
const studyProgramme = require('./routes/studyProgramme')
|
||||
|
||||
module.exports = (app, url) => {
|
||||
app.use(url, ping)
|
||||
@@ -54,6 +55,7 @@ module.exports = (app, url) => {
|
||||
app.use(url, semesters)
|
||||
app.use(url, tags)
|
||||
app.use(url, programmeModules)
|
||||
app.use(url, studyProgramme)
|
||||
app.use(`${url}/faculties`, faculties)
|
||||
app.use(`${url}/updater`, auth.roles(['admin']), updater)
|
||||
app.use(`${url}/teachers`, auth.roles(['teachers']), teachers)
|
||||
|
||||
@@ -2,6 +2,7 @@ const router = require('express').Router()
|
||||
const Student = require('../services/students')
|
||||
const userService = require('../services/userService')
|
||||
const Unit = require('../services/units')
|
||||
const { getAllStudentsUserHasInGroups } = require('../services/studyGuidanceGroups')
|
||||
|
||||
const filterStudentTags = (student, userId) => {
|
||||
return {
|
||||
@@ -13,7 +14,7 @@ const filterStudentTags = (student, userId) => {
|
||||
router.get('/students', async (req, res) => {
|
||||
const {
|
||||
roles,
|
||||
decodedToken: { userId },
|
||||
decodedToken: { userId, sisPersonId },
|
||||
query: { searchTerm },
|
||||
} = req
|
||||
|
||||
@@ -37,8 +38,14 @@ router.get('/students', async (req, res) => {
|
||||
} else {
|
||||
const unitsUserCanAccess = await userService.getUnitsFromElementDetails(userId)
|
||||
const codes = unitsUserCanAccess.map(unit => unit.id)
|
||||
let extraStudents = []
|
||||
if (roles.includes('studyGuidanceGroups')) {
|
||||
const matchingOnlyByTerm = await Student.bySearchTerm(trimmedSearchTerm)
|
||||
const studentsUserCanAccess = await getAllStudentsUserHasInGroups(sisPersonId)
|
||||
extraStudents.push(...matchingOnlyByTerm.filter(s => studentsUserCanAccess.has(s.studentNumber)))
|
||||
}
|
||||
const matchingStudents = await Student.bySearchTermAndElements(trimmedSearchTerm, codes)
|
||||
res.json(matchingStudents)
|
||||
res.json([...matchingStudents, ...extraStudents])
|
||||
}
|
||||
})
|
||||
|
||||
@@ -53,6 +60,17 @@ router.get('/students/:id', async (req, res) => {
|
||||
: res.status(200).json(filterStudentTags(results, decodedToken.id)).end()
|
||||
}
|
||||
|
||||
if (roles && roles.includes('studyGuidanceGroups')) {
|
||||
const studentsUserCanAccess = await getAllStudentsUserHasInGroups(decodedToken.sisPersonId)
|
||||
if (!studentsUserCanAccess.has(studentId)) {
|
||||
return res.status(400).json({ error: 'error finding student' }).end()
|
||||
}
|
||||
const results = await Student.withId(studentId)
|
||||
return results.error
|
||||
? res.status(400).json({ error: 'error finding student' }).end()
|
||||
: res.status(200).json(filterStudentTags(results, decodedToken.id)).end()
|
||||
}
|
||||
|
||||
const uid = req.decodedToken.userId
|
||||
const student = await Student.withId(studentId)
|
||||
if (student.error) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const router = require('express').Router()
|
||||
const { logger } = require('../util/logger')
|
||||
const { getAllGroupsAndStudents } = require('../services/studyGuidanceGroups')
|
||||
const { getAllGroupsAndStudents, changeGroupTags } = require('../services/studyGuidanceGroups')
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const {
|
||||
@@ -13,4 +13,27 @@ router.get('/', async (req, res) => {
|
||||
return res.json(await getAllGroupsAndStudents(sisPersonId))
|
||||
})
|
||||
|
||||
router.put('/:id/tags', async (req, res) => {
|
||||
const {
|
||||
decodedToken: { uid, sisPersonId },
|
||||
body: tags,
|
||||
params: { id: groupId },
|
||||
} = req
|
||||
|
||||
if (!sisPersonId) {
|
||||
logger.error(`User ${uid} tried to change study guidance group tags but personId was ${sisPersonId} in header`)
|
||||
return res.status(400).json({ error: 'Not possible to change groups without personId' })
|
||||
}
|
||||
|
||||
const groupsUserHasAccessTo = (await getAllGroupsAndStudents(sisPersonId)).map(group => group.id)
|
||||
|
||||
if (!groupsUserHasAccessTo.includes(groupId)) {
|
||||
logger.error(
|
||||
`User ${uid} tried to change study guidance group tags, but didn't have permission for the group ${groupId}`
|
||||
)
|
||||
return res.status(400).json({ error: 'Access denied' })
|
||||
}
|
||||
return res.status(200).json(await changeGroupTags({ groupId, tags }))
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
const router = require('express').Router()
|
||||
const { getBasicStatsForStudytrack, getCreditStatsForStudytrack } = require('../services/newStudyprogramme')
|
||||
const { getBasicStats, setBasicStats, getCreditStats, setCreditStats } = require('../services/analyticsService')
|
||||
const logger = require('../util/logger')
|
||||
|
||||
router.get('/v2/studyprogrammes/:id/basicstats', async (req, res) => {
|
||||
const code = req.params.id
|
||||
if (code) {
|
||||
let data = null
|
||||
try {
|
||||
data = await getBasicStats(code)
|
||||
} catch (e) {
|
||||
logger.error(`Failed to get code ${code} basic stats`)
|
||||
}
|
||||
if (!data) {
|
||||
try {
|
||||
let result
|
||||
if (code.includes('MH') || code.includes('KH')) {
|
||||
result = await getBasicStatsForStudytrack({
|
||||
studyprogramme: req.params.id,
|
||||
startDate: new Date('2017-01-01'),
|
||||
})
|
||||
} else {
|
||||
result = await getBasicStatsForStudytrack({
|
||||
studyprogramme: req.params.id,
|
||||
startDate: new Date('2000-01-01'),
|
||||
})
|
||||
}
|
||||
data = await setBasicStats(result)
|
||||
} catch (e) {
|
||||
logger.error(`Failed to update code ${code} basic stats`)
|
||||
}
|
||||
}
|
||||
return res.json({ data })
|
||||
} else {
|
||||
res.status(422).end()
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/v2/studyprogrammes/:id/creditstats', async (req, res) => {
|
||||
const code = req.params.id
|
||||
if (code) {
|
||||
let data = null
|
||||
try {
|
||||
data = await getCreditStats(code)
|
||||
} catch (e) {
|
||||
logger.error(`Failed to get code ${code} credit stats`)
|
||||
}
|
||||
if (!data) {
|
||||
try {
|
||||
let result
|
||||
if (code.includes('MH') || code.includes('KH')) {
|
||||
result = await getCreditStatsForStudytrack({
|
||||
studyprogramme: req.params.id,
|
||||
startDate: new Date('2017-01-01'),
|
||||
})
|
||||
} else {
|
||||
result = await getCreditStatsForStudytrack({
|
||||
studyprogramme: req.params.id,
|
||||
startDate: new Date('2000-01-01'),
|
||||
})
|
||||
}
|
||||
data = await setCreditStats(result)
|
||||
} catch (e) {
|
||||
logger.error(`Failed to update code ${code} credit stats`)
|
||||
}
|
||||
}
|
||||
return res.json({ data })
|
||||
} else {
|
||||
res.status(422).end()
|
||||
}
|
||||
})
|
||||
|
||||
router.use('*', (req, res, next) => next())
|
||||
|
||||
module.exports = router
|
||||
@@ -3,6 +3,8 @@ const moment = require('moment')
|
||||
|
||||
const createRedisKeyForProductivity = id => `PRODUCTIVITY_${id}`
|
||||
const createRedisKeyForThroughput = id => `THROUGHPUT_${id}`
|
||||
const createRedisKeyForBasicStats = id => `BASIC_STATS_${id}`
|
||||
const createRedisKeyForCreditStats = id => `CREDIT_STATS_${id}`
|
||||
|
||||
const getProductivity = async id => {
|
||||
const redisKey = createRedisKeyForProductivity(id)
|
||||
@@ -87,6 +89,46 @@ const patchThroughput = async data => {
|
||||
}
|
||||
}
|
||||
|
||||
const getBasicStats = async id => {
|
||||
const redisKey = createRedisKeyForBasicStats(id)
|
||||
const dataFromRedis = await redisClient.getAsync(redisKey)
|
||||
if (!dataFromRedis) return null
|
||||
return JSON.parse(dataFromRedis)
|
||||
}
|
||||
|
||||
const setBasicStats = async data => {
|
||||
const { id } = data
|
||||
const redisKey = createRedisKeyForBasicStats(id)
|
||||
const dataToRedis = {
|
||||
...data,
|
||||
status: 'DONE',
|
||||
lastUpdated: moment().format(),
|
||||
}
|
||||
const setOperationStatus = await redisClient.setAsync(redisKey, JSON.stringify(dataToRedis))
|
||||
if (setOperationStatus !== 'OK') return null
|
||||
return dataToRedis
|
||||
}
|
||||
|
||||
const getCreditStats = async id => {
|
||||
const redisKey = createRedisKeyForCreditStats(id)
|
||||
const dataFromRedis = await redisClient.getAsync(redisKey)
|
||||
if (!dataFromRedis) return null
|
||||
return JSON.parse(dataFromRedis)
|
||||
}
|
||||
|
||||
const setCreditStats = async data => {
|
||||
const { id } = data
|
||||
const redisKey = createRedisKeyForCreditStats(id)
|
||||
const dataToRedis = {
|
||||
...data,
|
||||
status: 'DONE',
|
||||
lastUpdated: moment().format(),
|
||||
}
|
||||
const setOperationStatus = await redisClient.setAsync(redisKey, JSON.stringify(dataToRedis))
|
||||
if (setOperationStatus !== 'OK') return null
|
||||
return dataToRedis
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getProductivity,
|
||||
setProductivity,
|
||||
@@ -94,4 +136,8 @@ module.exports = {
|
||||
getThroughput,
|
||||
setThroughput,
|
||||
patchThroughput,
|
||||
getBasicStats,
|
||||
setBasicStats,
|
||||
getCreditStats,
|
||||
setCreditStats,
|
||||
}
|
||||
|
||||
@@ -368,39 +368,9 @@ const byNameAndOrCodeLike = async (name, code) => {
|
||||
})
|
||||
|
||||
const courses = rawCourses
|
||||
.map(
|
||||
({
|
||||
id,
|
||||
code,
|
||||
name,
|
||||
latest_instance_date,
|
||||
is_study_module,
|
||||
coursetypecode,
|
||||
startdate,
|
||||
max_attainment_date,
|
||||
min_attainment_date,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
substitutions,
|
||||
organizations,
|
||||
}) => {
|
||||
return {
|
||||
id,
|
||||
code,
|
||||
name,
|
||||
latest_instance_date,
|
||||
is_study_module,
|
||||
coursetypecode,
|
||||
startdate,
|
||||
max_attainment_date,
|
||||
min_attainment_date,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
substitutions,
|
||||
organizations: organizations.map(o => o.id),
|
||||
}
|
||||
}
|
||||
)
|
||||
.map(course => {
|
||||
return { ...course.dataValues, organizations: course.organizations.map(o => o.id) }
|
||||
})
|
||||
.sort(a => (a.code.match(/^[A-Za-z]{3}[0-9]{1}/) ? -1 : 1))
|
||||
|
||||
let substitutionGroupIndex = 0
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
const sequelize = require('sequelize')
|
||||
const { Op } = sequelize
|
||||
const { indexOf } = require('lodash')
|
||||
const {
|
||||
Credit,
|
||||
Course,
|
||||
Organization,
|
||||
Studyright,
|
||||
StudyrightElement,
|
||||
ElementDetail,
|
||||
Transfer,
|
||||
Student,
|
||||
} = require('../models')
|
||||
const { mapToProviders } = require('../util/utils')
|
||||
|
||||
const formatStudyright = studyright => {
|
||||
const { studyrightid, studystartdate, enddate, graduated, prioritycode, extentcode, student } = studyright
|
||||
return {
|
||||
studyrightid,
|
||||
studystartdate,
|
||||
enddate,
|
||||
graduated,
|
||||
prioritycode,
|
||||
extentcode,
|
||||
studentnumber: student.studentnumber,
|
||||
}
|
||||
}
|
||||
|
||||
const getYearsArray = since => {
|
||||
const years = []
|
||||
for (let i = since; i <= new Date().getFullYear(); i++) {
|
||||
years.push(i)
|
||||
}
|
||||
return years
|
||||
}
|
||||
|
||||
const getYearsObject = years => {
|
||||
let yearsObject = {}
|
||||
for (const year of years) {
|
||||
yearsObject = { ...yearsObject, [year]: 0 }
|
||||
}
|
||||
return yearsObject
|
||||
}
|
||||
|
||||
const getStatsBasis = years => {
|
||||
return {
|
||||
graphStats: new Array(years.length).fill(0),
|
||||
tableStats: getYearsObject(years),
|
||||
}
|
||||
}
|
||||
|
||||
const isMajorStudentCredit = (studyright, attainment_date) =>
|
||||
studyright &&
|
||||
(studyright.prioritycode === 1 || studyright.prioritycode === 30) && // Is studyright state = MAIN or state = GRADUATED
|
||||
studyright.studystartdate <= attainment_date && // Has the credit been attained after studying in the programme started
|
||||
studyright.enddate >= attainment_date && // Has the credit been attained before the studyright ended
|
||||
(!studyright.canceldate || studyright.canceldate >= attainment_date) // If the studyright was cancelled, was the credit attained before it was cancelled
|
||||
|
||||
const getCreditsForStudyProgramme = async (provider, since) =>
|
||||
await Credit.findAll({
|
||||
attributes: ['id', 'course_code', 'credits', 'attainment_date', 'student_studentnumber'],
|
||||
include: {
|
||||
model: Course,
|
||||
attributes: ['code'],
|
||||
required: true,
|
||||
where: {
|
||||
is_study_module: false,
|
||||
},
|
||||
include: {
|
||||
model: Organization,
|
||||
required: true,
|
||||
where: {
|
||||
code: provider,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
credittypecode: {
|
||||
[Op.notIn]: [10, 9, 7],
|
||||
},
|
||||
isStudyModule: {
|
||||
[Op.not]: true,
|
||||
},
|
||||
attainment_date: {
|
||||
[Op.gte]: since,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const getProgrammesStudents = async studyprogramme =>
|
||||
(
|
||||
await Studyright.findAll({
|
||||
attributes: ['studyrightid', 'studystartdate', 'enddate', 'graduated', 'prioritycode', 'extentcode'],
|
||||
include: [
|
||||
{
|
||||
model: StudyrightElement,
|
||||
required: true,
|
||||
where: {
|
||||
code: {
|
||||
[Op.in]: [studyprogramme],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
model: Student,
|
||||
attributes: ['studentnumber'],
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
).map(formatStudyright)
|
||||
|
||||
const getTransferredCredits = async (provider, since) =>
|
||||
await Credit.findAll({
|
||||
attributes: ['id', 'course_code', 'credits', 'attainment_date', 'credittypecode', 'student_studentnumber'],
|
||||
include: {
|
||||
model: Course,
|
||||
attributes: ['code'],
|
||||
required: true,
|
||||
where: {
|
||||
is_study_module: false,
|
||||
},
|
||||
include: {
|
||||
model: Organization,
|
||||
required: true,
|
||||
where: {
|
||||
code: provider,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
credittypecode: {
|
||||
[Op.eq]: [9],
|
||||
},
|
||||
attainment_date: {
|
||||
[Op.gte]: since,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const startedStudyrights = async (studytrack, since) =>
|
||||
await Studyright.findAll({
|
||||
include: {
|
||||
model: StudyrightElement,
|
||||
required: true,
|
||||
include: {
|
||||
model: ElementDetail,
|
||||
required: true,
|
||||
where: {
|
||||
code: studytrack,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
studystartdate: {
|
||||
[Op.gte]: since,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const graduatedStudyRights = async (studytrack, since) =>
|
||||
await Studyright.findAll({
|
||||
include: {
|
||||
model: StudyrightElement,
|
||||
required: true,
|
||||
include: {
|
||||
model: ElementDetail,
|
||||
required: true,
|
||||
where: {
|
||||
code: studytrack,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
graduated: 1,
|
||||
enddate: {
|
||||
[Op.gte]: since,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const cancelledStudyRights = async (studytrack, since) => {
|
||||
return await Studyright.findAll({
|
||||
include: {
|
||||
model: StudyrightElement,
|
||||
required: true,
|
||||
where: {
|
||||
code: {
|
||||
[Op.eq]: studytrack,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
canceldate: {
|
||||
[Op.gte]: since,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const transfersAway = async (studytrack, startDate) => {
|
||||
return await Transfer.findAll({
|
||||
where: {
|
||||
transferdate: {
|
||||
[Op.gte]: startDate,
|
||||
},
|
||||
sourcecode: studytrack,
|
||||
},
|
||||
distinct: true,
|
||||
col: 'studentnumber',
|
||||
})
|
||||
}
|
||||
|
||||
const transfersTo = async (studytrack, startDate) => {
|
||||
return await Transfer.findAll({
|
||||
where: {
|
||||
transferdate: {
|
||||
[Op.gte]: startDate,
|
||||
},
|
||||
targetcode: studytrack,
|
||||
},
|
||||
distinct: true,
|
||||
col: 'studentnumber',
|
||||
})
|
||||
}
|
||||
|
||||
const getGraduatedStats = async (studytrack, startDate, years) => {
|
||||
const studyrights = await graduatedStudyRights(studytrack, startDate)
|
||||
const { graphStats, tableStats } = getStatsBasis(years)
|
||||
|
||||
studyrights.forEach(({ enddate }) => {
|
||||
const graduationYear = enddate.getFullYear()
|
||||
graphStats[indexOf(years, graduationYear)] += 1
|
||||
tableStats[graduationYear] += 1
|
||||
})
|
||||
return { graphStats, tableStats }
|
||||
}
|
||||
|
||||
const getStartedStats = async (studytrack, startDate, years) => {
|
||||
const studyrights = await startedStudyrights(studytrack, startDate)
|
||||
const { graphStats, tableStats } = getStatsBasis(years)
|
||||
|
||||
studyrights.forEach(({ studystartdate }) => {
|
||||
const startYear = studystartdate.getFullYear()
|
||||
graphStats[indexOf(years, startYear)] += 1
|
||||
tableStats[startYear] += 1
|
||||
})
|
||||
return { graphStats, tableStats }
|
||||
}
|
||||
|
||||
const getCancelledStats = async (studytrack, startDate, years) => {
|
||||
const studyrights = await cancelledStudyRights(studytrack, startDate)
|
||||
const { graphStats, tableStats } = getStatsBasis(years)
|
||||
|
||||
studyrights.forEach(({ canceldate }) => {
|
||||
const cancelYear = canceldate.getFullYear()
|
||||
graphStats[indexOf(years, cancelYear)] += 1
|
||||
tableStats[cancelYear] += 1
|
||||
})
|
||||
|
||||
return { graphStats, tableStats }
|
||||
}
|
||||
|
||||
const getTransferredAwayStats = async (studytrack, startDate, years) => {
|
||||
const studyrights = await transfersAway(studytrack, startDate)
|
||||
const { graphStats, tableStats } = getStatsBasis(years)
|
||||
|
||||
studyrights.forEach(({ transferdate }) => {
|
||||
const transferYear = transferdate.getFullYear()
|
||||
graphStats[indexOf(years, transferYear)] += 1
|
||||
tableStats[transferYear] += 1
|
||||
})
|
||||
|
||||
return { graphStats, tableStats }
|
||||
}
|
||||
|
||||
const getTransferredToStats = async (studytrack, startDate, years) => {
|
||||
const studyrights = await transfersTo(studytrack, startDate)
|
||||
const { graphStats, tableStats } = getStatsBasis(years)
|
||||
|
||||
studyrights.forEach(({ transferdate }) => {
|
||||
const transferYear = transferdate.getFullYear()
|
||||
graphStats[indexOf(years, transferYear)] += 1
|
||||
tableStats[transferYear] += 1
|
||||
})
|
||||
|
||||
return { graphStats, tableStats }
|
||||
}
|
||||
|
||||
const getRegularCreditStats = async (studytrack, startDate, years) => {
|
||||
const providercode = mapToProviders([studytrack])[0]
|
||||
const studyrights = await getProgrammesStudents(studytrack)
|
||||
const credits = await getCreditsForStudyProgramme(providercode, startDate)
|
||||
|
||||
let majors = getStatsBasis(years)
|
||||
let nonMajors = getStatsBasis(years)
|
||||
|
||||
credits.forEach(({ student_studentnumber, attainment_date, credits }) => {
|
||||
const studyright = studyrights.find(studyright => studyright.studentnumber == student_studentnumber)
|
||||
const attainmentYear = attainment_date.getFullYear()
|
||||
|
||||
// Map all credits for the studyprogramme and
|
||||
// divide them into major students' and nonmajors' credits by year
|
||||
if (isMajorStudentCredit(studyright, attainment_date)) {
|
||||
majors.graphStats[indexOf(years, attainmentYear)] += credits || 0
|
||||
majors.tableStats[attainmentYear] += credits || 0
|
||||
} else {
|
||||
nonMajors.graphStats[indexOf(years, attainmentYear)] += credits || 0
|
||||
nonMajors.tableStats[attainmentYear] += credits || 0
|
||||
}
|
||||
})
|
||||
|
||||
return { majors, nonMajors }
|
||||
}
|
||||
|
||||
const getTransferredCreditStats = async (studytrack, startDate, years) => {
|
||||
const providercode = mapToProviders([studytrack])[0]
|
||||
const credits = await getTransferredCredits(providercode, startDate)
|
||||
const { graphStats, tableStats } = getStatsBasis(years)
|
||||
|
||||
credits.forEach(({ attainment_date, credits }) => {
|
||||
const attainmentYear = attainment_date.getFullYear()
|
||||
graphStats[indexOf(years, attainmentYear)] += credits || 0
|
||||
tableStats[attainmentYear] += credits || 0
|
||||
})
|
||||
|
||||
return { graphStats, tableStats }
|
||||
}
|
||||
|
||||
const getCreditStatsForStudytrack = async ({ studyprogramme, startDate }) => {
|
||||
const years = getYearsArray(startDate.getFullYear())
|
||||
const { majors, nonMajors } = await getRegularCreditStats(studyprogramme, startDate, years)
|
||||
const transferred = await getTransferredCreditStats(studyprogramme, startDate, years)
|
||||
|
||||
const reversedYears = getYearsArray(startDate.getFullYear()).reverse()
|
||||
const tableStats = reversedYears.map(year => [
|
||||
year,
|
||||
majors.tableStats[year],
|
||||
nonMajors.tableStats[year],
|
||||
transferred.tableStats[year],
|
||||
])
|
||||
|
||||
return {
|
||||
id: studyprogramme,
|
||||
years,
|
||||
tableStats,
|
||||
graphStats: [
|
||||
{
|
||||
name: 'Major students credits',
|
||||
data: majors.graphStats,
|
||||
},
|
||||
{
|
||||
name: 'Non-major students credits',
|
||||
data: nonMajors.graphStats,
|
||||
},
|
||||
{
|
||||
name: 'Transferred credits',
|
||||
data: transferred.graphStats,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const getBasicStatsForStudytrack = async ({ studyprogramme, startDate }) => {
|
||||
const years = getYearsArray(startDate.getFullYear())
|
||||
const started = await getStartedStats(studyprogramme, startDate, years)
|
||||
const graduated = await getGraduatedStats(studyprogramme, startDate, years)
|
||||
const cancelled = await getCancelledStats(studyprogramme, startDate, years)
|
||||
const transferredAway = await getTransferredAwayStats(studyprogramme, startDate, years)
|
||||
const transferredTo = await getTransferredToStats(studyprogramme, startDate, years)
|
||||
|
||||
const reversedYears = getYearsArray(startDate.getFullYear()).reverse()
|
||||
const tableStats = reversedYears.map(year => [
|
||||
year,
|
||||
started.tableStats[year],
|
||||
graduated.tableStats[year],
|
||||
cancelled.tableStats[year],
|
||||
transferredAway.tableStats[year],
|
||||
transferredTo.tableStats[year],
|
||||
])
|
||||
|
||||
return {
|
||||
id: studyprogramme,
|
||||
years,
|
||||
graphStats: [
|
||||
{
|
||||
name: 'Started',
|
||||
data: started.graphStats,
|
||||
},
|
||||
{
|
||||
name: 'Graduated',
|
||||
data: graduated.graphStats,
|
||||
},
|
||||
{
|
||||
name: 'Cancelled',
|
||||
data: cancelled.graphStats,
|
||||
},
|
||||
{
|
||||
name: 'Transferred away',
|
||||
data: transferredAway.graphStats,
|
||||
},
|
||||
{
|
||||
name: 'Transferred To',
|
||||
data: transferredTo.graphStats,
|
||||
},
|
||||
],
|
||||
tableStats,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getBasicStatsForStudytrack,
|
||||
getCreditStatsForStudytrack,
|
||||
}
|
||||
@@ -494,6 +494,7 @@ const studentnumbersWithAllStudyrightElements = async (
|
||||
if (!newestStudytrack) return false
|
||||
return studyRights.includes(newestStudytrack.code)
|
||||
})
|
||||
|
||||
const studentnumberlist = filteredStudentnumbers.length > 0 ? filteredStudentnumbers : studentnumbers
|
||||
|
||||
// fetch students that have transferred out of the programme and filter out these studentnumbers
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const { getImporterClient } = require('../util/importerClient')
|
||||
const importerClient = getImporterClient()
|
||||
const { StudyGuidanceGroupTag } = require('../models/models_kone')
|
||||
|
||||
const getAllStudentsUserHasInGroups = async sisPersonId => {
|
||||
if (!importerClient) {
|
||||
@@ -19,10 +20,33 @@ const getAllGroupsAndStudents = async sisPersonId => {
|
||||
return []
|
||||
}
|
||||
const { data } = await importerClient.get(`/person-groups/person/${sisPersonId}`)
|
||||
return Object.values(data)
|
||||
const tagsByGroupId = (await StudyGuidanceGroupTag.findAll()).reduce((acc, curr) => {
|
||||
const { studyProgramme, year } = curr
|
||||
return { ...acc, [curr.studyGuidanceGroupId]: { studyProgramme, year } }
|
||||
}, {})
|
||||
return Object.values(data).map(group => ({ ...group, tags: tagsByGroupId[group.id] }))
|
||||
}
|
||||
|
||||
const changeGroupTags = async ({ groupId, tags }) => {
|
||||
const { studyProgramme, year } = tags
|
||||
const tagToUpdate = studyProgramme ? { studyProgramme } : { year }
|
||||
|
||||
const [result] = await StudyGuidanceGroupTag.upsert(
|
||||
{
|
||||
studyGuidanceGroupId: groupId,
|
||||
...tagToUpdate,
|
||||
},
|
||||
{
|
||||
where: {
|
||||
studyGuidanceGroupId: groupId,
|
||||
},
|
||||
}
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAllStudentsUserHasInGroups,
|
||||
getAllGroupsAndStudents,
|
||||
changeGroupTags,
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ const getTotalOpenUniCreditsOfCoursesBetween = async (a, b, alias = 'sum', alias
|
||||
return sequelize.query(
|
||||
`
|
||||
SELECT SUM(cr.credits) AS ${alias}, COUNT(DISTINCT(cr.student_studentnumber)) AS ${alias2},
|
||||
o.code AS organizationcode, co.code, co.id
|
||||
o.code AS organizationcode, co.code, co.id, co.name
|
||||
FROM credit cr
|
||||
INNER JOIN course co ON cr.course_code = co.code
|
||||
INNER JOIN course_providers cp ON cp.coursecode = co.id
|
||||
@@ -245,6 +245,27 @@ const getOrgStats = (stats, orgId) =>
|
||||
},
|
||||
}
|
||||
|
||||
const getOrgsOfOpenUniReqularCounterparts = async codes => {
|
||||
const rows = await sequelize.query(
|
||||
`
|
||||
SELECT co.id, co.code, cp.organizationcode, org.code AS organization
|
||||
FROM course co, course_providers cp, organization org
|
||||
WHERE co.code IN(:codes)
|
||||
AND co.id = cp.coursecode
|
||||
AND org.id = cp.organizationcode;
|
||||
`,
|
||||
{
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
replacements: { codes },
|
||||
}
|
||||
)
|
||||
|
||||
return rows.reduce((acc, { code, organization }) => {
|
||||
acc[code] = organization
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
const calculateStatusStatistics = async (unixMillis, showByYear) => {
|
||||
const YEAR_TO_MILLISECONDS = 31556952000
|
||||
/* Memoize parses booleans into strings... */
|
||||
@@ -314,6 +335,21 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
|
||||
Organization.findAll(),
|
||||
])
|
||||
|
||||
const isOpenUniCourseCode = code => code.match(/^AY?(.+?)(?:en|fi|sv)?$/)
|
||||
|
||||
const unifyOpenUniversity = code => {
|
||||
const regexresult = isOpenUniCourseCode(code)
|
||||
if (!regexresult) return code
|
||||
return regexresult[1]
|
||||
}
|
||||
|
||||
const fromAyToNormalCourses = [
|
||||
...new Set(_.flatten(yearlyOpenUniAccCredits).map(c => unifyOpenUniversity(c.code))),
|
||||
...new Set(_.flatten(yearlyOpenUniTotalCredits).map(c => unifyOpenUniversity(c.code))),
|
||||
]
|
||||
|
||||
const orgsOfOpenUniReqularCounterparts = await getOrgsOfOpenUniReqularCounterparts(fromAyToNormalCourses)
|
||||
|
||||
const facultyProgrammes = facultiesAndProgrammesForTrends
|
||||
|
||||
const yearlyOrgStatPromises = yearRange.map(async year => {
|
||||
@@ -380,7 +416,6 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
|
||||
}, {})
|
||||
|
||||
programmeToFaculties['H906'] = ['H906']
|
||||
programmeToFaculties['H930'] = ['H930']
|
||||
|
||||
const providerToProgramme = elementDetails.reduce((res, curr) => {
|
||||
const [p] = mapToProviders([curr.code])
|
||||
@@ -418,17 +453,64 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
|
||||
return _.mergeWith(a, b, sumMerge)
|
||||
}
|
||||
|
||||
const refactoredOpenUniCredits = [..._.flatten(yearlyOpenUniAccCredits), ..._.flatten(yearlyOpenUniTotalCredits)].map(
|
||||
ele => {
|
||||
const org = orgsOfOpenUniReqularCounterparts[unifyOpenUniversity(ele.code)]
|
||||
return {
|
||||
...ele,
|
||||
organizationcode: org ? org : 'H930',
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/* Calculate course level stats and group by providers */
|
||||
const coursesGroupedByProvider = Object.entries(
|
||||
_.groupBy(
|
||||
[
|
||||
..._.flatten(yearlyAccCredits),
|
||||
..._.flatten(yearlyTotalCredits),
|
||||
..._.flatten(yearlyOpenUniAccCredits),
|
||||
..._.flatten(yearlyOpenUniTotalCredits),
|
||||
],
|
||||
'organizationcode'
|
||||
_.groupBy([..._.flatten(yearlyAccCredits), ..._.flatten(yearlyTotalCredits)], 'organizationcode')
|
||||
).reduce((acc, [organizationcode, courseCredits]) => {
|
||||
acc[organizationcode] = Object.entries(_.groupBy(courseCredits, 'code')).reduce(
|
||||
(acc, [courseCode, yearlyInstances]) => {
|
||||
acc[courseCode] = {
|
||||
yearly: {},
|
||||
name: yearlyInstances[0].name,
|
||||
type: 'course',
|
||||
code: courseCode,
|
||||
}
|
||||
|
||||
const array = _.range(2017, startYear + 1)
|
||||
|
||||
array.forEach(year => {
|
||||
acc[courseCode]['yearly'][year] = {}
|
||||
acc[courseCode]['yearly'][year]['acc'] = 0
|
||||
acc[courseCode]['yearly'][year]['accStudents'] = 0
|
||||
acc[courseCode]['yearly'][year]['total'] = undefined
|
||||
acc[courseCode]['yearly'][year]['totalStudents'] = undefined
|
||||
})
|
||||
|
||||
yearlyInstances.forEach(instance => {
|
||||
if (!acc[courseCode]['yearly'][instance.year]) acc[courseCode]['yearly'][instance.year] = {}
|
||||
|
||||
if (instance.acc !== undefined) {
|
||||
acc[courseCode]['yearly'][instance.year]['acc'] = instance.acc
|
||||
acc[courseCode]['yearly'][instance.year]['accStudents'] = Number(instance.students)
|
||||
} else {
|
||||
acc[courseCode]['yearly'][instance.year]['total'] = instance.total
|
||||
acc[courseCode]['yearly'][instance.year]['totalStudents'] = Number(instance.students)
|
||||
}
|
||||
})
|
||||
|
||||
acc[courseCode]['current'] = _.get(acc, [courseCode, 'yearly', startYear, 'acc']) || 0
|
||||
acc[courseCode]['previous'] = _.get(acc, [courseCode, 'yearly', startYear - 1, 'acc']) || 0
|
||||
acc[courseCode]['currentStudents'] = _.get(acc, [courseCode, 'yearly', startYear, 'accStudents']) || 0
|
||||
acc[courseCode]['previousStudents'] = _.get(acc, [courseCode, 'yearly', startYear - 1, 'accStudents']) || 0
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const openUniCoursesGroupedByProvider = Object.entries(
|
||||
_.groupBy(refactoredOpenUniCredits, 'organizationcode')
|
||||
).reduce((acc, [organizationcode, courseCredits]) => {
|
||||
acc[organizationcode] = Object.entries(_.groupBy(courseCredits, 'code')).reduce(
|
||||
(acc, [courseCode, yearlyInstances]) => {
|
||||
@@ -477,7 +559,7 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
|
||||
const programme = providerToProgramme[organizationcode]
|
||||
|
||||
if (programme && programme.code) {
|
||||
if (programme.code === 'H906' || programme.code === 'H930') {
|
||||
if (programme.code === 'H906') {
|
||||
const courseValues = Object.values(courses)
|
||||
const yearlyValues = courseValues.map(c => c.yearly)
|
||||
|
||||
@@ -519,6 +601,34 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const openUniGroupedByProgramme = Object.entries(openUniCoursesGroupedByProvider).reduce(
|
||||
(acc, [organizationcode, courses]) => {
|
||||
const programme = providerToProgramme[organizationcode]
|
||||
|
||||
if (programme && programme.code) {
|
||||
const courseValues = Object.values(courses)
|
||||
const yearlyValues = courseValues.map(c => c.yearly)
|
||||
|
||||
acc[programme.code] = {
|
||||
type: 'programme',
|
||||
code: programme.code,
|
||||
name: programme.name,
|
||||
drill: courses,
|
||||
yearly: _.mergeWith({}, ...yearlyValues, mergele),
|
||||
current: _.sumBy(courseValues, 'current') | undefined,
|
||||
previous: _.sumBy(courseValues, 'previous') | undefined,
|
||||
currentStudents: _.sumBy(courseValues, 'currentStudents') || 'undefined',
|
||||
previousStudents: _.sumBy(courseValues, 'previousStudents') | 'undefined',
|
||||
}
|
||||
|
||||
return acc
|
||||
}
|
||||
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
/* Group programmes into faculties and calculate faculty level stats */
|
||||
const groupedByFaculty = Object.entries(groupedByProgramme).reduce((acc, [programmeCode, programmeStats]) => {
|
||||
const facultyCodes = programmeToFaculties[programmeCode]
|
||||
@@ -569,7 +679,31 @@ const calculateStatusStatistics = async (unixMillis, showByYear) => {
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return groupedByFaculty
|
||||
const openUniGroupedByFaculty = Object.entries(openUniGroupedByProgramme).reduce(
|
||||
(acc, [programmeCode, programmeStats]) => {
|
||||
const facultyCode = 'H930'
|
||||
|
||||
if (!acc[facultyCode]) {
|
||||
acc[facultyCode] = {
|
||||
type: 'faculty',
|
||||
code: facultyCode,
|
||||
drill: {},
|
||||
name: facultyCodeToFaculty[facultyCode] ? facultyCodeToFaculty[facultyCode].name : null,
|
||||
yearly: {},
|
||||
current: 0,
|
||||
previous: 0,
|
||||
}
|
||||
}
|
||||
|
||||
acc[facultyCode]['drill'][programmeCode] = programmeStats
|
||||
acc[facultyCode]['yearly'] = _.mergeWith(acc[facultyCode]['yearly'], programmeStats.yearly, mergele)
|
||||
acc[facultyCode]['current'] += programmeStats.current
|
||||
acc[facultyCode]['previous'] += programmeStats.previous
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
return { ...groupedByFaculty, ...openUniGroupedByFaculty }
|
||||
}
|
||||
|
||||
const getStatus = async (unixMillis, showByYear, doRefresh = false) => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"root": true,
|
||||
"env": {
|
||||
"browser": true
|
||||
"browser": true,
|
||||
"node": true
|
||||
},
|
||||
"parser": "@babel/eslint-parser",
|
||||
"parserOptions": {
|
||||
@@ -30,7 +31,7 @@
|
||||
"react/no-unescaped-entities": "off",
|
||||
"react/prop-types": "off",
|
||||
"react/prefer-stateless-function": "error",
|
||||
"import/no-unresolved": "error",
|
||||
"import/no-unresolved": "off",
|
||||
"react/jsx-no-bind": "error",
|
||||
"prefer-object-spread/prefer-object-spread": "error",
|
||||
"babel/no-invalid-this": "error",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Generated
+85
-22596
File diff suppressed because it is too large
Load Diff
@@ -6,9 +6,10 @@
|
||||
"build": "DISABLE_ESLINT_PLUGIN=true react-scripts build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.6.2",
|
||||
"@sentry/browser": "^6.9.0",
|
||||
"@sentry/tracing": "^6.11.0",
|
||||
"axios": "^0.21.1",
|
||||
"axios": "^0.21.2",
|
||||
"formik": "^2.2.9",
|
||||
"highcharts": "^9.1.2",
|
||||
"highcharts-custom-events": "^3.0.10",
|
||||
@@ -22,18 +23,14 @@
|
||||
"qs": "^6.10.1",
|
||||
"query-string": "^6.14.1",
|
||||
"react": "^16.14.0",
|
||||
"react-context-devtool": "^2.0.3",
|
||||
"react-datetime": "^3.0.4",
|
||||
"react-dom": "^16.14.0",
|
||||
"react-highcharts": "^16.1.0",
|
||||
"react-markdown": "^6.0.2",
|
||||
"react-redux": "^7.2.4",
|
||||
"react-redux": "^7.2.6",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-scripts": "4.0.3",
|
||||
"react-scroll-to-component": "^1.0.2",
|
||||
"redux": "^4.1.0",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"reselect": "^4.0.0",
|
||||
"semantic-ui-css": "^2.4.1",
|
||||
"semantic-ui-react": "^2.0.3",
|
||||
"unfuck-spa-shibboleth-session": "^3.1.3",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import FilterContextProvider from './components/FilterTray/FilterContextProvider'
|
||||
import { LanguageProvider } from './components/LanguagePicker/useLanguage'
|
||||
|
||||
@@ -9,8 +8,4 @@ const CommonContext = ({ children }) => (
|
||||
</LanguageProvider>
|
||||
)
|
||||
|
||||
CommonContext.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
}
|
||||
|
||||
export default CommonContext
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import axios from 'axios'
|
||||
import * as Sentry from '@sentry/browser'
|
||||
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
|
||||
import { getMocked, setMocking } from '../common'
|
||||
import { apiBasePath, isDev } from '../conf'
|
||||
import { loginPrefix, logoutPrefix } from '../redux/auth'
|
||||
|
||||
const getHeaders = () => {
|
||||
export const getHeaders = () => {
|
||||
// Set up dev user for development environment, mimicking production admin user
|
||||
const devUserHeaders = {
|
||||
uid: 'mluukkai',
|
||||
@@ -19,16 +21,16 @@ const getHeaders = () => {
|
||||
|
||||
export const api = axios.create({ baseURL: apiBasePath, headers: getHeaders() })
|
||||
|
||||
const types = {
|
||||
attempt: prefix => `${prefix}ATTEMPT`,
|
||||
failure: prefix => `${prefix}FAILURE`,
|
||||
success: prefix => `${prefix}SUCCESS`,
|
||||
export const actionSuffixes = {
|
||||
attempt: `ATTEMPT`,
|
||||
failure: `FAILURE`,
|
||||
success: `SUCCESS`,
|
||||
}
|
||||
|
||||
export const actionTypes = prefix => ({
|
||||
attempt: types.attempt(prefix),
|
||||
failure: types.failure(prefix),
|
||||
success: types.success(prefix),
|
||||
attempt: `${prefix}${actionSuffixes.attempt}`,
|
||||
failure: `${prefix}${actionSuffixes.failure}`,
|
||||
success: `${prefix}${actionSuffixes.success}`,
|
||||
})
|
||||
|
||||
export const logout = async () => {
|
||||
@@ -69,7 +71,8 @@ export const callController = (route, prefix, data, method = 'get', query, param
|
||||
params,
|
||||
onProgress,
|
||||
}
|
||||
return { type: `${prefix}ATTEMPT`, requestSettings }
|
||||
const { attempt } = actionTypes(prefix)
|
||||
return { type: attempt, requestSettings }
|
||||
}
|
||||
|
||||
const handleError = (err, actionHistory = []) => {
|
||||
@@ -90,20 +93,24 @@ export const handleRequest = store => next => async action => {
|
||||
const { requestSettings } = action
|
||||
if (requestSettings) {
|
||||
const { route, method, data, prefix, query, params, onProgress } = requestSettings
|
||||
const { success, failure } = actionTypes(prefix)
|
||||
try {
|
||||
const res = await callApi(route, method, data, params, 0, onProgress)
|
||||
store.dispatch({ type: `${prefix}SUCCESS`, response: res.data, query })
|
||||
store.dispatch({ type: success, response: res.data, query })
|
||||
} catch (e) {
|
||||
store.dispatch({ type: `${prefix}FAILURE`, response: e, query })
|
||||
store.dispatch({ type: failure, response: e, query })
|
||||
handleError(e, store.getState().actionHistory)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const handleAuth = store => next => async action => {
|
||||
const loginTypes = actionTypes(loginPrefix)
|
||||
const logoutTypes = actionTypes(logoutPrefix)
|
||||
next(action)
|
||||
const { type } = action
|
||||
if (type === 'LOGIN_ATTEMPT') {
|
||||
if (type === loginTypes.attempt) {
|
||||
const { success, failure } = loginTypes
|
||||
try {
|
||||
let token
|
||||
const mocked = getMocked()
|
||||
@@ -118,18 +125,39 @@ export const handleAuth = store => next => async action => {
|
||||
...api.defaults.headers.common,
|
||||
'x-access-token': token,
|
||||
}
|
||||
store.dispatch({ type: 'LOGIN_SUCCESS', token })
|
||||
store.dispatch({ type: success, token })
|
||||
} catch (err) {
|
||||
store.dispatch({ type: 'LOGIN_FAILURE' })
|
||||
store.dispatch({ type: failure })
|
||||
handleError(err, store.getState().actionHistory)
|
||||
}
|
||||
} else if (type === 'LOGOUT_ATTEMPT') {
|
||||
} else if (type === loginTypes.attempt) {
|
||||
const { success, failure } = logoutTypes
|
||||
try {
|
||||
await logout()
|
||||
store.dispatch({ type: 'LOGOUT_SUCCESSFUL' })
|
||||
store.dispatch({ type: success })
|
||||
} catch (err) {
|
||||
store.dispatch({ type: 'LOGOUT_FAILURE' })
|
||||
store.dispatch({ type: failure })
|
||||
handleError(err, store.getState().actionHistory)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Redux-toolkit query based API
|
||||
// Api can be extended with enhanceEndpoints method
|
||||
// All tags used for invalidating cache must be defined here
|
||||
export const RTKApi = createApi({
|
||||
reducerPath: 'api',
|
||||
tagTypes: ['StudyGuidanceGroups'],
|
||||
baseQuery: fetchBaseQuery({
|
||||
baseUrl: apiBasePath,
|
||||
prepareHeaders: (headers, { getState }) => {
|
||||
// Get token from state and add it to headers
|
||||
const token = getState().auth.encodedToken
|
||||
if (token) headers.set('x-access-token', token)
|
||||
// Add possible default headers
|
||||
Object.entries(getHeaders()).forEach(([key, value]) => headers.set(key, value))
|
||||
return headers
|
||||
},
|
||||
}),
|
||||
endpoints: () => ({}),
|
||||
})
|
||||
|
||||
@@ -57,19 +57,7 @@ export default {
|
||||
Nuoli kuvaa muutosta, paljonko opintopisteitä on kertynyt tämänhetkisen lukuvuoden alusta tarkastelupäivään mennessä
|
||||
suhteessa edellisen lukuvuoden alusta vastaavaan tarkastelupäivään viime vuonna. Vain suoritetut ja hyväksiluetut opintopisteet huomioidaan.
|
||||
|
||||
Tilannekuvassa voi porautua kortteja klikkaamalla ensin ohjelmatasolle ja edelleen kurssitasolle. Asetukset-nappi avaa valikon, josta voi valita missä muodossa data halutaan nähdä.
|
||||
|
||||
Valinta "Näytä edelliset vuodet" näyttää vuosittaisen opintopiste-kertymän tähän päivään
|
||||
mennessä vuonna X sekä koko lukuvuoden X opintopisteet muodossa "kerääntymä vuonna X / koko lukuvuoden X opintopisteet".
|
||||
|
||||
Valinta "Näytä kalenterivuosittain" laskee kertymän kalenterivuoden alusta,
|
||||
eikä lukuvuoden alusta.
|
||||
|
||||
Valinta "Näytä suhteutettuna opiskelijoiden määrään" näyttää opintopistemäärät suhteutettuna kyseisen ajanjakson aikana kyseisen organisaation alaisuuteen kuuluvia kursseja suorittaineiden opiskelijoiden määrään. Tämä luku sisältää myös avoimen yliopiston opiskelijat.
|
||||
|
||||
Kohdasta "Näytä päivänä" voi valita tarkastelupäivän, joka on oletusarvoisesti tämänhetkinen päivä.
|
||||
|
||||
Avoimen yliopiston suorituksia ei vielä lasketa mukaan.
|
||||
Tilannekuvassa voi porautua kortteja klikkaamalla ensin ohjelmatasolle ja edelleen kurssitasolle. Asetukset-nappi avaa valikon, josta voi valita missä muodossa data halutaan nähdä. Mahdolliset valinnat ovat selitettyinä alla.
|
||||
`,
|
||||
statusGraduated: `
|
||||
Tilannekuva näyttää "reaaliaikaisesti" eri tiedekuntien ja ohjelmien valmistumiset nykyisen lukuvuoden aikana
|
||||
|
||||
@@ -3,6 +3,7 @@ import PopulationOverview from './populationOverview'
|
||||
import CoolDataScience from './coolDataScience'
|
||||
import CourseStatistics from './courseStatistics'
|
||||
import CoursePopulation from './coursePopulation'
|
||||
import Studyprogramme from './studyprogramme'
|
||||
|
||||
export default {
|
||||
PopulationStatistics,
|
||||
@@ -10,4 +11,5 @@ export default {
|
||||
CoolDataScience,
|
||||
CourseStatistics,
|
||||
CoursePopulation,
|
||||
Studyprogramme,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export default {
|
||||
StudentsOfTheStudyprogramme: `
|
||||
**Started**: Sisältää kyseisenä vuonna aloitetut opinto-oikeudet riippumatta siitä, minä vuonna kyseinen opiskeluoikeus on myönnetty. Opiskelija on myös voinut keskeyttää opintonsa tai siirtyä pois ohjelmasta.\n
|
||||
**Cancelled**: Sisältää kyseisenä vuonna perutut opiskeluoikeudet riippumatta siitä, onko kyseinen opiskelija ikinä edes alkanut opiskelemaan ohjelmassa tai opiskelun aloitusvuodesta.
|
||||
|
||||
**Transferred away**: Sisältää kyseisenä vuonna pois ohjelmasta siirtyneet opinto-oikeudet. Sama opiskelija voi esiintyä tilastoissa useana vuonna. Kyseiset opiskelijat ovat saattaneet aloittaa ohjelmassa tai eivät koskaan ole aloittaneet ohjelmassa.
|
||||
|
||||
**Transferred to**: Sisältää kyseisenä vuonna ohjelmaan siirtyneet opinto-oikeudet.
|
||||
|
||||
HUOM! Ohjelmaan siirtyneiden määrä on erityisen suuri vuonna 2020, jolloin vanhoista koulutusohjelmista luovuttiin ja opiskelijat siirtyivät uusiin ohjelmiin.
|
||||
`,
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Segment, Popup, Loader, Dimmer, Icon, Accordion, Checkbox, Message, Form } from 'semantic-ui-react'
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import { useSelector, useDispatch } from 'react-redux'
|
||||
import { Divider, Segment, Button, Popup, Loader, Icon, Checkbox, Form, Breadcrumb } from 'semantic-ui-react'
|
||||
import _ from 'lodash'
|
||||
import moment from 'moment'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import Datetime from 'react-datetime'
|
||||
|
||||
import { useHistory, useLocation } from 'react-router-dom'
|
||||
import TSA from '../../common/tsa'
|
||||
import { getTextIn } from '../../common'
|
||||
import { useLocalStorage } from '../../common/hooks'
|
||||
@@ -27,7 +28,77 @@ const mapValueToRange = (x, min1, max1, min2, max2) => {
|
||||
return ((x - min1) * (max2 - min2)) / (max1 - min1) + min2
|
||||
}
|
||||
|
||||
const StatusContainer = ({ stats, handleClick, min1, max1, showYearlyValues, showRelativeValues, showByYear }) => {
|
||||
const settingDefinitions = {
|
||||
showByYear: {
|
||||
label: 'Näytä kalenterivuosittain',
|
||||
short: 'Näytä tilastot kalenterivuosittain lukuvuosien sijasta.',
|
||||
long: `
|
||||
Kun tämä valinta on käytössä, vuosittaiset ajanjaksot lasketaan kalenterivuoden alusta sen loppuun.
|
||||
Muulloin vuosittaiset ajanjaksot lasketaan lukukauden alusta seuraavan lukukauden alkuun.
|
||||
`,
|
||||
defaultValue: false,
|
||||
},
|
||||
|
||||
showYearlyValues: {
|
||||
label: 'Näytä edelliset vuodet',
|
||||
short: 'Näytä tilastot vuosittain, alkaen vuodesta 2017.',
|
||||
long: `
|
||||
Näyttää tilastot vuodesta 2017 eteenpäin.
|
||||
|
||||
Jokaiselta vuodelta näytetään kaksi tilastoa: ajanjakson kokonaistilasto ja ns. tähän mennessä "*kertynyt*" tilasto.
|
||||
Kokonaistilasto vastaa nimensä mukaisesti koko ajanjaksoa, kun taas *kertynyt* tilasto kattaa ajanjakson vuoden
|
||||
tai lukuvuoden alusta "Näytä päivänä"-valintaa vastaavaan päivämäärään tuona vuotena. Luvut näytetään muodossa *<kerynyt>*/*<kokonais>*.
|
||||
Kuluvalta vuodelta näytetään ainoastaan kertynyt tilasto.
|
||||
`,
|
||||
defaultValue: false,
|
||||
},
|
||||
|
||||
showRelativeValues: {
|
||||
label: 'Näytä suhteutettuna opiskelijoiden määrään',
|
||||
short:
|
||||
'Näyttää tilastot suhteutettuna opiskelijoiden määrään kyseisellä aikavälillä ja kyseisessä organisaatiossa.',
|
||||
long: `
|
||||
Näyttää tilastot suhteutettuna opiskelijoiden määrään kyseisellä aikavälillä ja kyseisessä organisaatiossa.
|
||||
Opiskelijoiden määrä perustuu ajanjaksolla kyseisen organisaation alaisista kursseista suoritusmerkintöjä saaneiden opiskelijoiden määrään.
|
||||
Luku siis sisältää muutkin kuin kyseiseen ohjelman tai osaston opinto-oikeuden omaavat opiskelijat.
|
||||
`,
|
||||
defaultValue: false,
|
||||
},
|
||||
|
||||
showCountingFrom: {
|
||||
label: 'Näytä päivänä',
|
||||
short: 'Valitse päivä johon asti kertyneet tilastot näytetään.',
|
||||
long: `
|
||||
Tämä valinta määrittää päivämäärän, jota käyttäen kertyneet tilastot lasketaan.
|
||||
Esimerkiksi "Näytä kalenterivuosittain" valinnan ollessa pois päältä,
|
||||
lasketaan kertyneet tilastot (vrt. lukuvuosien kokonaistilastot) kunkin lukuvuoden alusta
|
||||
tätä päivämäärää vastaavaan päivään kyseisenä lukuvuonna.
|
||||
`,
|
||||
defaultValue: null,
|
||||
},
|
||||
|
||||
showStudentCounts: {
|
||||
label: 'Näytä kurssien opiskelijamäärät',
|
||||
short: 'Näyttää suoritettujen opintopisteiden sijasta opiskelijoiden määrät kurssitason näkymässä.',
|
||||
long: `
|
||||
Oletuksena kurssitason näkymässä näytetään organisaatio- ja ohjelmatason näkymien tapaan suoritettujen opintopisteiden
|
||||
kokonaismäärä. Kun tämä valinta on käytössä, tämän sijasta näytettävät luvut vastaavat kurssin suorittaneiden
|
||||
*yksilöityjen* opiskelijoiden määrää. Kukin opiskelija lasketaan siis vain kerran tähän tilastoon.
|
||||
`,
|
||||
defaultValue: false,
|
||||
},
|
||||
}
|
||||
|
||||
const StatusContainer = ({
|
||||
stats,
|
||||
handleClick,
|
||||
min1,
|
||||
max1,
|
||||
showYearlyValues,
|
||||
showRelativeValues,
|
||||
showByYear,
|
||||
showStudentCounts,
|
||||
}) => {
|
||||
const title = getTextIn(stats.name)
|
||||
const clickable = !!stats.drill
|
||||
|
||||
@@ -114,6 +185,8 @@ const StatusContainer = ({ stats, handleClick, min1, max1, showYearlyValues, sho
|
||||
style={{
|
||||
color: getColor(change),
|
||||
transform: `rotateZ(${-mapValueToRange(change * 100, min1, max1, -45, 45)}deg)`,
|
||||
transitionProperty: 'transform',
|
||||
transitionDuration: '200ms',
|
||||
}}
|
||||
size="huge"
|
||||
name="arrow right"
|
||||
@@ -132,14 +205,20 @@ const StatusContainer = ({ stats, handleClick, min1, max1, showYearlyValues, sho
|
||||
<div className={`years item-type-${stats.type}`}>
|
||||
{_.orderBy(Object.entries(stats.yearly), ([y]) => y, ['desc']).map(([year, yearStats]) => {
|
||||
return (
|
||||
<span style={{ display: 'contents' }} className="year-row">
|
||||
<span className="year-row">
|
||||
<b className="year-label">
|
||||
{year}
|
||||
{!showByYear && `-${`${Number(year) + 1}`.slice(-2)}`}:
|
||||
</b>
|
||||
{stats.type === 'course' ? (
|
||||
{stats.type === 'course' && showStudentCounts ? (
|
||||
<>
|
||||
<span className="year-value">{yearStats.accStudents}</span>
|
||||
{!!yearStats.totalStudents && (
|
||||
<>
|
||||
<span className="separator">{yearStats.totalStudents !== undefined && '/'}</span>
|
||||
<span className="year-value">{yearStats.totalStudents}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="unit">students</span>
|
||||
</>
|
||||
) : (
|
||||
@@ -147,7 +226,7 @@ const StatusContainer = ({ stats, handleClick, min1, max1, showYearlyValues, sho
|
||||
<span className="year-value accumulated">
|
||||
{getDisplayValue(yearStats.acc, yearStats.accStudents)}
|
||||
</span>
|
||||
<span style={{ color: '#AAA', margin: '0 0.3em' }}>{yearStats.total !== undefined && '/'}</span>
|
||||
<span className="separator">{yearStats.total !== undefined && '/'}</span>
|
||||
<span className="year-value total">
|
||||
{yearStats.total !== undefined && getDisplayValue(yearStats.total, yearStats.totalStudents)}
|
||||
</span>
|
||||
@@ -162,185 +241,22 @@ const StatusContainer = ({ stats, handleClick, min1, max1, showYearlyValues, sho
|
||||
)
|
||||
}
|
||||
|
||||
const VerticalLine = () => <div style={{ margin: '0 10px', fontSize: '20px' }}>|</div>
|
||||
const StatusContent = ({ data, settings, onDrill }) => {
|
||||
const { showByYear, showRelativeValues, showYearlyValues, showStudentCounts } = settings
|
||||
|
||||
const Status = ({ getStatusDispatch, data, loading }) => {
|
||||
const DATE_FORMAT = 'DD.MM.YYYY'
|
||||
const [showYearlyValues, setShowYearlyValues] = useLocalStorage('showYearlyValues', true)
|
||||
const [showByYear, setShowByYear] = useLocalStorage('showByYear', false)
|
||||
const [showRelativeValues, setShowRelativeValues] = useLocalStorage('showRelativeValues', false)
|
||||
const [drillStack, setDrillStack] = useState([])
|
||||
const [showSettings, setShowSettings] = useState(true)
|
||||
const [selectedDate, setSelectedDate] = useState(moment())
|
||||
const [codes, setCodes] = useState([])
|
||||
const { CoolDataScience } = InfoToolTips
|
||||
|
||||
const isValidDate = d => moment.isMoment(d) && moment().diff(d) > 0
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDate && isValidDate(selectedDate)) {
|
||||
getStatusDispatch({ date: selectedDate.valueOf(), showByYear })
|
||||
}
|
||||
}, [selectedDate, showByYear])
|
||||
|
||||
useEffect(() => {
|
||||
if (codes.length > 0) {
|
||||
const updatedDrillStack = codes.reduce((acc, code) => {
|
||||
const drilled = data[code]
|
||||
// check if the code is on first level of drilldown
|
||||
if (drilled) acc.push(drilled.drill)
|
||||
// if not on first level then use the previous object in array
|
||||
// this might be source of bugs but its the best I could come up with
|
||||
else if (acc.length > 0 && acc[0][code]) acc.push(acc[0][code].drill)
|
||||
return acc
|
||||
}, [])
|
||||
setDrillStack(updatedDrillStack)
|
||||
}
|
||||
}, [data])
|
||||
|
||||
const handleShowYearlyValuesToggled = () => {
|
||||
const yearlyValues = showYearlyValues
|
||||
setShowYearlyValues(!showYearlyValues)
|
||||
sendAnalytics(`S Show yearly values toggle ${!yearlyValues ? 'on' : 'off'}`, 'Status')
|
||||
}
|
||||
|
||||
const handleShowByYearToggled = () => {
|
||||
const byYear = showByYear
|
||||
setShowByYear(!showByYear)
|
||||
sendAnalytics(`S Show by year toggle ${!byYear ? 'on' : 'off'}`, 'Status')
|
||||
}
|
||||
|
||||
const handleShowRelativeValuesChanged = () => {
|
||||
sendAnalytics(`S Show relative values toggle ${!showRelativeValues ? 'on' : 'off'}`)
|
||||
setShowRelativeValues(!showRelativeValues)
|
||||
}
|
||||
|
||||
const pushToDrillStack = (values, code) => {
|
||||
const updatedCodes = [...codes].concat(code)
|
||||
const updatedDrillStack = [...drillStack].concat(values)
|
||||
setCodes(updatedCodes)
|
||||
setDrillStack(updatedDrillStack)
|
||||
sendAnalytics('S Drilldown clicked', 'Status')
|
||||
}
|
||||
|
||||
const popFromDrillStack = () => {
|
||||
const updatedDrillStack = _.dropRight([...drillStack], 1)
|
||||
const updatedCodes = _.dropRight([...codes], 1)
|
||||
setCodes(updatedCodes)
|
||||
setDrillStack(updatedDrillStack)
|
||||
sendAnalytics('S Drillup clicked', 'Status')
|
||||
}
|
||||
|
||||
const renderSettings = () => {
|
||||
return (
|
||||
<Accordion style={{ padding: 0, paddingTop: '10px', flex: 1 }}>
|
||||
<Accordion.Title style={{ padding: 0, cursor: 'default' }} active={showSettings}>
|
||||
<span style={{ cursor: 'pointer' }} onClick={() => setShowSettings(!showSettings)}>
|
||||
<Icon name="setting" />
|
||||
<span>Asetukset</span>
|
||||
</span>
|
||||
</Accordion.Title>
|
||||
<Accordion.Content style={{ padding: 0, marginTop: '10px' }} active={showSettings}>
|
||||
<Segment style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
background: 'red',
|
||||
transform: 'rotateY(0deg) rotate(45deg)',
|
||||
position: 'absolute',
|
||||
top: '-6px',
|
||||
left: '35px',
|
||||
border: '1px solid #dededf',
|
||||
borderRight: 'none',
|
||||
borderBottom: 'none',
|
||||
backgroundColor: 'white',
|
||||
}}
|
||||
/>
|
||||
<Checkbox
|
||||
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
|
||||
label="Näytä edelliset vuodet"
|
||||
onChange={handleShowYearlyValuesToggled}
|
||||
checked={showYearlyValues}
|
||||
/>
|
||||
<VerticalLine />
|
||||
<Checkbox
|
||||
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
|
||||
label="Näytä kalenterivuosittain"
|
||||
onChange={handleShowByYearToggled}
|
||||
checked={showByYear}
|
||||
/>
|
||||
<VerticalLine />
|
||||
<Checkbox
|
||||
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
|
||||
label="Näytä suhteutettuna opiskelijoiden määrään"
|
||||
onChange={handleShowRelativeValuesChanged}
|
||||
checked={showRelativeValues}
|
||||
/>
|
||||
<VerticalLine />
|
||||
<Form>
|
||||
<Form.Field error={!isValidDate(selectedDate)} style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '0.9em' }}>Näytä päivänä:</span>
|
||||
<Datetime
|
||||
className="status-date-time-input"
|
||||
dateFormat={DATE_FORMAT}
|
||||
timeFormat={false}
|
||||
closeOnSelect
|
||||
value={selectedDate}
|
||||
locale="fi"
|
||||
isValidDate={isValidDate}
|
||||
onChange={setSelectedDate}
|
||||
/>
|
||||
</Form.Field>
|
||||
</Form>
|
||||
</Segment>
|
||||
</Accordion.Content>
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
|
||||
const DrilldownMessage = () => (
|
||||
<Message
|
||||
color="blue"
|
||||
content="Klikkaamalla tiedekuntaa pystyt porautumaan koulutusohjelma tasolle ja ohjelmaa klikkaamalla pystyt porautumaan kurssitasolle.
|
||||
Vasemmassa yläkulmassa olevaa nuolta klikkaamalla pääset edelliseen näkymään."
|
||||
/>
|
||||
)
|
||||
|
||||
if (!data || loading)
|
||||
return (
|
||||
<Segment style={{ padding: '40px' }} textAlign="center">
|
||||
<Dimmer inverted active />
|
||||
<Loader active={loading} />
|
||||
</Segment>
|
||||
)
|
||||
|
||||
const orderedAbsDiffs = _.orderBy(
|
||||
Object.values(_.last(drillStack) || data).map(({ current, currentStudents, previous, previousStudents }) => {
|
||||
return Math.abs(
|
||||
Math.floor(
|
||||
(getP(
|
||||
showRelativeValues ? current / currentStudents : current,
|
||||
showRelativeValues ? previous / previousStudents : previous
|
||||
) -
|
||||
1) *
|
||||
1000
|
||||
) / 10
|
||||
)
|
||||
const orderedAbsDiffs = _.chain(data)
|
||||
.map(({ current, currentStudents, previous, previousStudents }) => {
|
||||
const currentValue = showRelativeValues ? current / currentStudents : current
|
||||
const previousValue = showRelativeValues ? previous / previousStudents : previous
|
||||
return Math.abs(Math.floor((getP(currentValue, previousValue) - 1) * 1000) / 10)
|
||||
})
|
||||
)
|
||||
.orderBy()
|
||||
.value()
|
||||
|
||||
const medianDiff = orderedAbsDiffs[Math.round((orderedAbsDiffs.length - 1) / 2)]
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>Koulutusohjelmien tuottamat opintopisteet</h2>
|
||||
<DrilldownMessage />
|
||||
<div style={{ display: 'flex', marginBottom: '20px', marginRight: '40px' }}>
|
||||
{drillStack.length > 0 && (
|
||||
<Icon onClick={popFromDrillStack} style={{ fontSize: '40px', cursor: 'pointer' }} name="arrow left" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
@@ -351,20 +267,21 @@ const Status = ({ getStatusDispatch, data, loading }) => {
|
||||
}}
|
||||
>
|
||||
{_.orderBy(
|
||||
Object.entries(_.last(drillStack) || data),
|
||||
([, { current, previous, currentStudents, previousStudents }]) =>
|
||||
data,
|
||||
({ current, previous, currentStudents, previousStudents }) =>
|
||||
getP(current || currentStudents, previous || previousStudents), // oh god<r
|
||||
['desc']
|
||||
).map(([code, stats]) => {
|
||||
const handleClick = () => pushToDrillStack(stats.drill, code)
|
||||
).map(stats => {
|
||||
const handleClick = () => onDrill(stats.code)
|
||||
|
||||
return (
|
||||
<StatusContainer
|
||||
key={code}
|
||||
key={stats.code}
|
||||
stats={stats}
|
||||
handleClick={handleClick}
|
||||
showRelativeValues={showRelativeValues}
|
||||
showYearlyValues={showYearlyValues}
|
||||
showStudentCounts={showStudentCounts}
|
||||
min1={-medianDiff * 2}
|
||||
max1={medianDiff * 2}
|
||||
showByYear={showByYear}
|
||||
@@ -372,20 +289,367 @@ const Status = ({ getStatusDispatch, data, loading }) => {
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{renderSettings()}
|
||||
<Message>
|
||||
{
|
||||
// eslint-disable-next-line react/no-children-prop
|
||||
<ReactMarkdown children={CoolDataScience.status} escapeHtml={false} />
|
||||
}
|
||||
</Message>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ coolDataScience }) => ({
|
||||
data: coolDataScience.data.status,
|
||||
loading: coolDataScience.pending.status,
|
||||
})
|
||||
const WithHelpTooltip = ({ children, tooltip, onOpenDetails, ...rest }) => {
|
||||
const popupContext = useRef()
|
||||
const [popupOpen, setPopupOpen] = useState(false)
|
||||
|
||||
export default connect(mapStateToProps, { getStatusDispatch: getStatus })(Status)
|
||||
const trigger = (
|
||||
<div>
|
||||
{children}
|
||||
<div ref={popupContext} style={{ display: 'inline-block', paddingTop: '0.2em', cursor: 'help' }}>
|
||||
<Icon
|
||||
onClick={() => setPopupOpen(!popupOpen)}
|
||||
ref={popupContext}
|
||||
style={{ marginLeft: '0.3em', color: '#888' }}
|
||||
name="question circle outline"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const popupProps = _.defaults(rest, {
|
||||
position: 'right center',
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup
|
||||
hoverable
|
||||
size="tiny"
|
||||
open={popupOpen}
|
||||
onOpen={() => setPopupOpen(true)}
|
||||
onClose={() => setPopupOpen(false)}
|
||||
trigger={trigger}
|
||||
context={popupContext}
|
||||
{...popupProps}
|
||||
on="hover"
|
||||
mouseEnterDelay={1000}
|
||||
>
|
||||
<div>{tooltip}</div>
|
||||
<span style={{ color: '#2185d0', cursor: 'pointer' }} onClick={onOpenDetails}>
|
||||
Lue lisää...
|
||||
</span>
|
||||
</Popup>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const isValidDate = d => moment().diff(moment(d)) > 0
|
||||
|
||||
const StatusSettings = ({ onSettingsChange, settings, onOpenDetails }) => {
|
||||
const { selectedDate } = settings
|
||||
const DATE_FORMAT = 'DD.MM.YYYY'
|
||||
|
||||
const changeSetting = (property, value) => {
|
||||
sendAnalytics(`S Set setting "${property}" to ${value}`, 'Status')
|
||||
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
[property]: value,
|
||||
})
|
||||
}
|
||||
|
||||
const itemStyles = {
|
||||
margin: '0.5rem 1rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}
|
||||
|
||||
const createSettingToggle = key => (
|
||||
<div style={itemStyles}>
|
||||
<WithHelpTooltip tooltip={settingDefinitions[key].short} onOpenDetails={() => onOpenDetails(key)}>
|
||||
<Checkbox
|
||||
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
|
||||
label={settingDefinitions[key].label}
|
||||
checked={settings[key]}
|
||||
onChange={() => changeSetting(key, !settings[key])}
|
||||
/>
|
||||
</WithHelpTooltip>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'stretch', padding: 0, flexDirection: 'column' }}>
|
||||
{createSettingToggle('showYearlyValues')}
|
||||
{createSettingToggle('showByYear')}
|
||||
{createSettingToggle('showRelativeValues')}
|
||||
{createSettingToggle('showStudentCounts')}
|
||||
<div style={itemStyles}>
|
||||
<Form>
|
||||
<Form.Field
|
||||
error={selectedDate !== null && !isValidDate(selectedDate)}
|
||||
style={{ display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<WithHelpTooltip
|
||||
tooltip={settingDefinitions.showCountingFrom.short}
|
||||
onOpenDetails={() => onOpenDetails('showCountingFrom')}
|
||||
>
|
||||
<span style={{ fontSize: '0.9em' }}>{settingDefinitions.showCountingFrom.label}</span>
|
||||
</WithHelpTooltip>
|
||||
<Datetime
|
||||
className="status-date-time-input"
|
||||
dateFormat={DATE_FORMAT}
|
||||
timeFormat={false}
|
||||
closeOnSelect
|
||||
value={moment(selectedDate)}
|
||||
locale="fi"
|
||||
isValidDate={isValidDate}
|
||||
inputProps={{ placeholder: 'Valise päivämäärä' }}
|
||||
onChange={value => {
|
||||
changeSetting('selectedDate', value.format(DATE_FORMAT) === moment().format(DATE_FORMAT) ? null : value)
|
||||
}}
|
||||
/>
|
||||
</Form.Field>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const getDefaultSettings = () =>
|
||||
_.chain(settingDefinitions)
|
||||
.toPairs()
|
||||
.map(([key, { defaultValue }]) => [key, defaultValue])
|
||||
.fromPairs()
|
||||
.value()
|
||||
|
||||
const Status = () => {
|
||||
const [explicitSettings, setSettings] = useLocalStorage('trendsStatusSettings', {})
|
||||
const [usageDetailsOpen, setUsageDetailsOpen] = useState(false)
|
||||
const moreDetailsRef = useRef(null)
|
||||
const history = useHistory()
|
||||
const location = useLocation()
|
||||
|
||||
const drillStack = location.state?.drillStack ?? []
|
||||
|
||||
const data = useSelector(state => state.coolDataScience.data.status)
|
||||
const loading = useSelector(state => state.coolDataScience.pending.status)
|
||||
const dispatch = useDispatch()
|
||||
|
||||
const settings = useMemo(() => _.defaults(explicitSettings, getDefaultSettings()), [explicitSettings])
|
||||
|
||||
const { CoolDataScience } = InfoToolTips
|
||||
|
||||
useEffect(() => {
|
||||
const { selectedDate, showByYear } = settings
|
||||
|
||||
let date
|
||||
|
||||
if (!selectedDate) {
|
||||
date = moment().valueOf()
|
||||
} else if (isValidDate(selectedDate)) {
|
||||
date = moment(selectedDate).valueOf()
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
dispatch(
|
||||
getStatus({
|
||||
date,
|
||||
showByYear,
|
||||
})
|
||||
)
|
||||
}, [settings.selectedDate, settings.showByYear])
|
||||
|
||||
const pushToDrillStack = code => {
|
||||
history.push('/trends/status', {
|
||||
drillStack: [...drillStack, code],
|
||||
})
|
||||
|
||||
sendAnalytics('S Drilldown clicked', 'Status')
|
||||
}
|
||||
|
||||
const breadcrumb = useMemo(() => {
|
||||
const stack = _.chain(drillStack)
|
||||
.reduce(
|
||||
([stack, data], code) => {
|
||||
let content = code
|
||||
let next = null
|
||||
|
||||
if (data) {
|
||||
const entry = data[code]
|
||||
|
||||
if (entry) {
|
||||
content = getTextIn(entry.name)
|
||||
next = entry.drill
|
||||
}
|
||||
}
|
||||
|
||||
const item = {
|
||||
key: code,
|
||||
content,
|
||||
link: !!next,
|
||||
active: !next,
|
||||
onClick: () => {
|
||||
history.push('/trends/status', {
|
||||
drillStack: [...stack.map(({ key }) => key).splice(1), code],
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
return [[...stack, item], next]
|
||||
},
|
||||
[[], data]
|
||||
)
|
||||
.value()[0]
|
||||
|
||||
const hy = {
|
||||
key: '',
|
||||
content: getTextIn({
|
||||
fi: 'Helsingin Yliopisto',
|
||||
en: 'University of Helsinki',
|
||||
sv: 'Helsingfors universitet',
|
||||
}),
|
||||
link: true,
|
||||
onClick: () =>
|
||||
history.push('/trends/status', {
|
||||
drillStack: [],
|
||||
}),
|
||||
}
|
||||
|
||||
return [hy, ...stack]
|
||||
}, [data, drillStack])
|
||||
|
||||
const drilledData = useMemo(
|
||||
() =>
|
||||
_.chain(drillStack)
|
||||
.reduce((data, code) => data && _.values(data).find(item => item.code === code)?.drill, data)
|
||||
.defaultTo({})
|
||||
.values()
|
||||
.value(),
|
||||
[data, drillStack]
|
||||
)
|
||||
|
||||
const popFromDrillStack = () => {
|
||||
const newDrillStack = [...drillStack]
|
||||
newDrillStack.pop()
|
||||
history.push('/trends/status', {
|
||||
drillStack: newDrillStack,
|
||||
})
|
||||
|
||||
sendAnalytics('S Drillup clicked', 'Status')
|
||||
}
|
||||
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const attentionSeekers = useRef({})
|
||||
|
||||
const scrollToAttentionSeeker = el => {
|
||||
el.scrollIntoView({
|
||||
block: 'nearest',
|
||||
inline: 'nearest',
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
|
||||
const attentionSeekerRef = key => ref => {
|
||||
if (ref !== null && attentionSeekers.current[key] === true) {
|
||||
scrollToAttentionSeeker(ref)
|
||||
}
|
||||
|
||||
attentionSeekers.current[key] = ref
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '1rem' }} ref={moreDetailsRef}>
|
||||
<h2 style={{ margin: 0, flexGrow: 1 }}>Koulutusohjelmien tuottamat opintopisteet</h2>
|
||||
<Popup
|
||||
trigger={
|
||||
<Button>
|
||||
<Icon name="settings" /> Asetukset
|
||||
</Button>
|
||||
}
|
||||
position="bottom right"
|
||||
on="click"
|
||||
wide="very"
|
||||
open={settingsOpen}
|
||||
onOpen={() => setSettingsOpen(true)}
|
||||
onClose={() => {
|
||||
// Close the popup only after the event has had a chance to propagate.
|
||||
setTimeout(() => setSettingsOpen(false), 0)
|
||||
}}
|
||||
style={{ padding: '0.25em 0em', maxHeight: '80vh' }}
|
||||
>
|
||||
<StatusSettings
|
||||
settings={settings}
|
||||
onSettingsChange={setSettings}
|
||||
onOpenDetails={key => {
|
||||
if (moreDetailsRef.current) {
|
||||
moreDetailsRef.current.scrollIntoView({
|
||||
block: 'start',
|
||||
inline: 'end',
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
|
||||
if (attentionSeekers.current[key]) {
|
||||
scrollToAttentionSeeker(attentionSeekers.current[key])
|
||||
} else {
|
||||
attentionSeekers.current[key] = true
|
||||
}
|
||||
|
||||
setUsageDetailsOpen(true)
|
||||
}}
|
||||
/>
|
||||
</Popup>
|
||||
<Popup
|
||||
trigger={
|
||||
<Button>
|
||||
<Icon name="question circle" /> Käyttöohje
|
||||
</Button>
|
||||
}
|
||||
position="bottom right"
|
||||
on="click"
|
||||
wide="very"
|
||||
open={usageDetailsOpen}
|
||||
onOpen={() => setUsageDetailsOpen(true)}
|
||||
onClose={() => setUsageDetailsOpen(false)}
|
||||
style={{ padding: '0', paddingBottom: '0.5em', maxHeight: '80vh', overflowY: 'auto' }}
|
||||
>
|
||||
<div style={{ padding: '1em' }}>
|
||||
{
|
||||
// eslint-disable-next-line react/no-children-prop
|
||||
<ReactMarkdown children={CoolDataScience.status} escapeHtml={false} />
|
||||
}
|
||||
</div>
|
||||
{_.toPairs(settingDefinitions).map(([key, { label, long }]) => (
|
||||
<div key={key}>
|
||||
<Divider />
|
||||
<div style={{ padding: '0 1em' }} ref={attentionSeekerRef(key)}>
|
||||
<b>
|
||||
Valinta "<i>{label}</i>"
|
||||
</b>
|
||||
<div style={{ margin: '0.5em', fontSize: '0.9em' }}>
|
||||
<ReactMarkdown escapeHtml={false}>{long.replace(/(^|\n)[ \t]+/g, '\n')}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Popup>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', marginBottom: '20px', marginRight: '40px', alignItems: 'center' }}>
|
||||
<Breadcrumb icon="right angle" sections={breadcrumb} size="large" />
|
||||
{drillStack.length > 0 && (
|
||||
<Icon
|
||||
onClick={popFromDrillStack}
|
||||
style={{ cursor: 'pointer', color: 'rgba(0,0,0,.28)', marginLeft: '0.4em', marginTop: '-0.17em' }}
|
||||
name="arrow left"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data && !loading ? (
|
||||
<StatusContent settings={settings} onDrill={pushToDrillStack} data={drilledData} />
|
||||
) : (
|
||||
<Loader active={loading} inline="centered" />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Status
|
||||
|
||||
@@ -5,8 +5,22 @@ import Graduated from './StatusGraduated'
|
||||
|
||||
const StatusPanel = () => {
|
||||
const panes = [
|
||||
{ menuItem: 'Attainments', render: () => <Status /> },
|
||||
{ menuItem: 'Graduations', render: () => <Graduated /> },
|
||||
{
|
||||
menuItem: 'Attainments',
|
||||
render: () => (
|
||||
<Tab.Pane>
|
||||
<Status />
|
||||
</Tab.Pane>
|
||||
),
|
||||
},
|
||||
{
|
||||
menuItem: 'Graduations',
|
||||
render: () => (
|
||||
<Tab.Pane>
|
||||
<Graduated />
|
||||
</Tab.Pane>
|
||||
),
|
||||
},
|
||||
]
|
||||
return <Tab panes={panes} />
|
||||
}
|
||||
|
||||
@@ -26,10 +26,6 @@
|
||||
margin-left: 0.3em;
|
||||
}
|
||||
|
||||
.years.item-type-course {
|
||||
grid-template-columns: repeat(2, fit-content(0)) auto;
|
||||
}
|
||||
|
||||
.year-label {
|
||||
white-space: nowrap;
|
||||
padding-right: 0.5em;
|
||||
@@ -43,7 +39,16 @@
|
||||
.year-row {
|
||||
position: relative;
|
||||
padding-left: 0.5em;
|
||||
display: inline-block;
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.year-row .unit {
|
||||
grid-column: 5;
|
||||
}
|
||||
|
||||
.year-row .separator {
|
||||
color: #aaa;
|
||||
margin: 0 0.3em;
|
||||
}
|
||||
|
||||
.status-date-time-input {
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { connect } from 'react-redux'
|
||||
import { shape, arrayOf, string, number } from 'prop-types'
|
||||
import { Table, Tab } from 'semantic-ui-react'
|
||||
import { maxBy } from 'lodash'
|
||||
import InfoBox from '../../InfoBox'
|
||||
import InfoBox from '../../Info/InfoBox'
|
||||
import infotooltips from '../../../common/InfoToolTips'
|
||||
import { getNewestProgramme, getTextIn } from '../../../common'
|
||||
import useLanguage from '../../LanguagePicker/useLanguage'
|
||||
|
||||
@@ -18,7 +18,7 @@ import ProgressBar from '../ProgressBar'
|
||||
import { getStudentToTargetCourseDateMap, getUserIsAdmin, getTextIn } from '../../common'
|
||||
import { useProgress, useTitle } from '../../common/hooks'
|
||||
import infotooltips from '../../common/InfoToolTips'
|
||||
import InfoBox from '../InfoBox'
|
||||
import InfoBox from '../Info/InfoBox'
|
||||
import FilterTray from '../FilterTray'
|
||||
import useFilters from '../FilterTray/useFilters'
|
||||
import useProgrammeFilter from '../FilterTray/filters/Programmes/useProgrammeFilter'
|
||||
@@ -258,9 +258,9 @@ const CoursePopulation = ({
|
||||
content: (
|
||||
<div ref={studentRef}>
|
||||
<PopulationStudents
|
||||
variant="coursePopulation"
|
||||
studentToTargetCourseDateMap={studentToTargetCourseDateMap}
|
||||
filteredStudents={studentData.students.filter(stu => selectedStudents.includes(stu.studentNumber))}
|
||||
coursePopulation
|
||||
language={language}
|
||||
coursecode={codes}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { dataSeriesType, viewModeNames } from './Panes/util'
|
||||
import PassRate from './Panes/passRate'
|
||||
import Distribution from './Panes/distribution'
|
||||
import Tables from './Panes/tables'
|
||||
import InfoBox from '../../InfoBox'
|
||||
import InfoBox from '../../Info/InfoBox'
|
||||
import { useTabs } from '../../../common/hooks'
|
||||
|
||||
import './resultTabs.css'
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import React, { Component } from 'react'
|
||||
import React, { useState, useRef, useMemo } from 'react'
|
||||
import { renderToString } from 'react-dom/server'
|
||||
import { connect } from 'react-redux'
|
||||
import moment from 'moment'
|
||||
import { arrayOf, bool, object, string, func, number, shape } from 'prop-types'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
import Highcharts from 'highcharts/highstock'
|
||||
import { Button } from 'semantic-ui-react'
|
||||
import boostcanvas from 'highcharts/modules/boost-canvas'
|
||||
import _ from 'lodash/fp'
|
||||
import boost from 'highcharts/modules/boost'
|
||||
import ReactHighstock from 'react-highcharts/ReactHighstock'
|
||||
import './creditAccumulationGraphHC.css'
|
||||
import CreditGraphTooltip from '../CreditGraphTooltip'
|
||||
import { clearLoading } from '../../redux/graphSpinner'
|
||||
import { setChartHeight } from '../../redux/settings'
|
||||
import { reformatDate, getTextIn } from '../../common'
|
||||
import useLanguage from '../LanguagePicker/useLanguage'
|
||||
import { DISPLAY_DATE_FORMAT, API_DATE_FORMAT } from '../../constants'
|
||||
|
||||
// boost canvas needed because tests break with large population
|
||||
@@ -23,313 +21,249 @@ boost(Highcharts)
|
||||
|
||||
const SINGLE_GRAPH_GOAL_SERIES_NAME = 'Goal'
|
||||
|
||||
class CreditAccumulationGraphHighCharts extends Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
options: [],
|
||||
updateGraph: false,
|
||||
}
|
||||
const createGraphOptions = ({
|
||||
singleStudent,
|
||||
seriesData,
|
||||
graphHeight,
|
||||
tooltipFormatter,
|
||||
onPointClicked,
|
||||
startDate,
|
||||
endDate,
|
||||
}) => {
|
||||
const tooltip = {
|
||||
shared: false,
|
||||
split: false,
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { students, selectedStudents, currentGraphSize, render } = this.props
|
||||
|
||||
this.createGraphOptions(students, selectedStudents, currentGraphSize, render)
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.students) {
|
||||
const nextStudents = nextProps.students.map(student => student.studentNumber)
|
||||
const oldStudents = this.props.students.map(student => student.studentNumber)
|
||||
const changedStudentAmount = nextProps.selectedStudents.length !== this.props.selectedStudents.length
|
||||
const changedGraphSize = nextProps.currentGraphSize !== this.props.currentGraphSize
|
||||
|
||||
const changed =
|
||||
nextStudents.some(student => !oldStudents.includes(student)) ||
|
||||
oldStudents.some(student => !nextStudents.includes(student))
|
||||
|
||||
if (changed || changedStudentAmount || changedGraphSize) {
|
||||
this.createGraphOptions(
|
||||
nextProps.students,
|
||||
nextProps.selectedStudents,
|
||||
nextProps.currentGraphSize,
|
||||
nextProps.render
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger updating the chart after filter tray transition has finished.
|
||||
* This hack remedies the problem that highcharts will not dynamically adapt to container width
|
||||
* if a CSS transition is used. The resize must fire only after the transition has completed to
|
||||
* ensure correct size.
|
||||
*/
|
||||
if (this.props.trayOpen !== nextProps.trayOpen) {
|
||||
setTimeout(() => {
|
||||
this.props.setChartHeight(this.props.currentGraphSize - 1)
|
||||
}, 700)
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
const { updateGraph } = this.state
|
||||
const { students, selectedStudents, currentGraphSize, render } = this.props
|
||||
|
||||
if (updateGraph) {
|
||||
this.createGraphOptions(students, selectedStudents, currentGraphSize, render)
|
||||
}
|
||||
}
|
||||
|
||||
getXAxisMonth = (date, startDate) =>
|
||||
Math.max(moment(date, API_DATE_FORMAT).diff(moment(startDate, API_DATE_FORMAT), 'days') / 30, 0)
|
||||
|
||||
sortCoursesByDate = courses => courses.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||
|
||||
filterCoursesByDate = (courses, date) => courses.filter(c => moment(c.date).isSameOrAfter(moment(date)))
|
||||
|
||||
createTooltip = point => {
|
||||
const { students, language } = this.props
|
||||
|
||||
if (point.series.name === SINGLE_GRAPH_GOAL_SERIES_NAME) {
|
||||
return null
|
||||
}
|
||||
|
||||
const targetCourse = this.sortCoursesByDate(students[0].courses).find(
|
||||
c => point.key === c.course_code && point.x === new Date(c.date).getTime()
|
||||
)
|
||||
|
||||
if (!targetCourse.course) return null
|
||||
const payload = [
|
||||
{
|
||||
name: students[0].studentNumber,
|
||||
payload: {
|
||||
...targetCourse,
|
||||
date: reformatDate(targetCourse.date, DISPLAY_DATE_FORMAT),
|
||||
title: getTextIn(targetCourse.course.name, language),
|
||||
},
|
||||
if (singleStudent) {
|
||||
Object.assign(tooltip, {
|
||||
formatter() {
|
||||
tooltipFormatter(this)
|
||||
},
|
||||
]
|
||||
return renderToString(<CreditGraphTooltip payload={payload} />)
|
||||
useHTML: true,
|
||||
style: {
|
||||
all: 'unset',
|
||||
display: 'none',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
createGraphOptions = (students, selectedStudents, graphSize, render) => {
|
||||
const studentCreditLines = this.createStudentCreditLines(students)
|
||||
|
||||
const dataOfSelected = studentCreditLines.filter(line => selectedStudents.includes(line.name))
|
||||
|
||||
let lastCredits = null
|
||||
if (this.isSingleStudentGraph() && render) {
|
||||
const started = moment(students.minDate)
|
||||
const lastDate = moment(students.maxDate)
|
||||
const lastMonth = Math.ceil(this.getXAxisMonth(lastDate, started))
|
||||
|
||||
let totalAbsenceMonths = 0
|
||||
const absencePoints = this.props.absences
|
||||
.filter(a => a.startdate >= started || (a.startdate <= started && a.enddate >= started))
|
||||
.reduce((res, { startdate, enddate }) => {
|
||||
const targetCreditsBeforeAbsence =
|
||||
(Math.ceil(this.getXAxisMonth(moment(startdate), started)) - totalAbsenceMonths) * (55 / 12)
|
||||
if (enddate < students.maxDate) {
|
||||
res.push([startdate, targetCreditsBeforeAbsence])
|
||||
res.push([enddate, targetCreditsBeforeAbsence])
|
||||
totalAbsenceMonths += moment(enddate).diff(moment(startdate), 'months')
|
||||
}
|
||||
return res
|
||||
}, [])
|
||||
|
||||
const zoneStart = absencePoints.length ? [absencePoints[0][0]] : []
|
||||
const zones = absencePoints.reduce(
|
||||
(acc, [start], i) => [...acc, { value: start, color: i % 2 === 0 ? '#96d7c3' : 'red' }],
|
||||
zoneStart
|
||||
)
|
||||
|
||||
lastCredits = (lastMonth - totalAbsenceMonths) * (55 / 12)
|
||||
dataOfSelected.push({
|
||||
name: SINGLE_GRAPH_GOAL_SERIES_NAME,
|
||||
data: [
|
||||
...[[students.minDate, 0], ...absencePoints, [students.maxDate, lastCredits]].sort((a, b) => a[0] - b[0]),
|
||||
],
|
||||
seriesThreshold: 150,
|
||||
color: '#96d7c3',
|
||||
marker: {
|
||||
enabled: false,
|
||||
},
|
||||
zones,
|
||||
zoneAxis: 'x',
|
||||
})
|
||||
}
|
||||
const self = this
|
||||
|
||||
const tooltipOptions = this.isSingleStudentGraph()
|
||||
? {
|
||||
formatter() {
|
||||
return self.createTooltip(this)
|
||||
},
|
||||
shared: false,
|
||||
useHTML: true,
|
||||
style: {
|
||||
all: 'unset',
|
||||
display: 'none',
|
||||
},
|
||||
split: false,
|
||||
}
|
||||
: {
|
||||
shared: false,
|
||||
split: false,
|
||||
}
|
||||
|
||||
const options = {
|
||||
plotOptions: {
|
||||
series: {
|
||||
findNearestPointBy: 'xy',
|
||||
point: {
|
||||
events: {
|
||||
click() {
|
||||
if (!self.isSingleStudentGraph()) {
|
||||
self.props.history.push(`/students/${this.series.name}`)
|
||||
}
|
||||
},
|
||||
mouseOver() {
|
||||
if (this.series.halo) {
|
||||
this.series.halo
|
||||
.attr({
|
||||
class: 'highcharts-tracker',
|
||||
})
|
||||
.toFront()
|
||||
}
|
||||
},
|
||||
hover: {
|
||||
halo: {
|
||||
size: 9,
|
||||
attributes: {
|
||||
fill: Highcharts.getOptions().colors[2],
|
||||
'stroke-width': 2,
|
||||
stroke: Highcharts.getOptions().colors[1],
|
||||
},
|
||||
return {
|
||||
plotOptions: {
|
||||
series: {
|
||||
findNearestPointBy: 'xy',
|
||||
point: {
|
||||
events: {
|
||||
click() {
|
||||
onPointClicked(this)
|
||||
},
|
||||
mouseOver() {
|
||||
if (this.series.halo) {
|
||||
this.series.halo
|
||||
.attr({
|
||||
class: 'highcharts-tracker',
|
||||
})
|
||||
.toFront()
|
||||
}
|
||||
},
|
||||
hover: {
|
||||
halo: {
|
||||
size: 9,
|
||||
attributes: {
|
||||
fill: Highcharts.getOptions().colors[2],
|
||||
'stroke-width': 2,
|
||||
stroke: Highcharts.getOptions().colors[1],
|
||||
},
|
||||
},
|
||||
},
|
||||
cursor: 'pointer',
|
||||
},
|
||||
cursor: 'pointer',
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
max: lastCredits && lastCredits > students.maxCredits ? lastCredits : students.maxCredits,
|
||||
title: { text: 'Credits' },
|
||||
},
|
||||
xAxis: {
|
||||
max: students.maxDate,
|
||||
min: students.minDateWithCredits || students.minDate,
|
||||
ordinal: false,
|
||||
},
|
||||
series: dataOfSelected,
|
||||
chart: {
|
||||
height: graphSize,
|
||||
},
|
||||
tooltip: {
|
||||
...tooltipOptions,
|
||||
},
|
||||
}
|
||||
this.setState({ options, updateGraph: false })
|
||||
},
|
||||
yAxis: {
|
||||
title: { text: 'Credits' },
|
||||
min: 0,
|
||||
max: undefined,
|
||||
},
|
||||
xAxis: {
|
||||
ordinal: false,
|
||||
min: startDate,
|
||||
max: endDate,
|
||||
},
|
||||
series: seriesData,
|
||||
chart: {
|
||||
height: graphHeight,
|
||||
},
|
||||
tooltip,
|
||||
}
|
||||
}
|
||||
|
||||
const sortCoursesByDate = courses =>
|
||||
[...courses].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||
|
||||
const filterCoursesByDate = (courses, date) => courses.filter(c => moment(c.date).isSameOrAfter(moment(date)))
|
||||
|
||||
const getXAxisMonth = (date, startDate) =>
|
||||
Math.max(moment(date, API_DATE_FORMAT).diff(moment(startDate, API_DATE_FORMAT), 'days') / 30, 0)
|
||||
|
||||
const singleStudentTooltipFormatter = (point, student, language) => {
|
||||
if (point.series.name === SINGLE_GRAPH_GOAL_SERIES_NAME) {
|
||||
return null
|
||||
}
|
||||
|
||||
isSingleStudentGraph = () => !!this.props.singleStudent
|
||||
const targetCourse = sortCoursesByDate(student.courses).find(
|
||||
({ course_code: courseCode, date }) => point.key === courseCode && point.x === new Date(date).getTime()
|
||||
)
|
||||
|
||||
createStudentCreditLines = students =>
|
||||
students.map(student => {
|
||||
const { started, studyrightStart } = student
|
||||
if (!targetCourse.course) {
|
||||
return null
|
||||
}
|
||||
|
||||
const startDate = this.props.selectedStudents.length === 1 ? started : studyrightStart
|
||||
let credits = 0
|
||||
let points = this.filterCoursesByDate(this.sortCoursesByDate(student.courses), startDate).map(course => {
|
||||
if (course.passed && !course.isStudyModuleCredit) {
|
||||
credits += course.credits
|
||||
}
|
||||
const defaultPointOptions = this.isSingleStudentGraph() ? { name: course.course_code } : {}
|
||||
return {
|
||||
...defaultPointOptions,
|
||||
x: new Date(course.date).getTime(),
|
||||
y: credits,
|
||||
}
|
||||
})
|
||||
if (points.length < 2) {
|
||||
points = [[students.minDate, 0], ...points]
|
||||
const payload = [
|
||||
{
|
||||
name: student.studentNumber,
|
||||
payload: {
|
||||
...targetCourse,
|
||||
date: reformatDate(targetCourse.date, DISPLAY_DATE_FORMAT),
|
||||
title: getTextIn(targetCourse.course.name, language),
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return renderToString(<CreditGraphTooltip payload={payload} />)
|
||||
}
|
||||
|
||||
const createGoalSeries = (starting, ending, absences) => {
|
||||
const lastMonth = Math.ceil(getXAxisMonth(moment(ending), moment(starting)))
|
||||
|
||||
let totalAbsenceMonths = 0
|
||||
const absencePoints = absences
|
||||
.filter(a => a.startdate >= starting || (a.startdate <= starting && a.enddate >= starting))
|
||||
.reduce((res, { startdate, enddate }) => {
|
||||
const targetCreditsBeforeAbsence =
|
||||
(Math.ceil(getXAxisMonth(moment(startdate), moment(starting))) - totalAbsenceMonths) * (55 / 12)
|
||||
if (enddate < ending) {
|
||||
res.push([startdate, targetCreditsBeforeAbsence])
|
||||
res.push([enddate, targetCreditsBeforeAbsence])
|
||||
totalAbsenceMonths += moment(enddate).diff(moment(startdate), 'months')
|
||||
}
|
||||
return {
|
||||
name: student.studentNumber,
|
||||
data: points,
|
||||
seriesThreshold: 150,
|
||||
marker: {
|
||||
enabled: this.isSingleStudentGraph(),
|
||||
radius: 4,
|
||||
return res
|
||||
}, [])
|
||||
|
||||
const zoneStart = absencePoints.length ? [absencePoints[0][0]] : []
|
||||
const zones = absencePoints.reduce(
|
||||
(acc, [start], i) => [...acc, { value: start, color: i % 2 === 0 ? '#96d7c3' : 'red' }],
|
||||
zoneStart
|
||||
)
|
||||
|
||||
const lastCredits = (lastMonth - totalAbsenceMonths) * (55 / 12)
|
||||
const data = [...[[starting, 0], ...absencePoints, [ending, lastCredits]].sort((a, b) => a[0] - b[0])]
|
||||
|
||||
return {
|
||||
name: SINGLE_GRAPH_GOAL_SERIES_NAME,
|
||||
data,
|
||||
seriesThreshold: 150,
|
||||
color: '#96d7c3',
|
||||
marker: {
|
||||
enabled: false,
|
||||
},
|
||||
zones,
|
||||
zoneAxis: 'x',
|
||||
}
|
||||
}
|
||||
|
||||
const createStudentCreditLines = (students, singleStudent) =>
|
||||
students.map(student => {
|
||||
const { started, studyrightStart } = student
|
||||
|
||||
const startDate = students.length === 1 ? started : studyrightStart
|
||||
|
||||
const { points } = _.flow(
|
||||
sortCoursesByDate,
|
||||
courses => filterCoursesByDate(courses, startDate),
|
||||
_.reduce(
|
||||
({ credits, points }, course) => {
|
||||
const gainedCredits = course.passed && !course.isStudyModuleCredit ? course.credits : 0
|
||||
|
||||
const point = {
|
||||
x: new Date(course.date).getTime(),
|
||||
y: credits + gainedCredits,
|
||||
}
|
||||
|
||||
if (singleStudent) {
|
||||
Object.assign(point, {
|
||||
name: course.course_code,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
points: [...points, point],
|
||||
credits: credits + gainedCredits,
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
{ credits: 0, points: [] }
|
||||
)
|
||||
)(student.courses)
|
||||
|
||||
resizeChart = size => {
|
||||
if (this.chart) {
|
||||
this.props.setChartHeight(size)
|
||||
return {
|
||||
name: student.studentNumber,
|
||||
data: points,
|
||||
seriesThreshold: 150,
|
||||
marker: {
|
||||
enabled: !!singleStudent,
|
||||
radius: 4,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const CreditAccumulationGraphHighCharts = ({ students, singleStudent, absences, startDate, endDate }) => {
|
||||
const history = useHistory()
|
||||
const chartRef = useRef()
|
||||
const language = useLanguage()
|
||||
const [graphHeight, setGraphHeight] = useState(600)
|
||||
|
||||
const seriesData = useMemo(() => createStudentCreditLines(students, singleStudent), [students, singleStudent])
|
||||
|
||||
if (singleStudent) {
|
||||
const starting = students[0].studyrights
|
||||
.map(sr => new Date(sr.startdate))
|
||||
.sort()[0]
|
||||
.getTime()
|
||||
const ending = endDate ? new Date(endDate).getTime() : new Date().getTime()
|
||||
|
||||
seriesData.push(createGoalSeries(starting, ending, absences))
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<div className="graphContainer">
|
||||
<div className="graphOptions">
|
||||
<Button
|
||||
active={this.props.currentGraphSize === 400}
|
||||
onClick={() => this.resizeChart(400)}
|
||||
content="Small"
|
||||
/>
|
||||
<Button
|
||||
active={this.props.currentGraphSize === 600}
|
||||
onClick={() => this.resizeChart(600)}
|
||||
content="Medium"
|
||||
/>
|
||||
<Button
|
||||
active={this.props.currentGraphSize === 1000}
|
||||
onClick={() => this.resizeChart(1000)}
|
||||
content="Large"
|
||||
/>
|
||||
</div>
|
||||
<ReactHighstock
|
||||
highcharts={Highcharts}
|
||||
ref={HighchartsReact => {
|
||||
this.chart = HighchartsReact
|
||||
}}
|
||||
constructorType="stockChart"
|
||||
config={this.state.options}
|
||||
/>
|
||||
</div>
|
||||
const options = createGraphOptions({
|
||||
singleStudent,
|
||||
seriesData,
|
||||
graphHeight,
|
||||
startDate: startDate ? new Date(startDate).getTime() : undefined,
|
||||
endDate,
|
||||
tooltipFormatter: point => singleStudent && singleStudentTooltipFormatter(point, students[0], language),
|
||||
onPointClicked: point => {
|
||||
if (!singleStudent) {
|
||||
history.push(`/students/${point.series.name}`)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const makeGraphSizeButton = (height, label) => (
|
||||
<Button active={graphHeight === height} onClick={() => setGraphHeight(height)} content={label} />
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="graphContainer">
|
||||
<div className="graphOptions">
|
||||
{makeGraphSizeButton(400, 'Small')}
|
||||
{makeGraphSizeButton(600, 'Medium')}
|
||||
{makeGraphSizeButton(1000, 'Large')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<ReactHighstock highcharts={Highcharts} ref={chartRef} constructorType="stockChart" config={options} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
CreditAccumulationGraphHighCharts.defaultProps = {
|
||||
absences: [],
|
||||
singleStudent: false,
|
||||
render: true,
|
||||
trayOpen: false,
|
||||
}
|
||||
|
||||
CreditAccumulationGraphHighCharts.propTypes = {
|
||||
singleStudent: bool,
|
||||
students: arrayOf(object).isRequired,
|
||||
selectedStudents: arrayOf(string).isRequired,
|
||||
setChartHeight: func.isRequired,
|
||||
currentGraphSize: number.isRequired,
|
||||
language: string.isRequired,
|
||||
absences: arrayOf(shape({})),
|
||||
render: bool,
|
||||
trayOpen: bool,
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
spinner: state.graphSpinner,
|
||||
currentGraphSize: state.settings.chartHeight,
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps, { clearLoading, setChartHeight })(withRouter(CreditAccumulationGraphHighCharts))
|
||||
export default CreditAccumulationGraphHighCharts
|
||||
|
||||
+4
-27
@@ -1,11 +1,11 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { shape, arrayOf, string, bool } from 'prop-types'
|
||||
import { useSelector } from 'react-redux'
|
||||
import PopulationCourseStatsFlat from '../../PopulationCourseStats/PopulationCourseStatsFlat'
|
||||
import useCourseFilter from '../../FilterTray/filters/Courses/useCourseFilter'
|
||||
|
||||
const CustomPopulationCourses = ({ courses, pending, selectedStudents, query, error, showFilter }) => {
|
||||
const CustomPopulationCourses = ({ selectedStudents, showFilter = false }) => {
|
||||
const { setCourses, resetCourses } = useCourseFilter()
|
||||
const { data: courses, pending, error } = useSelector(({ populationCourses }) => populationCourses)
|
||||
|
||||
useEffect(() => {
|
||||
if (!pending && !error && courses.coursestatistics) {
|
||||
@@ -21,33 +21,10 @@ const CustomPopulationCourses = ({ courses, pending, selectedStudents, query, er
|
||||
return (
|
||||
<PopulationCourseStatsFlat
|
||||
courses={courses}
|
||||
query={query}
|
||||
pending={pending}
|
||||
selectedStudents={selectedStudents}
|
||||
customPopulation
|
||||
showFilter={showFilter}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
CustomPopulationCourses.propTypes = {
|
||||
pending: bool.isRequired,
|
||||
courses: shape([]).isRequired,
|
||||
selectedStudents: arrayOf(string).isRequired,
|
||||
query: shape({}).isRequired,
|
||||
error: bool.isRequired,
|
||||
showFilter: bool,
|
||||
}
|
||||
|
||||
CustomPopulationCourses.defaultProps = {
|
||||
showFilter: false,
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ populationCourses }) => ({
|
||||
courses: populationCourses.data,
|
||||
pending: populationCourses.pending,
|
||||
query: populationCourses.query,
|
||||
error: populationCourses.error,
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps)(CustomPopulationCourses)
|
||||
export default CustomPopulationCourses
|
||||
|
||||
@@ -12,11 +12,10 @@ import PopulationStudents from '../PopulationStudents'
|
||||
import CustomPopulationCourses from './CustomPopulationCourses'
|
||||
import CustomPopulationProgrammeDist from './CustomPopulationProgrammeDist'
|
||||
import ProgressBar from '../ProgressBar'
|
||||
import InfoBox from '../InfoBox'
|
||||
import InfoBox from '../Info/InfoBox'
|
||||
import FilterTray from '../FilterTray'
|
||||
import useFilters from '../FilterTray/useFilters'
|
||||
import { CustomPopulationFilters } from '../FilterTray/FilterSets'
|
||||
import useFilterTray from '../FilterTray/useFilterTray'
|
||||
import useLanguage from '../LanguagePicker/useLanguage'
|
||||
import CustomPopulationSearch from './CustomPopulationSearch'
|
||||
import UnihowDataExport from './UnihowDataExport'
|
||||
@@ -35,7 +34,6 @@ const CustomPopulation = ({
|
||||
const [, setSelectedSearchId] = useState('')
|
||||
const [newestIndex, setNewest] = useState(null)
|
||||
const { setAllStudents, filteredStudents } = useFilters()
|
||||
const [trayOpen] = useFilterTray()
|
||||
const selectedStudents = filteredStudents.map(stu => stu.studentNumber)
|
||||
|
||||
const { progress } = useProgress(loading)
|
||||
@@ -98,13 +96,7 @@ const CustomPopulation = ({
|
||||
content: {
|
||||
content: (
|
||||
<div ref={creditGainRef}>
|
||||
<CreditAccumulationGraphHighCharts
|
||||
students={custompop}
|
||||
selectedStudents={selectedStudents}
|
||||
render={false}
|
||||
trayOpen={trayOpen}
|
||||
language={language}
|
||||
/>
|
||||
<CreditAccumulationGraphHighCharts students={filteredStudents} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -160,6 +152,7 @@ const CustomPopulation = ({
|
||||
content: (
|
||||
<div ref={studentRef}>
|
||||
<PopulationStudents
|
||||
variant="customPopulation"
|
||||
language={language}
|
||||
filteredStudents={filteredStudents}
|
||||
dataExport={<UnihowDataExport />}
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Popup, Button, Icon } from 'semantic-ui-react'
|
||||
import { contextKey as filterTrayContextKey } from './index'
|
||||
import useFilterTray from './useFilterTray'
|
||||
import useAnalytics from './useAnalytics'
|
||||
|
||||
/**
|
||||
* @param {func} applyFilter Function to apply the filter when toggled on
|
||||
* @param {func} clearFilter Function to clear the filter when toggled off.
|
||||
* @param {string} filterName Name to display in analytics.
|
||||
* @param {active} bool Toggle state. Must be controlled in the parent.
|
||||
* @param {funcs} popupContent Content component to show in the tooltip, if given. Tooltip is not shown if omitted.
|
||||
* @param {string} filterPanelContextKey Used to open the filter's panel, if given. If not given, the filter tray will not be opened either.
|
||||
*/
|
||||
const ExternalFilterToggle = ({
|
||||
filterPanelContextKey,
|
||||
applyFilter,
|
||||
clearFilter,
|
||||
filterName,
|
||||
active,
|
||||
popupContent,
|
||||
filterPanelContextKey,
|
||||
}) => {
|
||||
const [, setFilterTrayOpen] = useFilterTray(filterTrayContextKey)
|
||||
const [, setCreditFilterOpen] = useFilterTray(filterPanelContextKey)
|
||||
@@ -55,24 +62,4 @@ const ExternalFilterToggle = ({
|
||||
)
|
||||
}
|
||||
|
||||
ExternalFilterToggle.propTypes = {
|
||||
/** Used to open the filter's panel, if given. If not given, the filter tray will not be opened either. */
|
||||
filterPanelContextKey: PropTypes.string,
|
||||
/** Function to apply the filter when toggled on. */
|
||||
applyFilter: PropTypes.func.isRequired,
|
||||
/** Function to clear the filter when toggled off. */
|
||||
clearFilter: PropTypes.func.isRequired,
|
||||
/** Name to display in analytics. */
|
||||
filterName: PropTypes.string.isRequired,
|
||||
/** Toggle state. Must be controlled in the parent. */
|
||||
active: PropTypes.bool.isRequired,
|
||||
/** Content to show in the tooltip, if given. Tooltip is not shown if omitted. */
|
||||
popupContent: PropTypes.node,
|
||||
}
|
||||
|
||||
ExternalFilterToggle.defaultProps = {
|
||||
filterPanelContextKey: undefined,
|
||||
popupContent: undefined,
|
||||
}
|
||||
|
||||
export default ExternalFilterToggle
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { CourseFilterProvider } from './filters/Courses/useCourseFilter'
|
||||
import { CreditFilterProvider } from './filters/CreditsEarned/useCreditFilter'
|
||||
import { AgeFilterProvider } from './filters/Age/useAgeFilter'
|
||||
@@ -23,8 +22,4 @@ const filterProviders = [
|
||||
const FilterContextProvider = ({ children }) =>
|
||||
filterProviders.reduceRight((children, Provider) => <Provider>{children}</Provider>, children)
|
||||
|
||||
FilterContextProvider.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
}
|
||||
|
||||
export default FilterContextProvider
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import useAnalytics from './useAnalytics'
|
||||
import GraduatedFromProgramme from './filters/GraduatedFromProgramme'
|
||||
import TransferredToProgramme from './filters/TransferredToProgramme'
|
||||
import EnrollmentStatus from './filters/EnrollmentStatus'
|
||||
@@ -7,69 +8,70 @@ import Gender from './filters/Gender'
|
||||
import StartYearAtUni from './filters/StartYearAtUni'
|
||||
import Courses from './filters/Courses'
|
||||
import Programmes from './filters/Programmes'
|
||||
import useAnalytics from './useAnalytics'
|
||||
import Grade from './filters/Grade'
|
||||
import Date from './filters/Date'
|
||||
import Age from './filters/Age'
|
||||
import AdmissionType from './filters/AdmissionType'
|
||||
import Tags from './filters/Tags'
|
||||
|
||||
export const PopulationStatisticsFilters = ({ query }) => {
|
||||
const Filters = ({ name, children }) => {
|
||||
const analytics = useAnalytics()
|
||||
useEffect(() => {
|
||||
analytics.setTarget('Population Statistics')
|
||||
analytics.setTarget(name)
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<GraduatedFromProgramme />
|
||||
<TransferredToProgramme />
|
||||
<EnrollmentStatus />
|
||||
<CreditsEarned />
|
||||
<Age />
|
||||
<Gender />
|
||||
<StartYearAtUni />
|
||||
{parseInt(query?.year, 10) >= 2020 ? <AdmissionType /> : null}
|
||||
<Courses />
|
||||
<Tags />
|
||||
</>
|
||||
)
|
||||
return React.Children.toArray(children)
|
||||
}
|
||||
|
||||
export const CoursePopulationFilters = () => {
|
||||
const analytics = useAnalytics()
|
||||
export const PopulationStatisticsFilters = ({ query }) => (
|
||||
<Filters name="Population Statistics">
|
||||
<GraduatedFromProgramme />
|
||||
<TransferredToProgramme />
|
||||
<EnrollmentStatus />
|
||||
<CreditsEarned />
|
||||
<Age />
|
||||
<Gender />
|
||||
<StartYearAtUni />
|
||||
{parseInt(query?.year, 10) >= 2020 ? <AdmissionType /> : null}
|
||||
<Courses />
|
||||
<Tags />
|
||||
<Date />
|
||||
</Filters>
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
analytics.setTarget('Course Population')
|
||||
})
|
||||
export const CoursePopulationFilters = () => (
|
||||
<Filters name="Course Population">
|
||||
<Grade />
|
||||
<Age />
|
||||
<Gender />
|
||||
<StartYearAtUni />
|
||||
<Courses />
|
||||
<Programmes />
|
||||
</Filters>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grade />
|
||||
<Age />
|
||||
<Gender />
|
||||
<StartYearAtUni />
|
||||
<Courses />
|
||||
<Programmes />
|
||||
</>
|
||||
)
|
||||
}
|
||||
export const CustomPopulationFilters = () => (
|
||||
<Filters name="Custom Population">
|
||||
<EnrollmentStatus />
|
||||
<Age />
|
||||
<Gender />
|
||||
<StartYearAtUni />
|
||||
<Courses />
|
||||
<Date />
|
||||
</Filters>
|
||||
)
|
||||
|
||||
export const CustomPopulationFilters = () => {
|
||||
const analytics = useAnalytics()
|
||||
|
||||
useEffect(() => {
|
||||
analytics.setTarget('Custom Population')
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<EnrollmentStatus />
|
||||
<Age />
|
||||
<Gender />
|
||||
<StartYearAtUni />
|
||||
<Courses />
|
||||
<Date />
|
||||
</>
|
||||
)
|
||||
}
|
||||
export const StudyGuidanceGroupFilters = ({ group }) => (
|
||||
<Filters name="Study Guidance Group">
|
||||
<EnrollmentStatus />
|
||||
<Age />
|
||||
<Gender />
|
||||
<StartYearAtUni />
|
||||
{group?.tags?.studyProgramme && group?.tags?.year >= 2020 && (
|
||||
<AdmissionType overrideCode={group.tags.studyProgramme} />
|
||||
)}
|
||||
{group?.tags?.studyProgramme && <GraduatedFromProgramme overrideCode={group.tags.studyProgramme} />}
|
||||
<Tags />
|
||||
<Courses />
|
||||
<Date />
|
||||
</Filters>
|
||||
)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { Form, Dropdown } from 'semantic-ui-react'
|
||||
import { connect } from 'react-redux'
|
||||
import FilterCard from './common/FilterCard'
|
||||
import ClearFilterButton from './common/ClearFilterButton'
|
||||
import useFilters from '../useFilters'
|
||||
import useAnalytics from '../useAnalytics'
|
||||
|
||||
const AdmissionType = ({ code }) => {
|
||||
const AdmissionType = ({ overrideCode = '' }) => {
|
||||
const { query } = useSelector(({ populations }) => populations)
|
||||
const code = overrideCode || query?.studyRights.programme || ''
|
||||
const { addFilter, removeFilter, activeFilters, withoutFilter } = useFilters()
|
||||
const analytics = useAnalytics()
|
||||
// Using undefined as default, since students can be filtered with null value
|
||||
@@ -81,12 +82,4 @@ const AdmissionType = ({ code }) => {
|
||||
)
|
||||
}
|
||||
|
||||
AdmissionType.propTypes = {
|
||||
code: PropTypes.string.isRequired,
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ populations }) => ({
|
||||
code: populations.query ? populations.query.studyRights.programme : '',
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps)(AdmissionType)
|
||||
export default AdmissionType
|
||||
|
||||
@@ -76,8 +76,17 @@ const AgeFilter = () => {
|
||||
|
||||
const active = Object.values(names).some(name => Object.keys(activeFilters).includes(name))
|
||||
|
||||
const infoText = null
|
||||
|
||||
return (
|
||||
<FilterCard title="Age" active={active} className="total-age-filter" contextKey={contextKey} name="ageFilter">
|
||||
<FilterCard
|
||||
title="Age"
|
||||
active={active}
|
||||
className="total-age-filter"
|
||||
contextKey={contextKey}
|
||||
name="ageFilter"
|
||||
info={infoText}
|
||||
>
|
||||
<Form>
|
||||
<div className="card-content">
|
||||
{Object.keys(currentValue).map(key => (
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { createContext, useState, useContext, useEffect } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { connect } from 'react-redux'
|
||||
import { getPopulationSelectedStudentCourses } from '../../../../redux/populationSelectedStudentCourses'
|
||||
|
||||
@@ -23,11 +22,6 @@ const CourseFilterProvider = ({ children, getPopulationSelectedStudentCourses })
|
||||
return <CourseFilterContext.Provider value={[state, setState]}>{children}</CourseFilterContext.Provider>
|
||||
}
|
||||
|
||||
CourseFilterProvider.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
getPopulationSelectedStudentCourses: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
const ConnectedProvider = connect(null, { getPopulationSelectedStudentCourses })(CourseFilterProvider)
|
||||
|
||||
export { ConnectedProvider as CourseFilterProvider }
|
||||
|
||||
@@ -41,6 +41,11 @@ export default () => {
|
||||
|
||||
const clearButtonDisabled = startDate === label && endDate === label
|
||||
|
||||
const infoText = {
|
||||
label: 'Selected date range only.',
|
||||
short: 'Include course credits from the selected date range only. Does not filter out students.',
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterCard
|
||||
title="Date of Course Credits"
|
||||
@@ -48,10 +53,8 @@ export default () => {
|
||||
footer={<ClearFilterButton disabled={clearButtonDisabled} onClick={reset} name={name} />}
|
||||
active={!clearButtonDisabled}
|
||||
name={name}
|
||||
info={infoText}
|
||||
>
|
||||
<div className="description-text">
|
||||
Include course credits from the selected date range only. Does not filter out students.
|
||||
</div>
|
||||
<div className="card-content" style={{ marginTop: '0.5rem' }}>
|
||||
<Form>
|
||||
<Form.Field>
|
||||
@@ -67,3 +70,6 @@ export default () => {
|
||||
</FilterCard>
|
||||
)
|
||||
}
|
||||
|
||||
// <div className="description-text"></div>
|
||||
// Include course credits from the selected date range only. Does not filter out students.
|
||||
|
||||
@@ -59,6 +59,11 @@ const EnrollmentStatus = ({ allSemesters, language }) => {
|
||||
setSemesters([])
|
||||
}
|
||||
|
||||
const infoText = {
|
||||
label: 'Filter students present or absent',
|
||||
short: 'Filter students based on if they are present or absent in certain semester/semesters.',
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterCard
|
||||
title="Enrollment Status"
|
||||
@@ -66,6 +71,7 @@ const EnrollmentStatus = ({ allSemesters, language }) => {
|
||||
active={active}
|
||||
footer={<ClearFilterButton disabled={!status && !semesters.length} onClick={clear} name={name} />}
|
||||
name={name}
|
||||
info={infoText}
|
||||
>
|
||||
<div className="card-content">
|
||||
<Form>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Form, Radio } from 'semantic-ui-react'
|
||||
import { connect } from 'react-redux'
|
||||
import { useSelector } from 'react-redux'
|
||||
import FilterCard from './common/FilterCard'
|
||||
import ClearFilterButton from './common/ClearFilterButton'
|
||||
import useFilters from '../useFilters'
|
||||
import useAnalytics from '../useAnalytics'
|
||||
|
||||
const GraduatedFromProgramme = ({ code }) => {
|
||||
const GraduatedFromProgramme = ({ overrideCode = '' }) => {
|
||||
const { query } = useSelector(({ populations }) => populations)
|
||||
const code = overrideCode || query?.studyRights.programme || ''
|
||||
const { addFilter, removeFilter } = useFilters()
|
||||
const analytics = useAnalytics()
|
||||
const [value, setValue] = useState(null)
|
||||
@@ -86,12 +87,4 @@ const GraduatedFromProgramme = ({ code }) => {
|
||||
)
|
||||
}
|
||||
|
||||
GraduatedFromProgramme.propTypes = {
|
||||
code: PropTypes.string.isRequired,
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ populations }) => ({
|
||||
code: populations.query ? populations.query.studyRights.programme : '',
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps)(GraduatedFromProgramme)
|
||||
export default GraduatedFromProgramme
|
||||
|
||||
@@ -43,6 +43,8 @@ export default () => {
|
||||
value: tag,
|
||||
}))
|
||||
|
||||
if (Object.entries(countsByTag).length === 0) return null
|
||||
|
||||
return (
|
||||
<FilterCard
|
||||
title="Tags"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Form, Radio } from 'semantic-ui-react'
|
||||
import FilterCard from './common/FilterCard'
|
||||
import ClearFilterButton from './common/ClearFilterButton'
|
||||
import useFilters from '../useFilters'
|
||||
import useAnalytics from '../useAnalytics'
|
||||
import { usePrevious } from '../../../common/hooks'
|
||||
@@ -38,13 +37,20 @@ export default () => {
|
||||
|
||||
const toggle = buttonValue => () => setValue(prev => (prev === buttonValue ? null : buttonValue))
|
||||
|
||||
const infoText = {
|
||||
label: 'Include and exclude students from this program',
|
||||
short: 'Possibility to exclude or include students who have transferred into this program',
|
||||
}
|
||||
|
||||
const rightMargin = { margin: '0 0.5rem 0' }
|
||||
return (
|
||||
<FilterCard
|
||||
title="Transfer Status"
|
||||
contextKey={name}
|
||||
active={active}
|
||||
footer={<ClearFilterButton disabled={!active} onClick={() => setValue(null)} name={name} />}
|
||||
className="total-transfer-filter"
|
||||
name={name}
|
||||
info={infoText}
|
||||
>
|
||||
<Form>
|
||||
<div className="card-content">
|
||||
@@ -55,6 +61,7 @@ export default () => {
|
||||
checked={value === null}
|
||||
onChange={toggle(null)}
|
||||
data-cy={`${name}-all`}
|
||||
style={rightMargin}
|
||||
/>
|
||||
<Radio
|
||||
label="Transferred"
|
||||
@@ -62,7 +69,7 @@ export default () => {
|
||||
checked={value === 1}
|
||||
onChange={toggle(1)}
|
||||
data-cy={`${name}-have`}
|
||||
style={{ margin: '0.5rem 0' }}
|
||||
style={rightMargin}
|
||||
/>
|
||||
<Radio
|
||||
label="Not Transferred"
|
||||
@@ -70,6 +77,7 @@ export default () => {
|
||||
checked={value === 0}
|
||||
onChange={toggle(0)}
|
||||
data-cy={`${name}-havenot`}
|
||||
style={rightMargin}
|
||||
/>
|
||||
</Form.Field>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Button, Icon } from 'semantic-ui-react'
|
||||
|
||||
const ClearFilterButton = ({ disabled, onClick, name }) => (
|
||||
@@ -9,10 +8,4 @@ const ClearFilterButton = ({ disabled, onClick, name }) => (
|
||||
</Button>
|
||||
)
|
||||
|
||||
ClearFilterButton.propTypes = {
|
||||
disabled: PropTypes.bool.isRequired,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
}
|
||||
|
||||
export default ClearFilterButton
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Icon, Header } from 'semantic-ui-react'
|
||||
import WithHelpTooltip from '../../../Info/InfoWithHelpTooltip'
|
||||
import useFilterTray from '../../useFilterTray'
|
||||
|
||||
const FilterCard = ({ title, children, contextKey, name, active }) => {
|
||||
const FilterCard = ({ title, children, contextKey, name, active, info }) => {
|
||||
const [open, , toggleOpen] = useFilterTray(contextKey)
|
||||
|
||||
const renderWithHelp = info => {
|
||||
if (info) {
|
||||
return (
|
||||
<WithHelpTooltip tooltip={info} data-cy="tooltip-div">
|
||||
<Icon name={open ? 'caret down' : 'caret right'} style={{ visibility: active ? 'hidden' : 'visible' }} />
|
||||
<Header size="tiny" style={{ marginTop: '0', marginBottom: 0 }}>
|
||||
{title}
|
||||
</Header>
|
||||
</WithHelpTooltip>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Icon name={open ? 'caret down' : 'caret right'} style={{ visibility: active ? 'hidden' : 'visible' }} />
|
||||
<Header size="tiny" style={{ marginTop: '0', marginBottom: 0 }}>
|
||||
{title}
|
||||
</Header>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
@@ -13,22 +34,11 @@ const FilterCard = ({ title, children, contextKey, name, active }) => {
|
||||
onClick={active ? undefined : toggleOpen}
|
||||
data-cy={`${name}-header`}
|
||||
>
|
||||
<Icon name={open ? 'caret down' : 'caret right'} style={{ visibility: active ? 'hidden' : 'visible' }} />
|
||||
<Header size="tiny" style={{ marginTop: '0' }}>
|
||||
{title}
|
||||
</Header>
|
||||
{renderWithHelp(info)}
|
||||
</div>
|
||||
{(active || open) && children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
FilterCard.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
contextKey: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
active: PropTypes.bool.isRequired,
|
||||
}
|
||||
|
||||
export default FilterCard
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Input, Label, Popup, Button, Icon } from 'semantic-ui-react'
|
||||
|
||||
const NumericInput = ({ onChange, onKeyDown, onClear, value, label, clearButtonDisabled, className, name }) => (
|
||||
const NumericInput = ({
|
||||
onChange,
|
||||
onKeyDown = () => {},
|
||||
onClear,
|
||||
value,
|
||||
label,
|
||||
clearButtonDisabled,
|
||||
className,
|
||||
name,
|
||||
}) => (
|
||||
<Input
|
||||
labelPosition="left"
|
||||
size="mini"
|
||||
@@ -30,20 +38,4 @@ const NumericInput = ({ onChange, onKeyDown, onClear, value, label, clearButtonD
|
||||
</Input>
|
||||
)
|
||||
|
||||
NumericInput.propTypes = {
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onKeyDown: PropTypes.func,
|
||||
onClear: PropTypes.func.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
label: PropTypes.node.isRequired,
|
||||
clearButtonDisabled: PropTypes.bool.isRequired,
|
||||
className: PropTypes.string,
|
||||
name: PropTypes.string.isRequired,
|
||||
}
|
||||
|
||||
NumericInput.defaultProps = {
|
||||
onKeyDown: () => {},
|
||||
className: null,
|
||||
}
|
||||
|
||||
export default NumericInput
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Segment, Header } from 'semantic-ui-react'
|
||||
import './filterTray.css'
|
||||
import useFilters from './useFilters'
|
||||
|
||||
export const contextKey = 'filterTray'
|
||||
|
||||
const FilterTray = ({ children, filterSet, visible }) => {
|
||||
const FilterTray = ({ children, filterSet, visible = true }) => {
|
||||
const { filteredStudents, allStudents } = useFilters()
|
||||
|
||||
if (!visible || !allStudents.length) {
|
||||
@@ -16,8 +15,8 @@ const FilterTray = ({ children, filterSet, visible }) => {
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'flex-start', width: '100%' }}>
|
||||
<Segment style={{ marginLeft: '0.5rem', width: '12rem', position: 'sticky', top: '10px' }}>
|
||||
<Header size="small" style={{ textAlign: 'center' }}>
|
||||
<Segment style={{ marginLeft: '0.5rem', width: '16rem', position: 'sticky', top: '10px' }}>
|
||||
<Header size="small" style={{ textAlign: 'center' }} data-cy="filtered-students">
|
||||
Filter students
|
||||
</Header>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
@@ -34,14 +33,4 @@ const FilterTray = ({ children, filterSet, visible }) => {
|
||||
)
|
||||
}
|
||||
|
||||
FilterTray.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
filterSet: PropTypes.node.isRequired,
|
||||
visible: PropTypes.bool,
|
||||
}
|
||||
|
||||
FilterTray.defaultProps = {
|
||||
visible: true,
|
||||
}
|
||||
|
||||
export default FilterTray
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { createContext, useState, useContext } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import TSA from '../../common/tsa'
|
||||
|
||||
const FilterAnalyticsContext = createContext([[], () => {}])
|
||||
@@ -10,10 +9,6 @@ export const FilterAnalyticsProvider = ({ children }) => {
|
||||
return <FilterAnalyticsContext.Provider value={[state, setState]}>{children}</FilterAnalyticsContext.Provider>
|
||||
}
|
||||
|
||||
FilterAnalyticsProvider.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
}
|
||||
|
||||
export default () => {
|
||||
const [state, setState] = useContext(FilterAnalyticsContext)
|
||||
const category = `Filters (${state})`
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
* Context for those expandable filter panels that need to be opened/closed remotely.
|
||||
*/
|
||||
import React, { createContext, useState, useContext } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
const FilterTrayContext = createContext([[], () => {}])
|
||||
FilterTrayContext.displayName = 'Filter Tray'
|
||||
@@ -13,10 +12,6 @@ export const FilterTrayProvider = ({ children }) => {
|
||||
return <FilterTrayContext.Provider value={[state, setState]}>{children}</FilterTrayContext.Provider>
|
||||
}
|
||||
|
||||
FilterTrayProvider.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
}
|
||||
|
||||
export default key => {
|
||||
const [state, setState] = useContext(FilterTrayContext)
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
* Context for managing filtering.
|
||||
*/
|
||||
import React, { createContext, useState, useContext, useEffect } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import lodash from 'lodash'
|
||||
|
||||
const defaultState = {
|
||||
@@ -37,10 +36,6 @@ export const FilterProvider = ({ children }) => {
|
||||
return <FilterContext.Provider value={[state, setState]}>{children}</FilterContext.Provider>
|
||||
}
|
||||
|
||||
FilterProvider.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
}
|
||||
|
||||
export default () => {
|
||||
const [state, setState] = useContext(FilterContext)
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Container, Header, Image, Divider, List } from 'semantic-ui-react'
|
||||
import moment from 'moment'
|
||||
import { connect } from 'react-redux'
|
||||
import propTypes from 'prop-types'
|
||||
import { isEqual } from 'lodash'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { images, getUserRoles, checkUserAccess } from '../common'
|
||||
import { useTitle } from '../common/hooks'
|
||||
import { builtAt } from '../conf'
|
||||
@@ -49,16 +47,17 @@ const OodiToOodikone = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const FrontPage = props => {
|
||||
const { userRoles, rights } = props
|
||||
|
||||
const FrontPage = () => {
|
||||
const { roles, rights } = useSelector(state => state.auth.token)
|
||||
const userRoles = getUserRoles(roles)
|
||||
useTitle()
|
||||
|
||||
const showItems = {
|
||||
populations: userRoles.includes('admin') || rights.length !== 0,
|
||||
studyProgramme: userRoles.includes('admin') || rights.length !== 0,
|
||||
students: userRoles.includes('admin') || rights.length !== 0,
|
||||
populations: userRoles.includes('admin') || rights.length > 0,
|
||||
studyProgramme: userRoles.includes('admin') || rights.length > 0,
|
||||
students: checkUserAccess(['studyGuidanceGroups', 'admin'], userRoles) || rights.length > 0,
|
||||
courseStatistics: checkUserAccess(['courseStatistics', 'admin'], userRoles) || rights.length > 0,
|
||||
teachers: userRoles.includes('admin') || userRoles.includes('teachers'),
|
||||
teachers: checkUserAccess(['teachers', 'admin'], userRoles),
|
||||
trends: true,
|
||||
feedback: true,
|
||||
}
|
||||
@@ -122,25 +121,4 @@ const FrontPage = props => {
|
||||
)
|
||||
}
|
||||
|
||||
FrontPage.propTypes = {
|
||||
userRoles: propTypes.arrayOf(propTypes.string),
|
||||
rights: propTypes.arrayOf(propTypes.string),
|
||||
}
|
||||
|
||||
FrontPage.defaultProps = {
|
||||
userRoles: [],
|
||||
rights: [],
|
||||
}
|
||||
|
||||
const mapStateToProps = ({
|
||||
auth: {
|
||||
token: { roles, rights },
|
||||
},
|
||||
}) => ({
|
||||
userRoles: getUserRoles(roles),
|
||||
rights,
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps, null, null, {
|
||||
areStatePropsEqual: isEqual,
|
||||
})(FrontPage)
|
||||
export default FrontPage
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import React, { useState, useRef } from 'react'
|
||||
import { Popup, Icon } from 'semantic-ui-react'
|
||||
import _ from 'lodash'
|
||||
|
||||
const InfoWithHelpTooltip = ({ children, tooltip, ...rest }) => {
|
||||
const popupContext = useRef()
|
||||
const [popupOpen, setPopupOpen] = useState(false)
|
||||
const [detailsOpen, setDetailsOPen] = useState(false)
|
||||
|
||||
const trigger = (
|
||||
<div style={{ display: 'flex' }} data-cy="tooltip-trigger">
|
||||
{children}
|
||||
<div ref={popupContext} style={{ display: 'inline-block', cursor: 'help' }} data-cy="popup-context">
|
||||
<Icon
|
||||
onClick={() => setPopupOpen(!popupOpen)}
|
||||
ref={popupContext}
|
||||
style={{ marginLeft: '0.5em', color: '#888' }}
|
||||
name="question circle outline"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const popupProps = _.defaults(rest, {
|
||||
position: 'right center',
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup
|
||||
wide={detailsOpen}
|
||||
hoverable
|
||||
size="tiny"
|
||||
open={popupOpen}
|
||||
onOpen={() => setPopupOpen(true)}
|
||||
onClose={() => {
|
||||
setPopupOpen(false)
|
||||
setDetailsOPen(false)
|
||||
}}
|
||||
trigger={trigger}
|
||||
context={popupContext}
|
||||
{...popupProps}
|
||||
on="hover"
|
||||
mouseEnterDelay={2000}
|
||||
>
|
||||
{!detailsOpen && (
|
||||
<>
|
||||
<div>{tooltip.label}</div>
|
||||
<span style={{ color: '#2185d0', cursor: 'pointer' }} onClick={() => setDetailsOPen(!detailsOpen)}>
|
||||
Lue lisää...
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{detailsOpen && <span>{tooltip.short}</span>}
|
||||
</Popup>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default InfoWithHelpTooltip
|
||||
@@ -42,7 +42,7 @@ const NavigationBar = props => {
|
||||
const visibleNavigationItems = {}
|
||||
Object.keys(allNavigationItems).forEach(key => {
|
||||
if (key === 'populations' || key === 'students') {
|
||||
if (!userRoles.includes('admin') && rights.length === 0) {
|
||||
if (!checkUserAccess(['admin', 'studyGuidanceGroups'], userRoles) && rights.length === 0) {
|
||||
return
|
||||
}
|
||||
} else if (key === 'courseStatistics') {
|
||||
|
||||
+61
-83
@@ -1,9 +1,7 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { Table, Form, Input, Tab, Icon } from 'semantic-ui-react'
|
||||
import { func, arrayOf, object, shape, string, bool } from 'prop-types'
|
||||
import { orderBy, debounce } from 'lodash'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import { clearCourseStats } from '../../redux/coursestats'
|
||||
import './populationCourseStats.css'
|
||||
import { PopulationCourseContext } from './PopulationCourseContext'
|
||||
@@ -43,7 +41,7 @@ const lodashSortOrderTypes = {
|
||||
DESC: 'desc',
|
||||
}
|
||||
|
||||
function updateCourseStatisticsCriteria(props, language, state) {
|
||||
const updateCourseStatisticsCriteria = (props, language, state) => {
|
||||
const { studentAmountLimit, sortCriteria, codeFilter, nameFilter, reversed } = state
|
||||
const {
|
||||
courses: { coursestatistics },
|
||||
@@ -90,8 +88,21 @@ const initialState = props => ({
|
||||
selectedStudentsLength: props.selectedStudentsLength || 0,
|
||||
})
|
||||
|
||||
function PopulationCourseStats(props) {
|
||||
const PopulationCourseStats = ({ courses, pending, selectedStudents, showFilter = true }) => {
|
||||
const dispatch = useDispatch()
|
||||
const { years } = useSelector(({ semesters }) => semesters.data)
|
||||
const populationCourses = useSelector(({ populationCourses }) => populationCourses)
|
||||
const isAdmin = getUserIsAdmin(useSelector(({ auth }) => auth.token.roles))
|
||||
const { language } = useLanguage()
|
||||
const props = {
|
||||
courses,
|
||||
isAdmin,
|
||||
pending,
|
||||
populationCourses,
|
||||
selectedStudents,
|
||||
showFilter,
|
||||
years,
|
||||
}
|
||||
|
||||
const [filterFields, setFilterFields] = useState({ codeFilter: '', nameFilter: '' })
|
||||
const [courseStatistics, setCourseStatistics] = useState(
|
||||
@@ -105,7 +116,7 @@ function PopulationCourseStats(props) {
|
||||
const filterAnalytics = useAnalytics()
|
||||
|
||||
useEffect(() => {
|
||||
if (state && props.courses) {
|
||||
if (!pending && state && props.courses) {
|
||||
const studentAmountLimit =
|
||||
state.selectedStudentsLength !== props.selectedStudents.length
|
||||
? Math.round(props.selectedStudents.length * 0.3)
|
||||
@@ -119,44 +130,46 @@ function PopulationCourseStats(props) {
|
||||
})
|
||||
setCourseStatistics(updateCourseStatisticsCriteria(props, language, state))
|
||||
}
|
||||
}, [props.courses, props.selectedStudents])
|
||||
}, [props.courses, props.selectedStudents, pending])
|
||||
|
||||
useEffect(() => {
|
||||
const { studentAmountLimit, codeFilter, nameFilter, reversed, sortCriteria } = state
|
||||
const {
|
||||
courses: { coursestatistics },
|
||||
language,
|
||||
} = props
|
||||
const studentAmountFilter = ({ stats }) => {
|
||||
const { students } = stats
|
||||
return studentAmountLimit === 0 || students >= studentAmountLimit
|
||||
if (!pending) {
|
||||
const { studentAmountLimit, codeFilter, nameFilter, reversed, sortCriteria } = state
|
||||
const {
|
||||
courses: { coursestatistics },
|
||||
language,
|
||||
} = props
|
||||
const studentAmountFilter = ({ stats }) => {
|
||||
const { students } = stats
|
||||
return studentAmountLimit === 0 || students >= studentAmountLimit
|
||||
}
|
||||
const courseCodeFilter = ({ course }) => {
|
||||
const { code } = course
|
||||
return code.toLowerCase().includes(codeFilter.toLowerCase())
|
||||
}
|
||||
const courseNameFilter = ({ course }) => {
|
||||
const { name } = course
|
||||
return getTextIn(name, language).toLowerCase().includes(nameFilter.toLowerCase())
|
||||
}
|
||||
|
||||
const filteredCourses =
|
||||
coursestatistics &&
|
||||
coursestatistics
|
||||
.filter(studentAmountFilter)
|
||||
.filter(c => !codeFilter || courseCodeFilter(c))
|
||||
.filter(c => !nameFilter || courseNameFilter(c))
|
||||
|
||||
const lodashSortOrder = reversed ? lodashSortOrderTypes.DESC : lodashSortOrderTypes.ASC
|
||||
|
||||
const sortedStatistics = orderBy(
|
||||
filteredCourses,
|
||||
[course => course.stats[sortCriteria], course => course.course.code],
|
||||
[lodashSortOrder, lodashSortOrderTypes.ASC]
|
||||
)
|
||||
|
||||
setCourseStatistics(sortedStatistics)
|
||||
}
|
||||
const courseCodeFilter = ({ course }) => {
|
||||
const { code } = course
|
||||
return code.toLowerCase().includes(codeFilter.toLowerCase())
|
||||
}
|
||||
const courseNameFilter = ({ course }) => {
|
||||
const { name } = course
|
||||
return getTextIn(name, language).toLowerCase().includes(nameFilter.toLowerCase())
|
||||
}
|
||||
|
||||
const filteredCourses =
|
||||
coursestatistics &&
|
||||
coursestatistics
|
||||
.filter(studentAmountFilter)
|
||||
.filter(c => !codeFilter || courseCodeFilter(c))
|
||||
.filter(c => !nameFilter || courseNameFilter(c))
|
||||
|
||||
const lodashSortOrder = reversed ? lodashSortOrderTypes.DESC : lodashSortOrderTypes.ASC
|
||||
|
||||
const sortedStatistics = orderBy(
|
||||
filteredCourses,
|
||||
[course => course.stats[sortCriteria], course => course.course.code],
|
||||
[lodashSortOrder, lodashSortOrderTypes.ASC]
|
||||
)
|
||||
|
||||
setCourseStatistics(sortedStatistics)
|
||||
}, [state.studentAmountLimit, state.codeFilter, state.nameFilter])
|
||||
}, [state.studentAmountLimit, state.codeFilter, state.nameFilter, pending])
|
||||
|
||||
const onFilterChange = (e, field) => {
|
||||
const {
|
||||
@@ -174,8 +187,8 @@ function PopulationCourseStats(props) {
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setFilters(filterFields)
|
||||
}, [filterFields])
|
||||
if (!pending) setFilters(filterFields)
|
||||
}, [filterFields, pending])
|
||||
|
||||
const handleCourseStatisticsCriteriaChange = () => {
|
||||
// eslint-disable-next-line react/no-access-state-in-setstate
|
||||
@@ -232,13 +245,14 @@ function PopulationCourseStats(props) {
|
||||
}
|
||||
|
||||
const onGoToCourseStatisticsClick = courseCode => {
|
||||
const { clearCourseStats: clearCourseStatsfn } = props
|
||||
sendAnalytics('Courses of Population course stats button clicked', courseCode)
|
||||
clearCourseStatsfn()
|
||||
dispatch(clearCourseStats())
|
||||
}
|
||||
|
||||
const onCourseNameCellClick = code => {
|
||||
const courseStatistic = props.populationCourses.data.coursestatistics.find(cs => cs.course.code === code)
|
||||
const courseStatistic =
|
||||
props.populationCourses.data.coursestatistics?.find(cs => cs.course.code === code) ||
|
||||
props.courses.coursestatistics?.find(cs => cs.course.code === code)
|
||||
if (courseStatistic) {
|
||||
const isSelected = courseIsSelected(code)
|
||||
const name = 'Course Filtername'
|
||||
@@ -280,7 +294,6 @@ function PopulationCourseStats(props) {
|
||||
)
|
||||
}
|
||||
|
||||
const { courses, pending } = props
|
||||
const { sortCriteria, reversed } = state
|
||||
const contextValue = {
|
||||
courseStatistics,
|
||||
@@ -324,8 +337,6 @@ function PopulationCourseStats(props) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { showFilter = true } = props
|
||||
|
||||
return (
|
||||
<div>
|
||||
{showFilter && (
|
||||
@@ -343,37 +354,4 @@ function PopulationCourseStats(props) {
|
||||
)
|
||||
}
|
||||
|
||||
PopulationCourseStats.propTypes = {
|
||||
courses: shape({
|
||||
coursestatistics: arrayOf(object),
|
||||
disciplines: shape({}),
|
||||
}).isRequired,
|
||||
populationCourses: shape({
|
||||
data: shape({ coursestatistics: arrayOf(shape({ course: shape({ code: string, name: shape({}) }) })) }),
|
||||
}).isRequired,
|
||||
clearCourseStats: func.isRequired,
|
||||
pending: bool.isRequired,
|
||||
selectedStudents: arrayOf(string).isRequired,
|
||||
years: shape({}), // eslint-disable-line
|
||||
showFilter: bool,
|
||||
language: string.isRequired,
|
||||
}
|
||||
|
||||
PopulationCourseStats.defaultProps = {
|
||||
showFilter: false,
|
||||
}
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const { years } = state.semesters.data
|
||||
const isAdmin = getUserIsAdmin(state.auth.token.roles)
|
||||
|
||||
return {
|
||||
years,
|
||||
populationCourses: state.populationCourses,
|
||||
isAdmin,
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, {
|
||||
clearCourseStats,
|
||||
})(withRouter(PopulationCourseStats))
|
||||
export default PopulationCourseStats
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { connect, useSelector } from 'react-redux'
|
||||
import { Table, Input, Tab, Icon } from 'semantic-ui-react'
|
||||
import { func, arrayOf, object, shape, string, bool } from 'prop-types'
|
||||
import { orderBy, debounce } from 'lodash'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import { clearCourseStats } from '../../redux/coursestats'
|
||||
@@ -92,7 +91,7 @@ const initialState = props => ({
|
||||
selectedStudentsLength: props.selectedStudentsLength || 0,
|
||||
})
|
||||
|
||||
function PopulationCourseStats(props) {
|
||||
const PopulationCourseStats = props => {
|
||||
const { language } = useLanguage()
|
||||
|
||||
const [filterFields, setFilterFields] = useState({ codeFilter: '', nameFilter: '' })
|
||||
@@ -268,7 +267,9 @@ function PopulationCourseStats(props) {
|
||||
}
|
||||
|
||||
const onCourseNameCellClick = code => {
|
||||
const courseStatistic = props.populationCourses.data.coursestatistics.find(cs => cs.course.code === code)
|
||||
const courseStatistic =
|
||||
props.populationCourses.data.coursestatistics?.find(cs => cs.course.code === code) ||
|
||||
props.courses.coursestatistics?.find(cs => cs.course.code === code)
|
||||
if (courseStatistic) {
|
||||
const isSelected = courseIsSelected(code)
|
||||
const name = 'Course Filtername'
|
||||
@@ -397,19 +398,6 @@ function PopulationCourseStats(props) {
|
||||
)
|
||||
}
|
||||
|
||||
PopulationCourseStats.propTypes = {
|
||||
courses: shape({
|
||||
coursestatistics: arrayOf(object),
|
||||
}).isRequired,
|
||||
populationCourses: shape({
|
||||
data: shape({ coursestatistics: arrayOf(shape({ course: shape({ code: string, name: shape({}) }) })) }),
|
||||
}).isRequired,
|
||||
clearCourseStats: func.isRequired,
|
||||
pending: bool.isRequired,
|
||||
selectedStudents: arrayOf(string).isRequired,
|
||||
language: string.isRequired,
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ populationCourses }) => ({
|
||||
populationCourses,
|
||||
})
|
||||
|
||||
@@ -1,25 +1,22 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { shape, arrayOf, string, bool } from 'prop-types'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { Segment, Button } from 'semantic-ui-react'
|
||||
import uuidv4 from 'uuid/v4'
|
||||
import SegmentDimmer from '../SegmentDimmer'
|
||||
import PopulationCourseStats from '../PopulationCourseStats'
|
||||
import CustomPopulationCourses from '../CustomPopulation/CustomPopulationCourses'
|
||||
import InfoBox from '../InfoBox'
|
||||
import InfoBox from '../Info/InfoBox'
|
||||
import FilterDegreeCoursesModal from './FilterDegreeCoursesModal'
|
||||
import useCourseFilter from '../FilterTray/filters/Courses/useCourseFilter'
|
||||
import infotooltips from '../../common/InfoToolTips'
|
||||
|
||||
const PopulationCourses = ({
|
||||
populationSelectedStudentCourses,
|
||||
populationCourses,
|
||||
selectedStudents,
|
||||
query,
|
||||
filteredStudents,
|
||||
}) => {
|
||||
const PopulationCourses = ({ selectedStudents, query = {}, filteredStudents }) => {
|
||||
const { setCoursesOnce, resetCourses, runCourseQuery } = useCourseFilter()
|
||||
const [showByStudytrack, setShowByStudytrack] = useState(true)
|
||||
const populationCourses = useSelector(({ populationCourses }) => populationCourses)
|
||||
const populationSelectedStudentCourses = useSelector(
|
||||
({ populationSelectedStudentCourses }) => populationSelectedStudentCourses
|
||||
)
|
||||
|
||||
const selectedPopulationCourses = populationSelectedStudentCourses.data
|
||||
? populationSelectedStudentCourses
|
||||
@@ -89,7 +86,6 @@ const PopulationCourses = ({
|
||||
<PopulationCourseStats
|
||||
key={selectedPopulationCourses.query.uuid}
|
||||
courses={selectedPopulationCourses.data}
|
||||
query={selectedPopulationCourses.query}
|
||||
pending={pending}
|
||||
selectedStudents={selectedStudents}
|
||||
/>
|
||||
@@ -100,21 +96,4 @@ const PopulationCourses = ({
|
||||
)
|
||||
}
|
||||
|
||||
PopulationCourses.defaultPropTypes = {
|
||||
query: {},
|
||||
}
|
||||
|
||||
PopulationCourses.propTypes = {
|
||||
populationSelectedStudentCourses: shape({ query: shape({}), data: shape({}), pending: bool }).isRequired,
|
||||
populationCourses: shape({ query: shape({}), data: shape({}), pending: bool }).isRequired,
|
||||
selectedStudents: arrayOf(string).isRequired,
|
||||
query: shape({}).isRequired,
|
||||
filteredStudents: arrayOf(shape({})).isRequired,
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ populationSelectedStudentCourses, populationCourses }) => ({
|
||||
populationCourses,
|
||||
populationSelectedStudentCourses,
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps)(PopulationCourses)
|
||||
export default PopulationCourses
|
||||
|
||||
+1
-14
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Table, Progress, Icon } from 'semantic-ui-react'
|
||||
import { number, func } from 'prop-types'
|
||||
|
||||
import ExternalCreditFilterToggle from './ExternalCreditFilterToggle'
|
||||
|
||||
const CollapsibleCreditRow = ({ min, max, studentCount, filteredLength, months }) => {
|
||||
@@ -76,17 +76,4 @@ const CollapsibleCreditRow = ({ min, max, studentCount, filteredLength, months }
|
||||
)
|
||||
}
|
||||
|
||||
CollapsibleCreditRow.propTypes = {
|
||||
min: number,
|
||||
max: number,
|
||||
studentCount: func.isRequired,
|
||||
filteredLength: number.isRequired,
|
||||
months: number.isRequired,
|
||||
}
|
||||
|
||||
CollapsibleCreditRow.defaultProps = {
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
}
|
||||
|
||||
export default CollapsibleCreditRow
|
||||
|
||||
+50
-53
@@ -1,62 +1,59 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
import { getMonths } from '../../../common/query'
|
||||
import { getStudentTotalCredits } from '../../../common'
|
||||
import CollapsibleCreditRow from './CollapsibleCreditRow'
|
||||
import React, { useState } from 'react'
|
||||
import { Divider, Icon, Grid, Label } from 'semantic-ui-react'
|
||||
|
||||
const CreditsGainedTab = ({ filteredStudents }) => {
|
||||
const months = getMonths(useLocation())
|
||||
const creditList = useMemo(() => filteredStudents.map(student => getStudentTotalCredits(student)), [filteredStudents])
|
||||
import CreditsGainedTable from './CreditsGainedTable'
|
||||
|
||||
const studentCount = (min, max = Infinity) =>
|
||||
max === 0
|
||||
? creditList.filter(credits => credits === 0).length
|
||||
: creditList.filter(credits => credits < max && credits >= min).length
|
||||
const admissionTypes = [
|
||||
'Todistusvalinta',
|
||||
'Koepisteet',
|
||||
'Yhteispisteet',
|
||||
'Avoin väylä',
|
||||
'Kilpailumenestys',
|
||||
'Muu',
|
||||
null,
|
||||
]
|
||||
|
||||
const limits = [
|
||||
[Math.ceil(months * (60 / 12))],
|
||||
[Math.ceil(months * (45 / 12)), Math.ceil(months * (60 / 12))],
|
||||
[Math.ceil(months * (30 / 12)), Math.ceil(months * (45 / 12))],
|
||||
[Math.ceil(months * (15 / 12)), Math.ceil(months * (30 / 12))],
|
||||
[1, Math.ceil(months * (15 / 12))],
|
||||
[null, 0],
|
||||
]
|
||||
const CreditsGainedTab = ({ allStudents, query }) => {
|
||||
const [show, setShow] = useState(false)
|
||||
if (!allStudents || !allStudents.length || !query) return null
|
||||
|
||||
const { studyRights } = query
|
||||
|
||||
const filterFunction = (student, type) =>
|
||||
student.studyrights.some(
|
||||
sr => sr.studyright_elements.some(e => e.code === studyRights?.programme) && type === sr.admission_type
|
||||
)
|
||||
|
||||
const getCreditsGainedTable = type => {
|
||||
const filteredStudents = allStudents.filter(s => filterFunction(s, type))
|
||||
return <CreditsGainedTable type={type || 'Ei valintatapaa'} filteredStudents={filteredStudents} />
|
||||
}
|
||||
|
||||
const admissionTypesAvailable = !allStudents.every(s => filterFunction(s, null))
|
||||
|
||||
return (
|
||||
<Table celled>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell collapsing />
|
||||
<Table.HeaderCell>Credits Gained During First {months} Months</Table.HeaderCell>
|
||||
<Table.HeaderCell>
|
||||
Number of Students
|
||||
<br />
|
||||
<span style={{ fontWeight: 100 }}>(n={filteredStudents.length})</span>
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>Percentage of Population</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
{limits.map(([min, max]) => (
|
||||
<CollapsibleCreditRow
|
||||
key={`table-row-${min}-${max}`}
|
||||
min={min}
|
||||
max={max}
|
||||
studentCount={studentCount}
|
||||
filteredLength={filteredStudents.length}
|
||||
months={Number(months)}
|
||||
/>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
<Grid>
|
||||
<Grid.Row>
|
||||
<Grid.Column width={16} data-cy="credits-gained-main-table">
|
||||
<CreditsGainedTable type="All students of the population" filteredStudents={allStudents} />
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
{admissionTypesAvailable && (
|
||||
<Divider
|
||||
className="credits-gained-divider"
|
||||
horizontal
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => setShow(!show)}
|
||||
>
|
||||
By admission type <Icon name={`angle ${show ? 'down' : 'right'}`} />
|
||||
<Label style={{ marginLeft: '1rem', marginBottom: '0.5rem' }} color="red">
|
||||
NEW!
|
||||
</Label>
|
||||
</Divider>
|
||||
)}
|
||||
{show && <Grid.Row>{admissionTypes.map(type => getCreditsGainedTable(type))}</Grid.Row>}
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
|
||||
CreditsGainedTab.propTypes = {
|
||||
filteredStudents: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
}
|
||||
|
||||
export default CreditsGainedTab
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
import { getMonths } from '../../../common/query'
|
||||
import { getStudentTotalCredits } from '../../../common'
|
||||
import CollapsibleCreditRow from './CollapsibleCreditRow'
|
||||
|
||||
const CreditsGainedTable = ({ filteredStudents, type }) => {
|
||||
const months = getMonths(useLocation())
|
||||
const creditList = useMemo(() => filteredStudents.map(student => getStudentTotalCredits(student)), [filteredStudents])
|
||||
|
||||
if (!filteredStudents || !filteredStudents.length || !type) return null
|
||||
|
||||
const studentCount = (min, max = Infinity) =>
|
||||
max === 0
|
||||
? creditList.filter(credits => credits === 0).length
|
||||
: creditList.filter(credits => credits < max && credits >= min).length
|
||||
|
||||
const limits = [
|
||||
[Math.ceil(months * (60 / 12))],
|
||||
[Math.ceil(months * (45 / 12)), Math.ceil(months * (60 / 12))],
|
||||
[Math.ceil(months * (30 / 12)), Math.ceil(months * (45 / 12))],
|
||||
[Math.ceil(months * (15 / 12)), Math.ceil(months * (30 / 12))],
|
||||
[1, Math.ceil(months * (15 / 12))],
|
||||
[null, 0],
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="credits-gained-table" data-cy={`credits-gained-table-${type}`}>
|
||||
<h3>{type}</h3>
|
||||
<Table celled>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell collapsing />
|
||||
<Table.HeaderCell>Credits Gained During First {months} Months</Table.HeaderCell>
|
||||
<Table.HeaderCell>
|
||||
Number of Students
|
||||
<br />
|
||||
<span style={{ fontWeight: 100 }}>(n={filteredStudents.length})</span>
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>Percentage of Population</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body data-cy="credits-gained-table-body">
|
||||
{limits.map(([min, max]) => (
|
||||
<CollapsibleCreditRow
|
||||
key={`table-row-${min}-${max}`}
|
||||
min={min}
|
||||
max={max}
|
||||
studentCount={studentCount}
|
||||
filteredLength={filteredStudents.length}
|
||||
months={Number(months)}
|
||||
/>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreditsGainedTable
|
||||
+14
-3
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { Grid } from 'semantic-ui-react'
|
||||
import { Divider, Grid, Label } from 'semantic-ui-react'
|
||||
|
||||
import StatisticsTable from './StatisticsTable'
|
||||
|
||||
@@ -25,16 +25,27 @@ const StatisticsTab = ({ allStudents, query }) => {
|
||||
|
||||
const getStatisticsTable = type => {
|
||||
const filteredStudents = allStudents.filter(s => filterFunction(s, type))
|
||||
if (filteredStudents.length === allStudents.length) return null
|
||||
return <StatisticsTable type={type || 'Ei valintatapaa'} filteredStudents={filteredStudents} />
|
||||
}
|
||||
|
||||
const admissionTypesAvailable = !allStudents.every(s => filterFunction(s, null))
|
||||
|
||||
return (
|
||||
<Grid padded centered>
|
||||
<Grid.Row>
|
||||
<StatisticsTable type="All students of the population" filteredStudents={allStudents} />
|
||||
</Grid.Row>
|
||||
<Grid.Row>{admissionTypes.map(type => getStatisticsTable(type))}</Grid.Row>
|
||||
{admissionTypesAvailable && (
|
||||
<>
|
||||
<Divider horizontal>
|
||||
By admission type
|
||||
<Label style={{ marginLeft: '1rem', marginBottom: '0.5rem' }} color="red">
|
||||
NEW!
|
||||
</Label>
|
||||
</Divider>
|
||||
<Grid.Row>{admissionTypes.map(type => getStatisticsTable(type))}</Grid.Row>
|
||||
</>
|
||||
)}
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
margin: 10px auto;
|
||||
}
|
||||
|
||||
#credit-gain-stats .credits-gained-table {
|
||||
margin: 10px auto;
|
||||
}
|
||||
|
||||
#credit-gain-stats .credits-gained-divider:hover {
|
||||
color: gray;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#credit-gain-stats .collapsing + th,
|
||||
#credit-gain-stats .collapsing + td {
|
||||
border-left: none;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Tab } from 'semantic-ui-react'
|
||||
import { useTabChangeAnalytics } from '../../../common/hooks'
|
||||
import InfoBox from '../../InfoBox'
|
||||
import InfoBox from '../../Info/InfoBox'
|
||||
import StatisticsTab from './StatisticsTab'
|
||||
import CreditsGainedTab from './CreditsGainedTab'
|
||||
import infotooltips from '../../../common/InfoToolTips'
|
||||
@@ -12,7 +11,7 @@ const CreditGainStats = ({ filteredStudents, query }) => {
|
||||
const renderCreditsGainTab = useCallback(() => {
|
||||
return (
|
||||
<Tab.Pane attached={false}>
|
||||
<CreditsGainedTab filteredStudents={filteredStudents} />
|
||||
<CreditsGainedTab allStudents={filteredStudents} query={query} />
|
||||
</Tab.Pane>
|
||||
)
|
||||
}, [filteredStudents])
|
||||
@@ -51,8 +50,4 @@ const CreditGainStats = ({ filteredStudents, query }) => {
|
||||
)
|
||||
}
|
||||
|
||||
CreditGainStats.propTypes = {
|
||||
filteredStudents: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
}
|
||||
|
||||
export default CreditGainStats
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React, { useRef } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { object, arrayOf, bool, shape, node } from 'prop-types'
|
||||
import { Message, Accordion, Label } from 'semantic-ui-react'
|
||||
import { Message, Accordion } from 'semantic-ui-react'
|
||||
import { useLocalStorage } from '../../common/hooks'
|
||||
|
||||
import CreditAccumulationGraphHighCharts from '../CreditAccumulationGraphHighCharts'
|
||||
import PopulationStudents from '../PopulationStudents'
|
||||
import PopulationCourses from '../PopulationCourses'
|
||||
import InfoBox from '../InfoBox'
|
||||
import InfoBox from '../Info/InfoBox'
|
||||
import CreditGainStats from './CreditGainStats'
|
||||
import AgeStats from './AgeStats'
|
||||
import useFilters from '../FilterTray/useFilters'
|
||||
@@ -18,7 +18,7 @@ import infotooltips from '../../common/InfoToolTips'
|
||||
|
||||
const sendAnalytics = sendEvent.populationStatistics
|
||||
|
||||
const PopulationDetails = ({ samples, queryIsSet, isLoading, query, selectedStudentsByYear, dataExport }) => {
|
||||
const PopulationDetails = ({ samples, queryIsSet, isLoading, query, dataExport }) => {
|
||||
const { allStudents, filteredStudents } = useFilters()
|
||||
const [trayOpen] = useFilterTray('filterTray')
|
||||
const { language } = useLanguage()
|
||||
@@ -58,11 +58,9 @@ const PopulationDetails = ({ samples, queryIsSet, isLoading, query, selectedStud
|
||||
|
||||
const graphs = (
|
||||
<CreditAccumulationGraphHighCharts
|
||||
students={samples}
|
||||
students={filteredStudents}
|
||||
title="Id"
|
||||
label={samples.label}
|
||||
maxCredits={samples.maxCredits}
|
||||
selectedStudents={selectedStudents}
|
||||
trayOpen={trayOpen}
|
||||
language={language}
|
||||
/>
|
||||
@@ -102,7 +100,10 @@ const PopulationDetails = ({ samples, queryIsSet, isLoading, query, selectedStud
|
||||
key: 1,
|
||||
title: {
|
||||
content: (
|
||||
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
|
||||
<span
|
||||
style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}
|
||||
data-cy="credit-statistics"
|
||||
>
|
||||
Credit statistics
|
||||
</span>
|
||||
),
|
||||
@@ -124,9 +125,6 @@ const PopulationDetails = ({ samples, queryIsSet, isLoading, query, selectedStud
|
||||
content: (
|
||||
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
|
||||
Age distribution
|
||||
<Label style={{ marginLeft: '1rem', marginBottom: '0.5rem' }} color="red">
|
||||
NEW!
|
||||
</Label>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
@@ -150,7 +148,6 @@ const PopulationDetails = ({ samples, queryIsSet, isLoading, query, selectedStud
|
||||
<div ref={courseTableRef}>
|
||||
<PopulationCourses
|
||||
selectedStudents={selectedStudents}
|
||||
selectedStudentsByYear={selectedStudentsByYear}
|
||||
query={query}
|
||||
allStudents={allStudents}
|
||||
filteredStudents={filteredStudents}
|
||||
@@ -172,7 +169,12 @@ const PopulationDetails = ({ samples, queryIsSet, isLoading, query, selectedStud
|
||||
content: {
|
||||
content: (
|
||||
<div ref={studentTableRef}>
|
||||
<PopulationStudents language={language} filteredStudents={filteredStudents} dataExport={dataExport} />
|
||||
<PopulationStudents
|
||||
variant="population"
|
||||
language={language}
|
||||
filteredStudents={filteredStudents}
|
||||
dataExport={dataExport}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -190,7 +192,6 @@ PopulationDetails.propTypes = {
|
||||
samples: arrayOf(object).isRequired,
|
||||
queryIsSet: bool.isRequired,
|
||||
isLoading: bool.isRequired,
|
||||
selectedStudentsByYear: shape({}).isRequired,
|
||||
query: shape({}).isRequired,
|
||||
dataExport: node,
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import React, { useState, useEffect, useRef } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import qs from 'query-string'
|
||||
import { func, arrayOf, shape, bool, string, object, oneOfType } from 'prop-types'
|
||||
import { Form, Button, Message, Icon, Grid } from 'semantic-ui-react'
|
||||
import uuidv4 from 'uuid/v4'
|
||||
import Datetime from 'react-datetime'
|
||||
@@ -21,7 +20,6 @@ import { momentFromFormat, reformatDate, textAndDescriptionSearch, getTextIn, ca
|
||||
import { useSearchHistory } from '../../common/hooks'
|
||||
import { setLoading } from '../../redux/graphSpinner'
|
||||
import './populationSearch.css'
|
||||
import { dropdownType } from '../../constants/types'
|
||||
import SearchHistory from '../SearchHistory'
|
||||
|
||||
const YEAR_DATE_FORMAT = 'YYYY'
|
||||
@@ -568,28 +566,6 @@ const PopulationSearchForm = props => {
|
||||
)
|
||||
}
|
||||
|
||||
PopulationSearchForm.propTypes = {
|
||||
language: string.isRequired,
|
||||
getProgrammes: func.isRequired,
|
||||
getPopulationStatistics: func.isRequired,
|
||||
getPopulationCourses: func.isRequired,
|
||||
getMandatoryCourses: func.isRequired,
|
||||
queries: shape({}).isRequired,
|
||||
studyProgrammes: shape({}), //eslint-disable-line
|
||||
studyTracks: arrayOf(dropdownType), //eslint-disable-line
|
||||
setLoading: func.isRequired,
|
||||
pending: bool, //eslint-disable-line
|
||||
getSemesters: func.isRequired,
|
||||
semesters: shape({}).isRequired,
|
||||
history: shape({}).isRequired,
|
||||
location: shape({}).isRequired,
|
||||
getTagsByStudytrackAction: func.isRequired,
|
||||
tags: oneOfType([arrayOf(shape({ tag_id: string, tagname: string })), object]).isRequired,
|
||||
onProgress: func.isRequired,
|
||||
clearSelected: func.isRequired,
|
||||
clearPopulations: func.isRequired,
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ semesters, settings, populations, populationProgrammes, tags }) => {
|
||||
const { language } = settings
|
||||
const { pending } = populationProgrammes
|
||||
|
||||
@@ -12,7 +12,7 @@ import TSA from '../../common/tsa'
|
||||
import './populationSearch.css'
|
||||
import infotooltips from '../../common/InfoToolTips'
|
||||
import { getTextIn } from '../../common'
|
||||
import InfoBox from '../InfoBox'
|
||||
import InfoBox from '../Info/InfoBox'
|
||||
import FilterActiveNote from './FilterActiveNote'
|
||||
|
||||
const PopulationsQueryTSA = ({ programmeCode, unitData }) => {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Segment, Header, Divider, Form, Button, Icon } from 'semantic-ui-react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Link } from 'react-router-dom'
|
||||
import PopulationSearchForm from './PopulationSearchForm'
|
||||
import PopulationSearchHistory from './PopulationSearchHistory'
|
||||
import ProgressBar from '../ProgressBar'
|
||||
import InfoBox from '../InfoBox'
|
||||
import InfoBox from '../Info/InfoBox'
|
||||
import { useProgress } from '../../common/hooks'
|
||||
import infotoolTips from '../../common/InfoToolTips'
|
||||
|
||||
@@ -41,19 +40,6 @@ const PopulationSearch = ({ populationFound, history, location, loading }) => {
|
||||
)
|
||||
}
|
||||
|
||||
PopulationSearch.propTypes = {
|
||||
history: PropTypes.shape({
|
||||
location: PropTypes.shape({
|
||||
search: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
}).isRequired,
|
||||
location: PropTypes.shape({
|
||||
search: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
populationFound: PropTypes.bool.isRequired,
|
||||
loading: PropTypes.bool.isRequired,
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ populations }) => ({
|
||||
populationFound: populations.data.students !== undefined,
|
||||
loading: !!populations.pending,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { memo, useEffect } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import React, { useEffect } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { createSelector } from '@reduxjs/toolkit'
|
||||
import { useLocation, useHistory } from 'react-router-dom'
|
||||
import { Header, Segment } from 'semantic-ui-react'
|
||||
import { flattenDeep } from 'lodash'
|
||||
import PopulationDetails from '../PopulationDetails'
|
||||
import { getTotalCreditsFromCourses } from '../../common'
|
||||
import { useTitle } from '../../common/hooks'
|
||||
import selectors from '../../selectors/populationDetails'
|
||||
import FilterTray from '../FilterTray'
|
||||
@@ -12,9 +12,23 @@ import { PopulationStatisticsFilters } from '../FilterTray/FilterSets'
|
||||
import PopulationSearch from '../PopulationSearch'
|
||||
import DataExport from './DataExport'
|
||||
|
||||
const PopulationStatistics = memo(props => {
|
||||
const { queryIsSet, selectedStudentsByYear, query, samples, location, history, isLoading, students } = props
|
||||
const selectPopulations = createSelector(
|
||||
({ populations }) => populations,
|
||||
populations => ({
|
||||
query: populations.query ?? {},
|
||||
queryIsSet: !!populations.query,
|
||||
isLoading: populations.pending === true,
|
||||
students: populations.data.students ?? [],
|
||||
})
|
||||
)
|
||||
|
||||
const PopulationStatistics = () => {
|
||||
const location = useLocation()
|
||||
const history = useHistory()
|
||||
const { samples } = useSelector(selectors.makePopulationsToData)
|
||||
const { query, queryIsSet, isLoading, students } = useSelector(selectPopulations)
|
||||
const { setAllStudents } = useFilters()
|
||||
|
||||
useTitle('Population statistics')
|
||||
|
||||
// Pass students to filter context.
|
||||
@@ -33,7 +47,6 @@ const PopulationStatistics = memo(props => {
|
||||
{location.search !== '' ? (
|
||||
<PopulationDetails
|
||||
queryIsSet={queryIsSet}
|
||||
selectedStudentsByYear={selectedStudentsByYear}
|
||||
query={query}
|
||||
samples={samples}
|
||||
isLoading={isLoading}
|
||||
@@ -44,41 +57,6 @@ const PopulationStatistics = memo(props => {
|
||||
</div>
|
||||
</FilterTray>
|
||||
)
|
||||
})
|
||||
|
||||
const mapStateToProps = state => {
|
||||
// haha copied from other place :mintu:
|
||||
const { samples, selectedStudentsByYear } = selectors.makePopulationsToData(state)
|
||||
// REFACTOR YES, IF YOU SEE THIS COMMENT YOU ARE OBLIGATED TO FIX IT
|
||||
if (samples.length > 0) {
|
||||
const creditsAndDates = samples.map(s => {
|
||||
const passedCourses = s.courses.filter(c => c.passed)
|
||||
const passedCredits = getTotalCreditsFromCourses(passedCourses)
|
||||
const dates = passedCourses.map(c => c.date)
|
||||
const datesWithCredits = passedCourses.filter(c => c.credits > 0).map(c => c.date)
|
||||
return { passedCredits, dates, datesWithCredits }
|
||||
})
|
||||
const credits = creditsAndDates.map(cd => cd.passedCredits)
|
||||
const dates = flattenDeep(creditsAndDates.map(cd => cd.dates)).map(date => new Date(date).getTime())
|
||||
const datesWithCredits = flattenDeep(creditsAndDates.map(cd => cd.datesWithCredits)).map(date =>
|
||||
new Date(date).getTime()
|
||||
)
|
||||
samples.maxCredits = Math.max(...credits)
|
||||
samples.maxDate = Math.max(...dates)
|
||||
samples.minDate = Math.min(...dates)
|
||||
samples.minDateWithCredits = Math.min(...datesWithCredits)
|
||||
}
|
||||
|
||||
const { populations } = state
|
||||
|
||||
return {
|
||||
query: populations.query ? populations.query : {},
|
||||
queryIsSet: !!populations.query,
|
||||
selectedStudentsByYear,
|
||||
samples,
|
||||
isLoading: populations.pending === true,
|
||||
students: populations.data.students || [],
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(PopulationStatistics)
|
||||
export default PopulationStatistics
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
import { shape, arrayOf, string, func, bool, element, oneOfType } from 'prop-types'
|
||||
import { sortBy } from 'lodash'
|
||||
import { useChunk } from '../../common/hooks'
|
||||
import './populationStudents.css'
|
||||
|
||||
const verticalTitle = title => {
|
||||
// https://stackoverflow.com/a/41396815
|
||||
return <div className="tableVerticalTitle">{title}</div>
|
||||
}
|
||||
|
||||
const DIRECTIONS = {
|
||||
ASC: 'ascending',
|
||||
DESC: 'descending',
|
||||
}
|
||||
|
||||
const FlippedCourseTable = ({
|
||||
defaultdescending,
|
||||
defaultsortkey,
|
||||
columns,
|
||||
data,
|
||||
tableProps,
|
||||
getRowKey,
|
||||
chunkifyBy,
|
||||
}) => {
|
||||
const [direction, setDirection] = useState(defaultdescending ? DIRECTIONS.DESC : DIRECTIONS.ASC)
|
||||
const [selected, setSelected] = useState(defaultsortkey == null ? columns[0].key : defaultsortkey)
|
||||
const [sortedRows, setSortedRows] = useState(data)
|
||||
const chunkedData = useChunk(data, chunkifyBy)
|
||||
|
||||
const handleSort = column => {
|
||||
if (selected === column) {
|
||||
setDirection(direction === DIRECTIONS.ASC ? DIRECTIONS.DESC : DIRECTIONS.ASC)
|
||||
} else {
|
||||
setSelected(column)
|
||||
setDirection(DIRECTIONS.DESC)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const column = columns.find(c => c.key === selected)
|
||||
if (!column) {
|
||||
setSortedRows(chunkedData)
|
||||
return
|
||||
}
|
||||
const { getRowVal } = column
|
||||
const sorted = sortBy(chunkedData, [getRowVal])
|
||||
setSortedRows(direction === DIRECTIONS.ASC ? sorted : sorted.reverse())
|
||||
}, [selected])
|
||||
|
||||
return (
|
||||
<Table sortable {...tableProps} className="fixed-header" striped>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell key="general" content={<b>Students:</b>} colSpan="2" style={{ textAlign: 'right' }} />
|
||||
{sortedRows
|
||||
.filter(row => row.studentNumber)
|
||||
.map(row => (
|
||||
<Table.HeaderCell key={getRowKey(row)} content={verticalTitle(row.studentNumber)} />
|
||||
))}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
<Table.Row>
|
||||
<Table.Cell>Course</Table.Cell>
|
||||
<Table.Cell>Total</Table.Cell>
|
||||
{sortedRows
|
||||
.filter(row => row.studentNumber)
|
||||
.map(row => (
|
||||
<Table.Cell key={getRowKey(row)} content={columns[2].getRowVal(row)} />
|
||||
))}
|
||||
</Table.Row>
|
||||
{columns
|
||||
.filter(col => col.cellProps)
|
||||
.filter(col => data[0][col.code] && data[0][col.code] > 0)
|
||||
.map(col => (
|
||||
<Table.Row key={col.key}>
|
||||
<Table.Cell key="name" content={col.title} onClick={() => handleSort(col.key)} />
|
||||
<Table.Cell key="totals" content={data[0][col.code]} />
|
||||
{sortedRows
|
||||
.filter(row => row.studentNumber)
|
||||
.map(row => (
|
||||
<Table.Cell key={getRowKey(row)} content={col.getRowContent(row)} />
|
||||
))}
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
FlippedCourseTable.propTypes = {
|
||||
tableProps: shape({}),
|
||||
getRowKey: func.isRequired,
|
||||
getRowProps: func,
|
||||
columns: arrayOf(
|
||||
shape({
|
||||
key: string.isRequired,
|
||||
title: oneOfType([element, string]),
|
||||
headerProps: shape({}),
|
||||
getRowVal: func,
|
||||
getRowContent: func,
|
||||
getCellProps: func,
|
||||
cellProps: shape({}),
|
||||
group: bool,
|
||||
children: arrayOf(),
|
||||
})
|
||||
).isRequired,
|
||||
data: arrayOf(shape({})).isRequired,
|
||||
defaultdescending: bool,
|
||||
defaultsortkey: string,
|
||||
collapsingHeaders: bool,
|
||||
showNames: bool,
|
||||
chunkifyBy: string,
|
||||
}
|
||||
|
||||
FlippedCourseTable.defaultProps = {
|
||||
tableProps: undefined,
|
||||
getRowProps: undefined,
|
||||
defaultdescending: false,
|
||||
defaultsortkey: null,
|
||||
collapsingHeaders: false,
|
||||
showNames: undefined,
|
||||
chunkifyBy: undefined,
|
||||
}
|
||||
|
||||
export default FlippedCourseTable
|
||||
@@ -1,207 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
import { shape, arrayOf, string, func, bool, element, oneOfType } from 'prop-types'
|
||||
import { sortBy } from 'lodash'
|
||||
import { useChunk } from '../../common/hooks'
|
||||
import './populationStudents.css'
|
||||
|
||||
const verticalTitle = title => {
|
||||
// https://stackoverflow.com/a/41396815
|
||||
return <div className="tableVerticalTitle">{title}</div>
|
||||
}
|
||||
|
||||
const intoCollapsing = column => ({
|
||||
title: verticalTitle(column.headerProps.title),
|
||||
headerProps: { ...column.headerProps, colSpan: 1, rowSpan: 2 },
|
||||
key: column.key,
|
||||
collapsed: true,
|
||||
parent: column.parent,
|
||||
})
|
||||
|
||||
const initialCollapsing = columns => {
|
||||
const coll = {}
|
||||
columns.forEach(column => {
|
||||
if (!column.parent || column.key === 'general') return
|
||||
|
||||
coll[column.headerProps.title] = intoCollapsing(column)
|
||||
})
|
||||
return coll
|
||||
}
|
||||
|
||||
const DIRECTIONS = {
|
||||
ASC: 'ascending',
|
||||
DESC: 'descending',
|
||||
}
|
||||
|
||||
const PopulationCourseTable = ({
|
||||
defaultdescending,
|
||||
defaultsortkey,
|
||||
columns,
|
||||
data,
|
||||
tableProps,
|
||||
getRowProps,
|
||||
getRowKey,
|
||||
collapsingHeaders,
|
||||
chunkifyBy,
|
||||
}) => {
|
||||
const [direction, setDirection] = useState(defaultdescending ? DIRECTIONS.DESC : DIRECTIONS.ASC)
|
||||
const [selected, setSelected] = useState(defaultsortkey == null ? columns[0].key : defaultsortkey)
|
||||
const [collapsed, setCollapsed] = useState(initialCollapsing(columns))
|
||||
const [columnsWithCollapsedHeaders, setColumnsWithCollapsedHeaders] = useState(columns)
|
||||
const [sortedRows, setSortedRows] = useState(data)
|
||||
const chunkedData = useChunk(data, chunkifyBy)
|
||||
|
||||
const handleSort = column => () => {
|
||||
if (selected === column) {
|
||||
setDirection(direction === DIRECTIONS.ASC ? DIRECTIONS.DESC : DIRECTIONS.ASC)
|
||||
} else {
|
||||
setSelected(column)
|
||||
setDirection(DIRECTIONS.DESC)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCollapse = column => () => {
|
||||
const { title } = column.headerProps
|
||||
|
||||
if (collapsed[title]) {
|
||||
const { [title]: _, ...rest } = collapsed
|
||||
setCollapsed(rest)
|
||||
} else {
|
||||
setCollapsed({ ...collapsed, [title]: column })
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const column = columns.find(c => c.key === selected)
|
||||
if (!column) {
|
||||
return chunkedData
|
||||
}
|
||||
const { getRowVal } = column
|
||||
const sorted = sortBy(chunkedData, [getRowVal])
|
||||
return setSortedRows(direction === DIRECTIONS.ASC ? sorted : sorted.reverse())
|
||||
}, [columns, columnsWithCollapsedHeaders])
|
||||
|
||||
useEffect(() => {
|
||||
setColumnsWithCollapsedHeaders(
|
||||
collapsingHeaders
|
||||
? columns
|
||||
.filter(c => c.headerProps && !collapsed[c.headerProps.title] && !c.collapsed)
|
||||
.sort((a, b) => a.headerProps.ordernumber - b.headerProps.ordernumber)
|
||||
.concat(Object.values(collapsed).sort((a, b) => a.headerProps.ordernumber - b.headerProps.ordernumber))
|
||||
: columns
|
||||
)
|
||||
}, [collapsed])
|
||||
|
||||
const sortDirection = name => (selected === name ? direction : null)
|
||||
|
||||
return (
|
||||
<Table sortable {...tableProps} className="fixed-header" striped>
|
||||
<Table.Header>
|
||||
{columnsWithCollapsedHeaders.length > 0 && (
|
||||
<Table.Row>
|
||||
{columnsWithCollapsedHeaders
|
||||
.filter(c => !c.child && !(c.title == null))
|
||||
.map(c => (
|
||||
<Table.HeaderCell
|
||||
key={c.key}
|
||||
content={c.title}
|
||||
onClick={
|
||||
c.parent && collapsingHeaders && c.key !== 'general'
|
||||
? handleCollapse(intoCollapsing(c))
|
||||
: handleSort(c.key)
|
||||
}
|
||||
sorted={c.parent ? undefined : sortDirection(c.key)}
|
||||
{...c.headerProps}
|
||||
/>
|
||||
))}
|
||||
</Table.Row>
|
||||
)}
|
||||
<Table.Row>
|
||||
{columns
|
||||
.filter(c => c.child && !(c.title == null) && !collapsed[c.childOf])
|
||||
.map(c => (
|
||||
<Table.HeaderCell
|
||||
key={c.key}
|
||||
content={c.title}
|
||||
onClick={handleSort(c.key)}
|
||||
sorted={sortDirection(c.key)}
|
||||
{...c.headerProps}
|
||||
/>
|
||||
))}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{sortedRows.map(row => (
|
||||
<Table.Row key={getRowKey(row)} {...(getRowProps && getRowProps(row))}>
|
||||
{columns
|
||||
.filter(c => !c.parent)
|
||||
.map(c => {
|
||||
if (collapsed[c.childOf]) {
|
||||
return null
|
||||
}
|
||||
if (c.key.includes('programmecode') || c.key.includes('programmename')) {
|
||||
return (
|
||||
<Table.Cell
|
||||
width={c.key === 'programmecode' ? '1' : '16'}
|
||||
key={c.key}
|
||||
content={c.getRowContent ? c.getRowContent(row) : c.getRowVal(row)}
|
||||
{...c.cellProps}
|
||||
{...(c.getCellProps && c.getCellProps(row))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Table.Cell
|
||||
key={c.key}
|
||||
content={c.getRowContent ? c.getRowContent(row) : c.getRowVal(row)}
|
||||
{...c.cellProps}
|
||||
{...(c.getCellProps && c.getCellProps(row))}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{Object.values(collapsed).map(e => (
|
||||
<Table.Cell className="tableCell" key={e.key} warning />
|
||||
))}
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
PopulationCourseTable.propTypes = {
|
||||
tableProps: shape({}),
|
||||
getRowKey: func.isRequired,
|
||||
getRowProps: func,
|
||||
columns: arrayOf(
|
||||
shape({
|
||||
key: string.isRequired,
|
||||
title: oneOfType([element, string]),
|
||||
headerProps: shape({}),
|
||||
getRowVal: func,
|
||||
getRowContent: func,
|
||||
getCellProps: func,
|
||||
cellProps: shape({}),
|
||||
group: bool,
|
||||
children: arrayOf(),
|
||||
})
|
||||
).isRequired,
|
||||
data: arrayOf(shape({})).isRequired,
|
||||
defaultdescending: bool,
|
||||
defaultsortkey: string,
|
||||
collapsingHeaders: bool,
|
||||
showNames: bool,
|
||||
chunkifyBy: string,
|
||||
}
|
||||
|
||||
PopulationCourseTable.defaultProps = {
|
||||
tableProps: undefined,
|
||||
getRowProps: undefined,
|
||||
defaultdescending: false,
|
||||
defaultsortkey: null,
|
||||
collapsingHeaders: false,
|
||||
showNames: undefined,
|
||||
chunkifyBy: undefined,
|
||||
}
|
||||
|
||||
export default PopulationCourseTable
|
||||
@@ -1,52 +1,33 @@
|
||||
import React, { useState } from 'react'
|
||||
import { shape, bool, string, arrayOf } from 'prop-types'
|
||||
import { Item, Icon, Popup } from 'semantic-ui-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { flatten } from 'lodash'
|
||||
import _ from 'lodash'
|
||||
import moment from 'moment'
|
||||
import { connect } from 'react-redux'
|
||||
import SortableTable from '../../SortableTable'
|
||||
import {
|
||||
getStudentTotalCredits,
|
||||
getTextIn,
|
||||
getNewestProgramme,
|
||||
reformatDate,
|
||||
copyToClipboard,
|
||||
getUserIsAdmin,
|
||||
} from '../../../common'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { useIsAdmin } from 'common/hooks'
|
||||
import SortableTable from 'components/SortableTable'
|
||||
import { getStudentTotalCredits, getTextIn, getNewestProgramme, reformatDate, copyToClipboard } from 'common'
|
||||
import { useGetStudyGuidanceGroupPopulationQuery } from 'redux/studyGuidanceGroups'
|
||||
import { PRIORITYCODE_TEXTS } from '../../../constants'
|
||||
import sendEvent from '../../../common/sendEvent'
|
||||
import useFilters from '../../FilterTray/useFilters'
|
||||
import useLanguage from '../../LanguagePicker/useLanguage'
|
||||
|
||||
// TODO: Refactoring in process, contains lot of duplicate code.
|
||||
|
||||
const GeneralTab = ({
|
||||
showNames,
|
||||
coursePopulation,
|
||||
customPopulation,
|
||||
populationStatistics,
|
||||
queryStudyrights,
|
||||
isAdmin,
|
||||
studentToTargetCourseDateMap,
|
||||
coursecode,
|
||||
queryYear,
|
||||
}) => {
|
||||
const GeneralTab = ({ group, populations, columnKeysToInclude, studentToTargetCourseDateMap, coursecode }) => {
|
||||
const { language } = useLanguage()
|
||||
const { filteredStudents } = useFilters()
|
||||
const [popupStates, setPopupStates] = useState({})
|
||||
const sendAnalytics = sendEvent.populationStudents
|
||||
|
||||
// TODO: Refactor this away:
|
||||
const { data: populationStatistics, query } = populations
|
||||
|
||||
if (!populationStatistics || !populationStatistics.elementdetails) return null
|
||||
|
||||
const selectedStudents = filteredStudents.map(stu => stu.studentNumber)
|
||||
|
||||
// TODO: This fixes crashing upon using back button. Find the root cause and fix it.
|
||||
if (!populationStatistics.elementdetails) {
|
||||
return null
|
||||
}
|
||||
|
||||
const students = Object.fromEntries(filteredStudents.map(stu => [stu.studentNumber, stu]))
|
||||
const queryStudyrights = query ? Object.values(query.studyRights) : []
|
||||
const cleanedQueryStudyrights = queryStudyrights.filter(sr => !!sr)
|
||||
const programmeCode = cleanedQueryStudyrights[0] || group?.tags?.studyProgramme
|
||||
|
||||
const popupTimeoutLength = 1000
|
||||
let timeout = null
|
||||
@@ -135,38 +116,32 @@ const GeneralTab = ({
|
||||
return null
|
||||
}
|
||||
|
||||
const studentToStudyrightStartMap = !(customPopulation || coursePopulation)
|
||||
? selectedStudents.reduce((res, sn) => {
|
||||
const targetStudyright = flatten(
|
||||
students[sn].studyrights.reduce((acc, curr) => {
|
||||
acc.push(curr.studyright_elements)
|
||||
return acc
|
||||
}, [])
|
||||
).filter(e => e.code === cleanedQueryStudyrights[0])
|
||||
res[sn] = targetStudyright[0] ? targetStudyright[0].startdate : null
|
||||
return res
|
||||
}, {})
|
||||
: null
|
||||
const studentToStudyrightStartMap = selectedStudents.reduce((res, sn) => {
|
||||
const targetStudyright = _.flatten(
|
||||
students[sn].studyrights.reduce((acc, curr) => {
|
||||
acc.push(curr.studyright_elements)
|
||||
return acc
|
||||
}, [])
|
||||
).filter(e => e.code === programmeCode)
|
||||
res[sn] = targetStudyright[0] ? targetStudyright[0].startdate : null
|
||||
return res
|
||||
}, {})
|
||||
|
||||
const studentToStudyrightActualStartMap = !(customPopulation || coursePopulation)
|
||||
? selectedStudents.reduce((res, sn) => {
|
||||
const targetStudyright = students[sn].studyrights.find(studyright =>
|
||||
studyright.studyright_elements.some(e => e.code === cleanedQueryStudyrights[0])
|
||||
)
|
||||
res[sn] = targetStudyright ? targetStudyright.studystartdate : null
|
||||
return res
|
||||
}, {})
|
||||
: null
|
||||
const studentToStudyrightActualStartMap = selectedStudents.reduce((res, sn) => {
|
||||
const targetStudyright = students[sn].studyrights.find(studyright =>
|
||||
studyright.studyright_elements.some(e => e.code === programmeCode)
|
||||
)
|
||||
res[sn] = targetStudyright ? targetStudyright.studystartdate : null
|
||||
return res
|
||||
}, {})
|
||||
|
||||
const studentToStudyrightEndMap = !(customPopulation || coursePopulation)
|
||||
? selectedStudents.reduce((res, sn) => {
|
||||
const targetStudyright = students[sn].studyrights.find(studyright =>
|
||||
studyright.studyright_elements.some(e => e.code === cleanedQueryStudyrights[0])
|
||||
)
|
||||
res[sn] = targetStudyright && targetStudyright.graduated === 1 ? targetStudyright.enddate : null
|
||||
return res
|
||||
}, {})
|
||||
: null
|
||||
const studentToStudyrightEndMap = selectedStudents.reduce((res, sn) => {
|
||||
const targetStudyright = students[sn].studyrights.find(studyright =>
|
||||
studyright.studyright_elements.some(e => e.code === programmeCode)
|
||||
)
|
||||
res[sn] = targetStudyright && targetStudyright.graduated === 1 ? targetStudyright.enddate : null
|
||||
return res
|
||||
}, {})
|
||||
|
||||
const getActualStartDate = studentNumber => {
|
||||
const studyRightStart = studentToStudyrightStartMap[studentNumber]
|
||||
@@ -188,30 +163,30 @@ const GeneralTab = ({
|
||||
sendAnalytics('Copy all student emails to clipboard', 'Copy all student emails to clipboard')
|
||||
}
|
||||
|
||||
const containsStudyTracks = () => {
|
||||
const allStudyrights = selectedStudents.map(sn => students[sn]).map(st => st.studyrights)
|
||||
return allStudyrights
|
||||
.map(
|
||||
studyrights =>
|
||||
studyrightCodes(studyrights, 'studyright_elements').reduce((acc, elemArr) => {
|
||||
elemArr
|
||||
.filter(el => populationStatistics.elementdetails.data[el.code].type === 30)
|
||||
.forEach(el => acc.push(getTextIn(populationStatistics.elementdetails.data[el.code].name, language)))
|
||||
return acc
|
||||
}, []).length > 0
|
||||
)
|
||||
.some(el => el === true)
|
||||
}
|
||||
|
||||
const columns = []
|
||||
if (showNames) {
|
||||
columns.push(
|
||||
{ key: 'lastname', title: 'last name', getRowVal: s => s.lastname },
|
||||
{ key: 'firstname', title: 'given names', getRowVal: s => s.firstnames }
|
||||
// Filters to check data for whether to show certain columns
|
||||
const containsStudyTracks = selectedStudents
|
||||
.map(sn => students[sn])
|
||||
.map(st => st.studyrights)
|
||||
.map(
|
||||
studyrights =>
|
||||
studyrightCodes(studyrights, 'studyright_elements').reduce((acc, elemArr) => {
|
||||
elemArr
|
||||
.filter(el => populationStatistics.elementdetails.data[el.code].type === 30)
|
||||
.forEach(el => acc.push(getTextIn(populationStatistics.elementdetails.data[el.code].name, language)))
|
||||
return acc
|
||||
}, []).length > 0
|
||||
)
|
||||
}
|
||||
columns.push(
|
||||
{
|
||||
.some(el => el === true)
|
||||
|
||||
const containsOption = cleanedQueryStudyrights.some(code => code.startsWith('MH') || code.startsWith('KH'))
|
||||
|
||||
const shouldShowAdmissionType = parseInt(query?.year, 10) >= 2020 || parseInt(group?.tags?.year, 10) >= 2020
|
||||
|
||||
// All columns components user is able to use
|
||||
const columnsAvailable = {
|
||||
lastname: { key: 'lastname', title: 'last name', getRowVal: s => s.lastname },
|
||||
firstname: { key: 'firstname', title: 'given names', getRowVal: s => s.firstnames },
|
||||
studentnumber: {
|
||||
key: 'studentnumber',
|
||||
title: 'Student Number',
|
||||
getRowVal: s => (!s.obfuscated ? s.studentNumber : 'hidden'),
|
||||
@@ -222,7 +197,7 @@ const GeneralTab = ({
|
||||
),
|
||||
headerProps: { colSpan: 2 },
|
||||
},
|
||||
{
|
||||
icon: {
|
||||
key: 'icon',
|
||||
getRowVal: s =>
|
||||
!s.obfuscated && (
|
||||
@@ -237,22 +212,28 @@ const GeneralTab = ({
|
||||
</Item>
|
||||
),
|
||||
cellProps: { collapsing: true, className: 'iconCellNoPointer' },
|
||||
}
|
||||
)
|
||||
if (!(coursePopulation || customPopulation)) {
|
||||
columns.push({
|
||||
key: 'credits since start',
|
||||
},
|
||||
creditsSinceStart: {
|
||||
key: 'creditsSinceStart',
|
||||
title: 'credits since start of studyright',
|
||||
getRowVal: s => {
|
||||
const credits = getStudentTotalCredits(s)
|
||||
return credits
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (coursecode.length > 0) {
|
||||
columns.push({
|
||||
key: 'grade for single course',
|
||||
},
|
||||
creditsSinceStartByYear: {
|
||||
key: 'creditsSinceStartByYear',
|
||||
title: `credits since ${group?.tags?.year}`,
|
||||
getRowVal: s => {
|
||||
const credits = getStudentTotalCredits({
|
||||
...s,
|
||||
courses: s.courses.filter(c => new Date(c.date) > new Date(group?.tags?.year, 7, 1)),
|
||||
})
|
||||
return credits
|
||||
},
|
||||
},
|
||||
gradeForSingleCourse: {
|
||||
key: 'gradeForSingleCourse',
|
||||
title: 'Grade',
|
||||
getRowVal: s => {
|
||||
const grade = s.courses.filter(c => coursecode.includes(c.course_code))
|
||||
@@ -262,171 +243,148 @@ const GeneralTab = ({
|
||||
}
|
||||
return ''
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
columns.push({
|
||||
key: 'all credits',
|
||||
title: 'All Credits',
|
||||
getRowVal: s => s.credits,
|
||||
})
|
||||
|
||||
if (!(coursePopulation || customPopulation)) {
|
||||
columns.push({
|
||||
key: 'transferred from',
|
||||
},
|
||||
allCredits: {
|
||||
key: 'allCredits',
|
||||
title: 'All Credits',
|
||||
getRowVal: s => s.credits,
|
||||
},
|
||||
transferredFrom: {
|
||||
key: 'transferredFrom',
|
||||
title: 'Transferred From',
|
||||
getRowVal: s => (s.transferredStudyright ? transferFrom(s) : ''),
|
||||
})
|
||||
}
|
||||
if (containsStudyTracks() && !(coursePopulation || customPopulation)) {
|
||||
columns.push({
|
||||
key: 'studytrack',
|
||||
},
|
||||
studyTrack: containsStudyTracks && {
|
||||
key: 'studyTrack',
|
||||
title: 'Study Track',
|
||||
getRowVal: s => studytrack(s.studyrights).map(st => st.name)[0],
|
||||
})
|
||||
}
|
||||
|
||||
if (isAdmin && !(coursePopulation || customPopulation)) {
|
||||
columns.push(
|
||||
{
|
||||
key: 'priority',
|
||||
title: 'priority',
|
||||
getRowVal: s => priorityText(s.studyrights),
|
||||
},
|
||||
{
|
||||
key: 'extent',
|
||||
title: 'extent',
|
||||
getRowVal: s => extentCodes(s.studyrights),
|
||||
}
|
||||
)
|
||||
}
|
||||
columns.push({
|
||||
key: 'tags',
|
||||
title: 'Tags',
|
||||
getRowVal: s => (!s.obfuscated ? tags(s.tags) : ''),
|
||||
})
|
||||
|
||||
if (!(coursePopulation || customPopulation)) {
|
||||
if (isAdmin) {
|
||||
columns.push({
|
||||
key: 'studystartdate',
|
||||
title: 'start of studyright',
|
||||
getRowVal: s => new Date(studentToStudyrightStartMap[s.studentNumber]).getTime(),
|
||||
getRowContent: s => reformatDate(studentToStudyrightStartMap[s.studentNumber], 'YYYY-MM-DD'),
|
||||
})
|
||||
}
|
||||
|
||||
// potentially will replace the 'start of studyright' column - both present for validation
|
||||
columns.push({
|
||||
key: 'studystartdateactual',
|
||||
},
|
||||
priority: {
|
||||
key: 'priority',
|
||||
title: 'priority',
|
||||
getRowVal: s => priorityText(s.studyrights),
|
||||
},
|
||||
extent: {
|
||||
key: 'extent',
|
||||
title: 'extent',
|
||||
getRowVal: s => extentCodes(s.studyrights),
|
||||
},
|
||||
tags: {
|
||||
key: 'tags',
|
||||
title: 'Tags',
|
||||
getRowVal: s => (!s.obfuscated ? tags(s.tags) : ''),
|
||||
},
|
||||
studyStartDate: {
|
||||
key: 'studyStartDate',
|
||||
title: 'start of studyright',
|
||||
getRowVal: s => new Date(studentToStudyrightStartMap[s.studentNumber]).getTime(),
|
||||
getRowContent: s => reformatDate(studentToStudyrightStartMap[s.studentNumber], 'YYYY-MM-DD'),
|
||||
},
|
||||
studyStartDateActual: {
|
||||
key: 'studyStartDateActual',
|
||||
title: 'started in studyright',
|
||||
getRowVal: s => new Date(getActualStartDate(s.studentNumber)).getTime(),
|
||||
getRowContent: s => reformatDate(getActualStartDate(s.studentNumber), 'YYYY-MM-DD'),
|
||||
})
|
||||
|
||||
columns.push({
|
||||
key: 'enddate',
|
||||
},
|
||||
endDate: {
|
||||
key: 'endDate',
|
||||
title: 'graduation date',
|
||||
getRowVal: s => new Date(studentToStudyrightEndMap[s.studentNumber]).getTime(),
|
||||
getRowContent: s =>
|
||||
studentToStudyrightEndMap[s.studentNumber]
|
||||
? reformatDate(studentToStudyrightEndMap[s.studentNumber], 'YYYY-MM-DD')
|
||||
: '',
|
||||
})
|
||||
}
|
||||
|
||||
if (!(coursePopulation || customPopulation) && parseInt(queryYear, 10) >= 2020) {
|
||||
const code = queryStudyrights[0]
|
||||
columns.push({
|
||||
key: 'admission type',
|
||||
},
|
||||
admissionType: shouldShowAdmissionType && {
|
||||
key: 'admissionType',
|
||||
title: 'admission type',
|
||||
getRowVal: s => {
|
||||
const studyright = s.studyrights.find(sr => sr.studyright_elements.some(e => e.code === code))
|
||||
const studyright = s.studyrights.find(sr => sr.studyright_elements.some(e => e.code === programmeCode))
|
||||
return studyright && studyright.admission_type ? studyright.admission_type : 'Ei valintatapaa'
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (coursePopulation || customPopulation) {
|
||||
columns.push(
|
||||
{
|
||||
key: 'programme',
|
||||
title: 'Study Programme',
|
||||
getRowVal: s => getTextIn(mainProgramme(s.studyrights, s.studentNumber), language) || 'No programme',
|
||||
},
|
||||
{
|
||||
key: 'startyear',
|
||||
title: 'Start Year at Uni',
|
||||
getRowVal: s => (!s.obfuscated ? reformatDate(s.started, 'YYYY') : ''),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (cleanedQueryStudyrights.some(code => code.startsWith('MH') || code.startsWith('KH')))
|
||||
columns.push({
|
||||
},
|
||||
programme: {
|
||||
key: 'programme',
|
||||
title: 'Study Programme',
|
||||
getRowVal: s => getTextIn(mainProgramme(s.studyrights, s.studentNumber), language) || 'No programme',
|
||||
},
|
||||
startYear: {
|
||||
key: 'startYear',
|
||||
title: 'Start Year at Uni',
|
||||
getRowVal: s => (!s.obfuscated ? reformatDate(s.started, 'YYYY') : ''),
|
||||
},
|
||||
option: containsOption && {
|
||||
key: 'option',
|
||||
title: cleanedQueryStudyrights.some(code => code.startsWith('MH')) ? 'Bachelor' : 'Master',
|
||||
getRowVal: s => (s.option ? getTextIn(s.option.name, language) : ''),
|
||||
})
|
||||
|
||||
if (showNames) {
|
||||
columns.push(
|
||||
{
|
||||
key: 'email',
|
||||
title: (
|
||||
<>
|
||||
email
|
||||
<Popup
|
||||
trigger={<Icon link name="copy" onClick={copyToClipboardAll} style={{ float: 'right' }} />}
|
||||
content="Copied email list!"
|
||||
on="click"
|
||||
open={popupStates['0']}
|
||||
onClose={() => handlePopupClose('0')}
|
||||
onOpen={() => handlePopupOpen('0')}
|
||||
position="top right"
|
||||
/>
|
||||
</>
|
||||
),
|
||||
getRowVal: s => s.email,
|
||||
headerProps: { colSpan: 2 },
|
||||
},
|
||||
{
|
||||
key: 'copy email',
|
||||
getRowVal: s =>
|
||||
s.email && !s.obfuscated ? (
|
||||
<Popup
|
||||
trigger={
|
||||
<Icon
|
||||
link
|
||||
name="copy outline"
|
||||
onClick={() => {
|
||||
copyToClipboard(s.email)
|
||||
sendAnalytics("Copy student's email to clipboard", "Copy student's email to clipboard")
|
||||
}}
|
||||
style={{ float: 'right' }}
|
||||
/>
|
||||
}
|
||||
content="Email copied!"
|
||||
on="click"
|
||||
open={popupStates[s.studentNumber]}
|
||||
onClose={() => handlePopupClose(s.studentNumber)}
|
||||
onOpen={() => handlePopupOpen(s.studentNumber)}
|
||||
position="top right"
|
||||
/>
|
||||
) : null,
|
||||
headerProps: { onClick: null, sorted: null },
|
||||
cellProps: { collapsing: true, className: 'iconCellNoPointer' },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (isAdmin) {
|
||||
columns.push({
|
||||
},
|
||||
email: {
|
||||
key: 'email',
|
||||
title: (
|
||||
<>
|
||||
email
|
||||
<Popup
|
||||
trigger={<Icon link name="copy" onClick={copyToClipboardAll} style={{ float: 'right' }} />}
|
||||
content="Copied email list!"
|
||||
on="click"
|
||||
open={popupStates['0']}
|
||||
onClose={() => handlePopupClose('0')}
|
||||
onOpen={() => handlePopupOpen('0')}
|
||||
position="top right"
|
||||
/>
|
||||
</>
|
||||
),
|
||||
getRowVal: s => s.email,
|
||||
headerProps: { colSpan: 2 },
|
||||
},
|
||||
copyEmail: {
|
||||
key: 'copyEmail',
|
||||
getRowVal: s =>
|
||||
s.email && !s.obfuscated ? (
|
||||
<Popup
|
||||
trigger={
|
||||
<Icon
|
||||
link
|
||||
name="copy outline"
|
||||
onClick={() => {
|
||||
copyToClipboard(s.email)
|
||||
sendAnalytics("Copy student's email to clipboard", "Copy student's email to clipboard")
|
||||
}}
|
||||
style={{ float: 'right' }}
|
||||
/>
|
||||
}
|
||||
content="Email copied!"
|
||||
on="click"
|
||||
open={popupStates[s.studentNumber]}
|
||||
onClose={() => handlePopupClose(s.studentNumber)}
|
||||
onOpen={() => handlePopupOpen(s.studentNumber)}
|
||||
position="top right"
|
||||
/>
|
||||
) : null,
|
||||
headerProps: { onClick: null, sorted: null },
|
||||
cellProps: { collapsing: true, className: 'iconCellNoPointer' },
|
||||
},
|
||||
updatedAt: {
|
||||
key: 'updatedAt',
|
||||
title: 'Last Updated At',
|
||||
getRowVal: s => reformatDate(s.updatedAt, 'YYYY-MM-DD HH:mm:ss'),
|
||||
})
|
||||
},
|
||||
}
|
||||
// Columns are shown in order they're declared above. JS guarantees this order of keys
|
||||
// to stay for non-integer keys
|
||||
const orderOfColumns = Object.keys(columnsAvailable).reduce(
|
||||
(acc, curr, ind) => ({
|
||||
...acc,
|
||||
[curr]: ind,
|
||||
}),
|
||||
{}
|
||||
)
|
||||
|
||||
const columns = _.chain(columnKeysToInclude)
|
||||
.map(colKey => columnsAvailable[colKey])
|
||||
.sortBy(col => orderOfColumns[col.key])
|
||||
.filter(col => !!col)
|
||||
.value()
|
||||
|
||||
return (
|
||||
<div style={{ overflowX: 'auto', maxHeight: '80vh' }}>
|
||||
@@ -446,36 +404,69 @@ const GeneralTab = ({
|
||||
)
|
||||
}
|
||||
|
||||
GeneralTab.defaultProps = {
|
||||
studentToTargetCourseDateMap: null,
|
||||
customPopulation: false,
|
||||
coursePopulation: false,
|
||||
// study guidance groups -feature uses different population + rtk query, so it needs to
|
||||
// be rendered differently. TODO: should rafactor this, maybe with using allStudents
|
||||
// from useFilters and making sure that it contains same students than the population
|
||||
// backend returns with population query below (so caching works)
|
||||
const StudyGuidanceGroupGeneralTabContainer = ({ group, ...props }) => {
|
||||
const groupStudentNumbers = group?.members?.map(({ personStudentNumber }) => personStudentNumber) || []
|
||||
const populations = useGetStudyGuidanceGroupPopulationQuery(groupStudentNumbers)
|
||||
return <GeneralTab populations={populations} group={group} {...props} />
|
||||
}
|
||||
|
||||
GeneralTab.propTypes = {
|
||||
showNames: bool.isRequired,
|
||||
coursePopulation: bool,
|
||||
customPopulation: bool,
|
||||
populationStatistics: shape({}).isRequired,
|
||||
queryStudyrights: arrayOf(string).isRequired,
|
||||
isAdmin: bool.isRequired,
|
||||
studentToTargetCourseDateMap: shape({}),
|
||||
}
|
||||
const GeneralTabContainer = ({ studyGuidanceGroup, variant, ...props }) => {
|
||||
const populations = useSelector(({ populations }) => populations)
|
||||
const { namesVisible } = useSelector(({ settings }) => settings)
|
||||
const isAdmin = useIsAdmin()
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const {
|
||||
populations,
|
||||
auth: {
|
||||
token: { roles },
|
||||
},
|
||||
} = state
|
||||
|
||||
return {
|
||||
isAdmin: getUserIsAdmin(roles),
|
||||
populationStatistics: populations.data,
|
||||
queryStudyrights: populations.query ? Object.values(populations.query.studyRights) : [],
|
||||
queryYear: populations?.query?.year,
|
||||
const getStudyGuidanceGroupColumns = () => {
|
||||
const cols = ['programme', 'startYear']
|
||||
if (studyGuidanceGroup?.tags?.year) cols.push('creditsSinceStartByYear')
|
||||
if (studyGuidanceGroup?.tags?.studyProgramme) cols.push('studyStartDate', 'studyStartDateActual', 'endDate')
|
||||
if (studyGuidanceGroup?.tags?.studyProgramme && studyGuidanceGroup?.tags?.year) {
|
||||
cols.push('admissionType')
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
const columnsByVariant = {
|
||||
customPopulation: ['programme', 'startYear'],
|
||||
coursePopulation: ['gradeForSingleCourse', 'programme', 'startYear'],
|
||||
population: [
|
||||
'creditsSinceStart',
|
||||
'transferredFrom',
|
||||
'priority',
|
||||
'extent',
|
||||
'studyStartDate',
|
||||
'studyStartDateActual',
|
||||
'endDate',
|
||||
'studyTrack',
|
||||
'admissionType',
|
||||
],
|
||||
studyGuidanceGroupPopulation: getStudyGuidanceGroupColumns(),
|
||||
}
|
||||
|
||||
const baseColumns = ['studentnumber', 'icon', 'allCredits', 'tags', 'updatedAt', 'option']
|
||||
const nameColumnsToAdd = namesVisible ? ['email', 'copyEmail', 'lastname', 'firstname'] : []
|
||||
const adminColumnsToFilter = isAdmin ? [] : ['priority', 'extent', 'studyStartDate', 'updatedAt']
|
||||
|
||||
const columnKeysToInclude = _.chain(baseColumns)
|
||||
.union(columnsByVariant[variant])
|
||||
.union(nameColumnsToAdd)
|
||||
.difference(adminColumnsToFilter)
|
||||
.value()
|
||||
|
||||
if (variant === 'studyGuidanceGroupPopulation') {
|
||||
return (
|
||||
<StudyGuidanceGroupGeneralTabContainer
|
||||
group={studyGuidanceGroup}
|
||||
columnKeysToInclude={columnKeysToInclude}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <GeneralTab populations={populations} columnKeysToInclude={columnKeysToInclude} {...props} />
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(GeneralTab)
|
||||
export default GeneralTabContainer
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import React, { Component, Fragment } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { string, arrayOf, object, func, bool, shape, node } from 'prop-types'
|
||||
import { Icon, Tab, Grid, Ref, Item, Dropdown } from 'semantic-ui-react'
|
||||
import { withRouter, Link } from 'react-router-dom'
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useSelector, useDispatch } from 'react-redux'
|
||||
import { Icon, Tab, Grid, Item, Dropdown } from 'semantic-ui-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { orderBy, uniqBy, flatten, sortBy, isNumber } from 'lodash'
|
||||
import scrollToComponent from 'react-scroll-to-component'
|
||||
import { getTextIn, getUserRoles } from '../../common'
|
||||
import { useTabChangeAnalytics } from '../../common/hooks'
|
||||
import { getTextIn } from '../../common'
|
||||
import { useTabChangeAnalytics, usePrevious, useIsAdmin } from '../../common/hooks'
|
||||
|
||||
import { toggleStudentListVisibility } from '../../redux/settings'
|
||||
import { getTagsByStudytrackAction } from '../../redux/tags'
|
||||
import { getStudentTagsByStudytrackAction } from '../../redux/tagstudent'
|
||||
|
||||
import StudentNameVisibilityToggle from '../StudentNameVisibilityToggle'
|
||||
import '../PopulationCourseStats/populationCourseStats.css'
|
||||
import SortableTable from '../SortableTable'
|
||||
import InfoBox from '../InfoBox'
|
||||
import InfoBox from '../Info/InfoBox'
|
||||
import CheckStudentList from './CheckStudentList'
|
||||
import TagPopulation from '../TagPopulation'
|
||||
import TagList from '../TagList'
|
||||
@@ -24,79 +22,96 @@ import GeneralTab from './StudentTable/GeneralTab'
|
||||
import sendEvent, { ANALYTICS_CATEGORIES } from '../../common/sendEvent'
|
||||
import infotoolTips from '../../common/InfoToolTips'
|
||||
|
||||
// TODO: Refactoring in process, contains lot of duplicate code.
|
||||
|
||||
const sendAnalytics = sendEvent.populationStudents
|
||||
|
||||
const StudentTableTabs = ({ panes, filterPanes }) => {
|
||||
// only its own component really because I needed to use hooks and didn't want to refactor
|
||||
// this megamillion line component :) /Joona
|
||||
const PopulationStudents = ({
|
||||
language,
|
||||
filteredStudents,
|
||||
studentToTargetCourseDateMap,
|
||||
dataExport,
|
||||
contentToInclude,
|
||||
coursecode = [],
|
||||
variant,
|
||||
studyGuidanceGroup,
|
||||
}) => {
|
||||
const [state, setState] = useState({})
|
||||
const studentRef = useRef()
|
||||
const dispatch = useDispatch()
|
||||
const { namesVisible: showNames, studentlistVisible: showList } = useSelector(({ settings }) => settings)
|
||||
const { data: tags } = useSelector(({ tags }) => tags)
|
||||
const { query } = useSelector(({ populations }) => populations)
|
||||
const queryStudyrights = query ? Object.values(query.studyRights) : []
|
||||
const { data: mandatoryCourses } = useSelector(({ populationMandatoryCourses }) => populationMandatoryCourses)
|
||||
const { data: populationCourses } = useSelector(({ populationCourses }) => populationCourses)
|
||||
const prevShowList = usePrevious(showList)
|
||||
const admin = useIsAdmin()
|
||||
const { handleTabChange } = useTabChangeAnalytics(
|
||||
ANALYTICS_CATEGORIES.populationStudents,
|
||||
'Change students table tab'
|
||||
)
|
||||
|
||||
return <Tab onTabChange={handleTabChange} panes={filterPanes(panes)} data-cy="student-table-tabs" />
|
||||
}
|
||||
useEffect(() => {
|
||||
if (tags && tags.length > 0) return
|
||||
const queryStudyright = queryStudyrights[0]
|
||||
dispatch(getTagsByStudytrackAction(queryStudyright))
|
||||
dispatch(getStudentTagsByStudytrackAction(queryStudyright))
|
||||
setState({ ...state, admin })
|
||||
}, [])
|
||||
|
||||
StudentTableTabs.propTypes = {
|
||||
panes: arrayOf(object).isRequired,
|
||||
filterPanes: func.isRequired,
|
||||
}
|
||||
|
||||
class PopulationStudents extends Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
|
||||
this.state = {}
|
||||
|
||||
this.studentsRef = React.createRef()
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const queryStudyright = this.props.queryStudyrights[0]
|
||||
const admin = this.props.userRoles.includes('admin')
|
||||
if (queryStudyright) {
|
||||
this.props.getTagsByStudytrack(queryStudyright)
|
||||
this.props.getStudentTagsStudyTrack(queryStudyright)
|
||||
useEffect(() => {
|
||||
if (!prevShowList && showList) {
|
||||
scrollToComponent(studentRef.current, { align: 'bottom' })
|
||||
}
|
||||
this.setState({ admin })
|
||||
}, [prevShowList])
|
||||
|
||||
const mandatoryCodes = mandatoryCourses.filter(course => course.visible && course.visible.visibility).map(c => c.code)
|
||||
let mandatoryPassed = {}
|
||||
if (populationCourses.coursestatistics) {
|
||||
const mandatorycodesMapCourseCodeToCourseID = new Map()
|
||||
mandatoryCourses
|
||||
.filter(course => course.visible && course.visible.visibility)
|
||||
.forEach(c => mandatorycodesMapCourseCodeToCourseID.set(c.code, c.id))
|
||||
|
||||
const courses = populationCourses.coursestatistics
|
||||
mandatoryPassed = mandatoryCodes.reduce((obj, code) => {
|
||||
const foundCourse = !!courses.find(c => c.course.code === code)
|
||||
|
||||
const searchCode = mandatorycodesMapCourseCodeToCourseID.get(code)
|
||||
|
||||
let foundSubsCourse
|
||||
if (searchCode) {
|
||||
foundSubsCourse = courses.find(c => {
|
||||
if (!c.course.substitutions) return false
|
||||
if (c.course.substitutions.length === null) return false
|
||||
return c.course.substitutions.includes(searchCode)
|
||||
})
|
||||
}
|
||||
|
||||
let passedArray = foundCourse ? Object.keys(courses.find(c => c.course.code === code).students.passed) : null
|
||||
|
||||
if (foundSubsCourse) {
|
||||
passedArray = passedArray
|
||||
? [...passedArray, ...Object.keys(foundSubsCourse.students.passed)]
|
||||
: Object.keys(foundSubsCourse.students.passed)
|
||||
}
|
||||
obj[code] = passedArray
|
||||
return obj
|
||||
}, {})
|
||||
}
|
||||
|
||||
componentDidUpdate = prevProps => {
|
||||
if (!prevProps.showList && this.props.showList) {
|
||||
scrollToComponent(this.studentsRef.current, { align: 'bottom' })
|
||||
}
|
||||
}
|
||||
|
||||
studyrightCodes = (studyrights, value) => {
|
||||
const { queryStudyrights } = this.props
|
||||
return studyrights
|
||||
.filter(sr => {
|
||||
const { studyright_elements: studyrightElements } = sr
|
||||
return studyrightElements.filter(sre => queryStudyrights.includes(sre.code)).length >= queryStudyrights.length
|
||||
})
|
||||
.map(a => a[value])
|
||||
}
|
||||
|
||||
handleRef = node => {
|
||||
this.studentsRef.current = node
|
||||
}
|
||||
|
||||
renderStudentTable() {
|
||||
const { customPopulation, coursePopulation } = this.props
|
||||
const renderStudentTable = () => {
|
||||
const verticalTitle = title => <div className="verticalTitle">{title}</div>
|
||||
|
||||
const hasPassedMandatory = (studentNumber, code) =>
|
||||
this.props.mandatoryPassed[code] && this.props.mandatoryPassed[code].includes(studentNumber)
|
||||
mandatoryPassed[code] && mandatoryPassed[code].includes(studentNumber)
|
||||
|
||||
const totalMandatoryPassed = studentNumber => {
|
||||
this.props.mandatoryCourses.reduce((acc, m) => {
|
||||
mandatoryCourses.reduce((acc, m) => {
|
||||
return hasPassedMandatory(studentNumber, m.code) ? acc + 1 : acc
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const nameColumns = this.props.showNames
|
||||
const nameColumns = showNames
|
||||
? [
|
||||
{
|
||||
key: 'lastname',
|
||||
@@ -164,7 +179,7 @@ class PopulationStudents extends Component {
|
||||
|
||||
const mandatoryCourseLabels = []
|
||||
|
||||
const labelToMandatoryCourses = this.props.mandatoryCourses.reduce((acc, e) => {
|
||||
const labelToMandatoryCourses = mandatoryCourses.reduce((acc, e) => {
|
||||
const label = e.label ? e.label.label : ''
|
||||
acc[label] = acc[label] || []
|
||||
if (acc[label].some(l => l.code === e.code)) return acc
|
||||
@@ -183,14 +198,14 @@ class PopulationStudents extends Component {
|
||||
const mandatoryTitle = m => {
|
||||
return (
|
||||
<>
|
||||
{getTextIn(m.name, this.props.language)}
|
||||
{getTextIn(m.name, language)}
|
||||
<br />
|
||||
{m.code}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const { visibleLabels, visibleCourseCodes } = this.props.mandatoryCourses.reduce(
|
||||
const { visibleLabels, visibleCourseCodes } = mandatoryCourses.reduce(
|
||||
(acc, cur) => {
|
||||
if (cur.visible && cur.visible.visibility) {
|
||||
acc.visibleLabels.add(cur.label_code)
|
||||
@@ -220,7 +235,11 @@ class PopulationStudents extends Component {
|
||||
</div>
|
||||
),
|
||||
parent: true,
|
||||
headerProps: { colSpan: labelToMandatoryCourses[e.label].length, title: e.label, ordernumber: e.orderNumber },
|
||||
headerProps: {
|
||||
colSpan: labelToMandatoryCourses[e.label].length,
|
||||
title: e.label,
|
||||
ordernumber: e.orderNumber,
|
||||
},
|
||||
}))
|
||||
)
|
||||
|
||||
@@ -242,8 +261,8 @@ class PopulationStudents extends Component {
|
||||
.map(m => ({
|
||||
key: `${m.label ? m.label.label : 'fix'}-${m.code}`, // really quick and dirty fix
|
||||
title: verticalTitle(mandatoryTitle(m)),
|
||||
cellProps: { title: `${m.code}, ${getTextIn(m.name, this.props.language)}` },
|
||||
headerProps: { title: `${m.code}, ${getTextIn(m.name, this.props.language)}` },
|
||||
cellProps: { title: `${m.code}, ${getTextIn(m.name, language)}` },
|
||||
headerProps: { title: `${m.code}, ${getTextIn(m.name, language)}` },
|
||||
getRowVal: s => (s.total ? getTotalRowVal(s, m) : hasPassedMandatory(s.studentNumber, m.code)),
|
||||
getRowContent: s => {
|
||||
if (s.total) return getTotalRowVal(s, m)
|
||||
@@ -257,33 +276,30 @@ class PopulationStudents extends Component {
|
||||
),
|
||||
]
|
||||
|
||||
const totals = this.props.filteredStudents.reduce(
|
||||
const totals = filteredStudents.reduce(
|
||||
(acc, s) => {
|
||||
const passedCourses = new Set()
|
||||
this.props.mandatoryCourses.forEach(m => {
|
||||
mandatoryCourses.forEach(m => {
|
||||
if (passedCourses.has(m.code)) return
|
||||
passedCourses.add(m.code)
|
||||
if (hasPassedMandatory(s.studentNumber, m.code)) ++acc[m.code]
|
||||
})
|
||||
return acc
|
||||
},
|
||||
this.props.mandatoryCourses.reduce((acc, e) => ({ ...acc, [e.code]: 0 }), { total: true })
|
||||
mandatoryCourses.reduce((acc, e) => ({ ...acc, [e.code]: 0 }), { total: true })
|
||||
)
|
||||
const mandatoryCourseData = [totals, ...this.props.filteredStudents]
|
||||
const mandatoryCourseData = [totals, ...filteredStudents]
|
||||
|
||||
// FIXME: here only for refactorment
|
||||
const { showNames, studentToTargetCourseDateMap, coursecode } = this.props
|
||||
const panes = [
|
||||
const panesAvailable = [
|
||||
{
|
||||
menuItem: 'General',
|
||||
render: () => (
|
||||
<Tab.Pane>
|
||||
<GeneralTab
|
||||
showNames={showNames}
|
||||
coursePopulation={coursePopulation}
|
||||
customPopulation={customPopulation}
|
||||
variant={variant}
|
||||
studentToTargetCourseDateMap={studentToTargetCourseDateMap}
|
||||
coursecode={coursecode}
|
||||
studyGuidanceGroup={studyGuidanceGroup}
|
||||
/>
|
||||
</Tab.Pane>
|
||||
),
|
||||
@@ -294,7 +310,7 @@ class PopulationStudents extends Component {
|
||||
<Tab.Pane>
|
||||
<div style={{ display: 'flex' }}>
|
||||
<div style={{ overflowX: 'auto', maxHeight: '80vh' }}>
|
||||
{this.props.mandatoryCourses.length > 0 && (
|
||||
{mandatoryCourses.length > 0 && (
|
||||
<SortableTable
|
||||
getRowKey={s => (s.total ? 'totals' : s.studentNumber)}
|
||||
tableProps={{
|
||||
@@ -308,7 +324,7 @@ class PopulationStudents extends Component {
|
||||
textAlign: 'center',
|
||||
}}
|
||||
collapsingHeaders
|
||||
showNames={this.props.showNames}
|
||||
showNames={showNames}
|
||||
columns={mandatoryCourseColumns}
|
||||
data={mandatoryCourseData}
|
||||
/>
|
||||
@@ -323,7 +339,7 @@ class PopulationStudents extends Component {
|
||||
render: () => (
|
||||
<Tab.Pane>
|
||||
<div style={{ overflowX: 'auto', maxHeight: '80vh' }}>
|
||||
{this.props.tags.length === 0 && (
|
||||
{tags.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
paddingLeft: '10px',
|
||||
@@ -337,7 +353,7 @@ class PopulationStudents extends Component {
|
||||
<h3>
|
||||
No tags defined. You can define them{' '}
|
||||
<Link
|
||||
to={`/study-programme/${this.props.queryStudyrights[0]}?p_m_tab=0&p_tab=6`}
|
||||
to={`/study-programme/${queryStudyrights[0]}?p_m_tab=0&p_tab=6`}
|
||||
onClick={() => {
|
||||
sendAnalytics('No tags defined button clicked', 'Tags tab')
|
||||
}}
|
||||
@@ -348,14 +364,14 @@ class PopulationStudents extends Component {
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
{this.props.tags.length > 0 && (
|
||||
{tags.length > 0 && (
|
||||
<>
|
||||
<TagPopulation
|
||||
tags={this.props.tags}
|
||||
selectedStudents={this.props.filteredStudents.map(stu => stu.studentNumber)}
|
||||
studytrack={this.props.queryStudyrights[0]}
|
||||
tags={tags}
|
||||
selectedStudents={filteredStudents.map(stu => stu.studentNumber)}
|
||||
studytrack={queryStudyrights[0]}
|
||||
/>
|
||||
<TagList studytrack={this.props.queryStudyrights[0]} selectedStudents={this.props.filteredStudents} />
|
||||
<TagList studytrack={queryStudyrights[0]} selectedStudents={filteredStudents} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -364,12 +380,7 @@ class PopulationStudents extends Component {
|
||||
},
|
||||
]
|
||||
|
||||
const filteredPanes = panesToFilter => {
|
||||
if (coursePopulation || customPopulation) {
|
||||
return panesToFilter.slice(0, 1)
|
||||
}
|
||||
return panesToFilter
|
||||
}
|
||||
const panes = panesAvailable.filter(pane => contentToInclude.panesToInclude.includes(pane.menuItem))
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -377,142 +388,59 @@ class PopulationStudents extends Component {
|
||||
<Grid.Column>
|
||||
<StudentNameVisibilityToggle />
|
||||
</Grid.Column>
|
||||
{this.props.dataExport && (
|
||||
{dataExport && (
|
||||
<Grid.Column textAlign="right">
|
||||
<Dropdown text="Export Data" icon="save" button labeled className="icon" direction="left">
|
||||
<Dropdown.Menu>{this.props.dataExport}</Dropdown.Menu>
|
||||
<Dropdown.Menu>{dataExport}</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</Grid.Column>
|
||||
)}
|
||||
</Grid>
|
||||
<StudentTableTabs panes={panes} filterPanes={filteredPanes} />
|
||||
<Tab onTabChange={handleTabChange} panes={panes} data-cy="student-table-tabs" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.props.filteredStudents.length === 0) {
|
||||
return null
|
||||
}
|
||||
if (filteredStudents.length === 0) return null
|
||||
|
||||
return (
|
||||
<Ref innerRef={this.handleRef}>
|
||||
<>
|
||||
<span style={{ marginRight: '0.5rem' }}>
|
||||
<InfoBox
|
||||
content={
|
||||
this.props.coursePopulation
|
||||
? infotoolTips.CoursePopulation.Students
|
||||
: infotoolTips.PopulationStatistics.Students
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
{this.state.admin ? (
|
||||
<CheckStudentList students={this.props.filteredStudents.map(stu => stu.studentNumber)} />
|
||||
) : null}
|
||||
{this.renderStudentTable()}
|
||||
</>
|
||||
</Ref>
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<span style={{ marginRight: '0.5rem' }} ref={studentRef}>
|
||||
<InfoBox content={contentToInclude.infotoolTipContent} />
|
||||
</span>
|
||||
{admin ? <CheckStudentList students={filteredStudents.map(stu => stu.studentNumber)} /> : null}
|
||||
{renderStudentTable()}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const PopulationStudentsContainer = ({ ...props }) => {
|
||||
const { variant } = props
|
||||
|
||||
if (!['population', 'customPopulation', 'coursePopulation', 'studyGuidanceGroupPopulation'].includes(variant)) {
|
||||
throw new Error(`${variant} is not a proper variant!`)
|
||||
}
|
||||
}
|
||||
|
||||
PopulationStudents.defaultProps = {
|
||||
studentToTargetCourseDateMap: null,
|
||||
customPopulation: false,
|
||||
coursePopulation: false,
|
||||
dataExport: null,
|
||||
coursecode: [],
|
||||
}
|
||||
|
||||
PopulationStudents.propTypes = {
|
||||
showNames: bool.isRequired,
|
||||
showList: bool.isRequired,
|
||||
language: string.isRequired,
|
||||
queryStudyrights: arrayOf(string).isRequired,
|
||||
mandatoryCourses: arrayOf(
|
||||
shape({
|
||||
name: shape({}).isRequired,
|
||||
code: string.isRequired,
|
||||
})
|
||||
).isRequired,
|
||||
mandatoryPassed: shape({}).isRequired,
|
||||
tags: arrayOf(shape({ tag_id: string, tagname: string, studytrack: string })).isRequired,
|
||||
getTagsByStudytrack: func.isRequired,
|
||||
getStudentTagsStudyTrack: func.isRequired,
|
||||
userRoles: arrayOf(string).isRequired,
|
||||
studentToTargetCourseDateMap: shape({}),
|
||||
coursePopulation: bool,
|
||||
customPopulation: bool,
|
||||
filteredStudents: arrayOf(shape({})).isRequired,
|
||||
dataExport: node,
|
||||
coursecode: arrayOf(string),
|
||||
}
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const {
|
||||
settings,
|
||||
populations,
|
||||
populationCourses,
|
||||
populationMandatoryCourses,
|
||||
tags,
|
||||
auth: {
|
||||
token: { roles },
|
||||
const contentByVariant = {
|
||||
population: {
|
||||
panesToInclude: ['General', 'Courses', 'Tags'],
|
||||
infotoolTipContent: infotoolTips.PopulationStatistics.Students,
|
||||
},
|
||||
coursePopulation: {
|
||||
panesToInclude: ['General'],
|
||||
infotoolTipContent: infotoolTips.CoursePopulation.Students,
|
||||
},
|
||||
customPopulation: {
|
||||
panesToInclude: ['General'],
|
||||
infotoolTipContent: infotoolTips.PopulationStatistics.Students,
|
||||
},
|
||||
studyGuidanceGroupPopulation: {
|
||||
panesToInclude: ['General'],
|
||||
infotoolTipContent: infotoolTips.PopulationStatistics.Students,
|
||||
},
|
||||
} = state
|
||||
|
||||
const mandatoryCodes = populationMandatoryCourses.data
|
||||
.filter(course => course.visible && course.visible.visibility)
|
||||
.map(c => c.code)
|
||||
|
||||
let mandatoryPassed = {}
|
||||
|
||||
if (populationCourses.data.coursestatistics) {
|
||||
const mandatorycodesMapCourseCodeToCourseID = new Map()
|
||||
populationMandatoryCourses.data
|
||||
.filter(course => course.visible && course.visible.visibility)
|
||||
.forEach(c => mandatorycodesMapCourseCodeToCourseID.set(c.code, c.id))
|
||||
|
||||
const courses = populationCourses.data.coursestatistics
|
||||
mandatoryPassed = mandatoryCodes.reduce((obj, code) => {
|
||||
const foundCourse = !!courses.find(c => c.course.code === code)
|
||||
|
||||
const searchCode = mandatorycodesMapCourseCodeToCourseID.get(code)
|
||||
|
||||
let foundSubsCourse
|
||||
if (searchCode) {
|
||||
foundSubsCourse = courses.find(c => {
|
||||
if (!c.course.substitutions) return false
|
||||
if (c.course.substitutions.length === null) return false
|
||||
return c.course.substitutions.includes(searchCode)
|
||||
})
|
||||
}
|
||||
|
||||
let passedArray = foundCourse ? Object.keys(courses.find(c => c.course.code === code).students.passed) : null
|
||||
|
||||
if (foundSubsCourse) {
|
||||
passedArray = passedArray
|
||||
? [...passedArray, ...Object.keys(foundSubsCourse.students.passed)]
|
||||
: Object.keys(foundSubsCourse.students.passed)
|
||||
}
|
||||
obj[code] = passedArray
|
||||
return obj
|
||||
}, {})
|
||||
}
|
||||
|
||||
return {
|
||||
showNames: settings.namesVisible,
|
||||
showList: settings.studentlistVisible,
|
||||
queryStudyrights: populations.query ? Object.values(populations.query.studyRights) : [],
|
||||
mandatoryCourses: populationMandatoryCourses.data,
|
||||
mandatoryPassed,
|
||||
tags: tags.data,
|
||||
userRoles: getUserRoles(roles),
|
||||
}
|
||||
return <PopulationStudents contentToInclude={contentByVariant[variant]} {...props} />
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, {
|
||||
toggleStudentListVisibility,
|
||||
getTagsByStudytrack: getTagsByStudytrackAction,
|
||||
getStudentTagsStudyTrack: getStudentTagsByStudytrackAction,
|
||||
})(withRouter(PopulationStudents))
|
||||
export default PopulationStudentsContainer
|
||||
|
||||
@@ -25,6 +25,9 @@ const ProtectedRoute = ({ requiredRoles = [], requireUserHasRights = false, ...r
|
||||
if (requiredRoles.includes('courseStatistics')) {
|
||||
return hasRequiredRoles || hasRequiredRights
|
||||
}
|
||||
if (rest.path.includes('students')) {
|
||||
return hasRequiredRoles || hasRequiredRights
|
||||
}
|
||||
return hasRequiredRoles && hasRequiredRights
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,13 @@ const Routes = () => (
|
||||
path="/study-programme/:studyProgrammeId?"
|
||||
component={StudyProgramme}
|
||||
/>
|
||||
<ProtectedRoute requireUserHasRights exact path={routes.students} component={StudentStatistics} />
|
||||
<ProtectedRoute
|
||||
requiredRoles={['admin', 'studyGuidanceGroups']}
|
||||
requireUserHasRights
|
||||
exact
|
||||
path={routes.students}
|
||||
component={StudentStatistics}
|
||||
/>
|
||||
<ProtectedRoute
|
||||
requiredRoles={['courseStatistics']}
|
||||
requireUserHasRights
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
import { shape, arrayOf, string, func, bool, element, oneOfType } from 'prop-types'
|
||||
import { sortBy } from 'lodash'
|
||||
import { useChunk } from '../../common/hooks'
|
||||
|
||||
@@ -166,39 +165,4 @@ const SortableTable = ({
|
||||
)
|
||||
}
|
||||
|
||||
SortableTable.propTypes = {
|
||||
tableProps: shape({}),
|
||||
getRowKey: func.isRequired,
|
||||
getRowProps: func,
|
||||
columns: arrayOf(
|
||||
shape({
|
||||
key: string.isRequired,
|
||||
title: oneOfType([element, string]),
|
||||
headerProps: shape({}),
|
||||
getRowVal: func,
|
||||
getRowContent: func,
|
||||
getCellProps: func,
|
||||
cellProps: shape({}),
|
||||
group: bool,
|
||||
children: arrayOf(),
|
||||
})
|
||||
).isRequired,
|
||||
data: arrayOf(shape({})).isRequired,
|
||||
defaultdescending: bool,
|
||||
defaultsortkey: string,
|
||||
collapsingHeaders: bool,
|
||||
showNames: bool,
|
||||
chunkifyBy: string,
|
||||
}
|
||||
|
||||
SortableTable.defaultProps = {
|
||||
tableProps: undefined,
|
||||
getRowProps: undefined,
|
||||
defaultdescending: false,
|
||||
defaultsortkey: null,
|
||||
collapsingHeaders: false,
|
||||
showNames: undefined,
|
||||
chunkifyBy: undefined,
|
||||
}
|
||||
|
||||
export default SortableTable
|
||||
|
||||
+6
-41
@@ -1,52 +1,31 @@
|
||||
import React, { useState } from 'react'
|
||||
import { shape, string, arrayOf } from 'prop-types'
|
||||
import { Menu, Tab, Input, Message } from 'semantic-ui-react'
|
||||
import { flattenDeep, cloneDeep } from 'lodash'
|
||||
import { flattenDeep } from 'lodash'
|
||||
import Highcharts from 'highcharts/highstock'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
import CreditAccumulationGraphHighCharts from '../../CreditAccumulationGraphHighCharts'
|
||||
import { byDateDesc, reformatDate, getTextIn } from '../../../common'
|
||||
import TSA from '../../../common/tsa'
|
||||
import useLanguage from '../../LanguagePicker/useLanguage'
|
||||
|
||||
const ANALYTICS_CATEGORY = 'Student stats'
|
||||
const sendAnalytics = (action, name, value) => TSA.Matomo.sendEvent(ANALYTICS_CATEGORY, action, name, value)
|
||||
|
||||
const CreditsGraph = ({ graphYearStart, student, absences }) => {
|
||||
const { language } = useLanguage()
|
||||
const selectedStart = graphYearStart || student.started
|
||||
const filteredCourses = student.courses.filter(c => new Date(c.date) > new Date(selectedStart))
|
||||
const newStudent = cloneDeep(student)
|
||||
newStudent.courses = filteredCourses
|
||||
const sample = [newStudent]
|
||||
|
||||
const selectedStart = new Date(graphYearStart ?? student.started)
|
||||
const dates = flattenDeep(student.courses.map(c => c.date)).map(d => new Date(d).getTime())
|
||||
sample.maxCredits = newStudent.courses.reduce((a, c) => {
|
||||
if (c.isStudyModuleCredit || !c.passed) return a + 0
|
||||
return a + c.credits
|
||||
}, 0)
|
||||
sample.maxDate = dates.length > 0 ? Math.max(...dates) : new Date().getTime()
|
||||
sample.minDate = new Date(selectedStart).getTime()
|
||||
const endDate = dates.length > 0 ? Math.max(...dates) : new Date().getTime()
|
||||
|
||||
return (
|
||||
<CreditAccumulationGraphHighCharts
|
||||
singleStudent
|
||||
students={sample}
|
||||
selectedStudents={[student.studentNumber]}
|
||||
title="Credit Accumulation"
|
||||
maxCredits={sample.maxCredits}
|
||||
students={[student]}
|
||||
startDate={selectedStart}
|
||||
endDate={endDate}
|
||||
absences={absences}
|
||||
language={language}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
CreditsGraph.propTypes = {
|
||||
student: shape({}).isRequired,
|
||||
absences: arrayOf(shape({})).isRequired,
|
||||
graphYearStart: string.isRequired,
|
||||
}
|
||||
|
||||
const chunkifyArray = (array, size = 1) => {
|
||||
if (!array) return []
|
||||
const firstChunk = array.slice(0, size) // create the first chunk of the given array
|
||||
@@ -205,12 +184,6 @@ const GradeGraph = ({ student, semesters, language }) => {
|
||||
)
|
||||
}
|
||||
|
||||
GradeGraph.propTypes = {
|
||||
student: shape({}).isRequired,
|
||||
semesters: shape({}).isRequired,
|
||||
language: string.isRequired,
|
||||
}
|
||||
|
||||
const StudentGraphs = ({ student, absences, graphYearStart, semesters, language }) => {
|
||||
const panes = [
|
||||
{
|
||||
@@ -233,12 +206,4 @@ const StudentGraphs = ({ student, absences, graphYearStart, semesters, language
|
||||
return <Tab panes={panes} />
|
||||
}
|
||||
|
||||
StudentGraphs.propTypes = {
|
||||
student: shape({}).isRequired,
|
||||
absences: arrayOf(shape({})).isRequired,
|
||||
graphYearStart: string.isRequired,
|
||||
semesters: shape({}).isRequired,
|
||||
language: string.isRequired,
|
||||
}
|
||||
|
||||
export default StudentGraphs
|
||||
|
||||
@@ -39,7 +39,7 @@ const StudentDetails = ({
|
||||
}) => {
|
||||
const dispatch = useDispatch()
|
||||
const { language } = useLanguage()
|
||||
const [graphYearStart, setGraphYear] = useState('')
|
||||
const [graphYearStart, setGraphYear] = useState(null)
|
||||
const [studyrightid, setStudyrightid] = useState('')
|
||||
const [honoursCode, setHonoursCode] = useState(null)
|
||||
|
||||
@@ -49,7 +49,7 @@ const StudentDetails = ({
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setGraphYear('')
|
||||
setGraphYear(null)
|
||||
if (studentNumber.length > 0) getStudent(studentNumber)
|
||||
else {
|
||||
resetStudent()
|
||||
@@ -151,7 +151,7 @@ const StudentDetails = ({
|
||||
|
||||
const handleStartDateChange = (elements, id) => {
|
||||
if (id === studyrightid) {
|
||||
setGraphYear('')
|
||||
setGraphYear(null)
|
||||
setStudyrightid('')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,33 +1,20 @@
|
||||
import React from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { shape, string, arrayOf } from 'prop-types'
|
||||
import { Segment, Header, Message } from 'semantic-ui-react'
|
||||
import { Segment, Header } from 'semantic-ui-react'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import { findStudents, getStudent, selectStudent } from '../../redux/students'
|
||||
import StudentSearch from './StudentSearch'
|
||||
import StudentDetails from './StudentDetails'
|
||||
import StudentNameVisibilityToggle from '../StudentNameVisibilityToggle'
|
||||
import { useTitle } from '../../common/hooks'
|
||||
import { getUserRoles, checkUserAccess } from '../../common'
|
||||
import { getUserRoles } from '../../common'
|
||||
import { toggleStudentNameVisibility } from '../../redux/settings'
|
||||
|
||||
const StudentStatistics = props => {
|
||||
const { match, userRoles, rights } = props
|
||||
const { match } = props
|
||||
const { studentNumber } = match.params
|
||||
|
||||
useTitle('Student statistics')
|
||||
checkUserAccess(['student', 'admin'], userRoles)
|
||||
|
||||
if (!checkUserAccess(['student', 'admin'], userRoles) && rights.length < 1)
|
||||
return (
|
||||
<div className="segmentContainer">
|
||||
<Message
|
||||
error
|
||||
color="red"
|
||||
header="You have no rights to access any data. If you should have access please contact grp-toska@helsinki.fi"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="segmentContainer">
|
||||
@@ -43,22 +30,6 @@ const StudentStatistics = props => {
|
||||
)
|
||||
}
|
||||
|
||||
StudentStatistics.propTypes = {
|
||||
match: shape({
|
||||
params: shape({
|
||||
studentNumber: string,
|
||||
}),
|
||||
}),
|
||||
userRoles: arrayOf(string).isRequired,
|
||||
rights: arrayOf(string).isRequired,
|
||||
}
|
||||
|
||||
StudentStatistics.defaultProps = {
|
||||
match: {
|
||||
params: { studentNumber: undefined },
|
||||
},
|
||||
}
|
||||
|
||||
const mapStateToProps = ({
|
||||
students,
|
||||
auth: {
|
||||
|
||||
+156
-131
@@ -1,63 +1,95 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
// temp disable prop types
|
||||
import React, { useEffect, useState, useRef } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { Button, Loader, Header, Accordion, Divider, Segment } from 'semantic-ui-react'
|
||||
import React, { useCallback, useEffect, useState, useRef } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { Button, Header, Accordion, Divider, Label, Segment } from 'semantic-ui-react'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
import scrollToComponent from 'react-scroll-to-component'
|
||||
import { getCustomPopulation } from '../../redux/populations'
|
||||
import { getCustomPopulationCoursesByStudentnumbers } from '../../redux/populationCourses'
|
||||
import StyledMessage from './StyledMessage'
|
||||
import { getTextIn } from '../../common'
|
||||
import FilterTray from '../FilterTray'
|
||||
import useFilters from '../FilterTray/useFilters'
|
||||
import { CustomPopulationFilters } from '../FilterTray/FilterSets'
|
||||
import useFilterTray from '../FilterTray/useFilterTray'
|
||||
import CreditAccumulationGraphHighCharts from '../CreditAccumulationGraphHighCharts'
|
||||
import PopulationStudents from '../PopulationStudents'
|
||||
import CustomPopulationCourses from '../CustomPopulation/CustomPopulationCourses'
|
||||
import CustomPopulationProgrammeDist from '../CustomPopulation/CustomPopulationProgrammeDist'
|
||||
import InfoBox from '../InfoBox'
|
||||
import infotooltips from '../../common/InfoToolTips'
|
||||
import { getTextIn } from 'common'
|
||||
import { useToggle } from 'common/hooks'
|
||||
import FilterTray from 'components/FilterTray'
|
||||
import useFilters from 'components/FilterTray/useFilters'
|
||||
import PopulationStudents from 'components/PopulationStudents'
|
||||
import CreditAccumulationGraphHighCharts from 'components/CreditAccumulationGraphHighCharts'
|
||||
import { StudyGuidanceGroupFilters } from 'components/FilterTray/FilterSets'
|
||||
import { useGetStudyGuidanceGroupPopulationQuery } from 'redux/studyGuidanceGroups'
|
||||
import { useFilteredAndFormattedElementDetails } from 'redux/elementdetails'
|
||||
import StudyGuidanceGroupPopulationCourses from './StudyGuidanceGroupPopulationCourses'
|
||||
import { startYearToAcademicYear, Wrapper, StyledMessage } from './common'
|
||||
|
||||
const SingleStudyGroupContent = ({ population, language }) => {
|
||||
const custompop = population?.students || []
|
||||
const { setAllStudents, filteredStudents } = useFilters()
|
||||
const [trayOpen] = useFilterTray()
|
||||
const selectedStudents = filteredStudents.map(stu => stu.studentNumber)
|
||||
const creditGainRef = useRef()
|
||||
const programmeRef = useRef()
|
||||
const coursesRef = useRef()
|
||||
const studentRef = useRef()
|
||||
const refs = [creditGainRef, programmeRef, coursesRef, studentRef]
|
||||
const [activeIndex, setIndex] = useState([])
|
||||
const [newestIndex, setNewest] = useState(null)
|
||||
const createAcademicYearStartDate = year => new Date(year, 7, 1)
|
||||
|
||||
const handleClick = index => {
|
||||
const indexes = [...activeIndex].sort()
|
||||
if (indexes.includes(index)) {
|
||||
indexes.splice(
|
||||
indexes.findIndex(ind => ind === index),
|
||||
1
|
||||
)
|
||||
} else {
|
||||
indexes.push(index)
|
||||
const takeOnlyCoursesStartingFromGivenAcademicYear = ({ students, year }) => {
|
||||
if (!year) return students
|
||||
const academicYearStartDate = createAcademicYearStartDate(year)
|
||||
return students.map(student => ({
|
||||
...student,
|
||||
courses: student.courses.filter(course => new Date(course.date) > academicYearStartDate),
|
||||
}))
|
||||
}
|
||||
|
||||
const useIsMounted = () => {
|
||||
const isMounted = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true
|
||||
|
||||
return () => {
|
||||
isMounted.current = false
|
||||
}
|
||||
if (activeIndex.length < indexes.length) setNewest(index)
|
||||
else setNewest(null)
|
||||
setIndex(indexes)
|
||||
}, [])
|
||||
|
||||
return useCallback(() => isMounted.current, [])
|
||||
}
|
||||
|
||||
const useToggleAndSetNewestIndex = ({ defaultValue, indexOfPanelToScroll, setNewestIndex, isMounted }) => {
|
||||
const [state, toggle] = useToggle(defaultValue)
|
||||
useEffect(() => {
|
||||
if (isMounted()) setNewestIndex(indexOfPanelToScroll)
|
||||
}, [state])
|
||||
return [state, toggle]
|
||||
}
|
||||
|
||||
const SingleStudyGroupContent = ({ population, group, language }) => {
|
||||
const isMounted = useIsMounted()
|
||||
const { setAllStudents, filteredStudents } = useFilters()
|
||||
const refs = [useRef(), useRef(), useRef(), useRef()]
|
||||
const [activeIndex, setActiveIndex] = useState([])
|
||||
const [newestIndex, setNewestIndex] = useState(null)
|
||||
const [creditsStartingFromAssociatedYear, toggleCreditsStartingFromAssociatedYear] = useToggleAndSetNewestIndex({
|
||||
defaultValue: !!group.tags?.year,
|
||||
indexOfPanelToScroll: 0,
|
||||
setNewestIndex,
|
||||
isMounted,
|
||||
})
|
||||
const [coursesStructuredByProgramme, toggleCoursesStructuredByProgramme] = useToggleAndSetNewestIndex({
|
||||
defaultValue: !!group.tags?.studyProgramme,
|
||||
indexOfPanelToScroll: 1,
|
||||
setNewestIndex,
|
||||
isMounted,
|
||||
})
|
||||
const filteredStudentsWithFilteredCourses = takeOnlyCoursesStartingFromGivenAcademicYear({
|
||||
students: filteredStudents,
|
||||
year: group.tags?.year,
|
||||
})
|
||||
|
||||
const togglePanel = index => {
|
||||
const currentActiveIndex = new Set(activeIndex)
|
||||
if (currentActiveIndex.has(index)) {
|
||||
currentActiveIndex.delete(index)
|
||||
} else {
|
||||
currentActiveIndex.add(index)
|
||||
setNewestIndex(index)
|
||||
}
|
||||
setActiveIndex([...currentActiveIndex])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setAllStudents(custompop)
|
||||
handleClick(0)
|
||||
}, [custompop])
|
||||
if (newestIndex) scrollToComponent(refs[newestIndex].current, { align: 'bottom' })
|
||||
}, [newestIndex])
|
||||
|
||||
useEffect(() => {
|
||||
if (newestIndex) {
|
||||
scrollToComponent(refs[newestIndex].current, { align: 'bottom' })
|
||||
}
|
||||
}, [activeIndex])
|
||||
setAllStudents(population?.students || [])
|
||||
togglePanel(0)
|
||||
}, [population])
|
||||
|
||||
const panels = [
|
||||
{
|
||||
@@ -65,20 +97,22 @@ const SingleStudyGroupContent = ({ population, language }) => {
|
||||
title: {
|
||||
content: (
|
||||
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
|
||||
Credit accumulation (for {selectedStudents.length} students)
|
||||
Credit accumulation (for {filteredStudents.length} students)
|
||||
</span>
|
||||
),
|
||||
},
|
||||
onTitleClick: () => handleClick(0),
|
||||
onTitleClick: () => togglePanel(0),
|
||||
content: {
|
||||
content: (
|
||||
<div ref={creditGainRef}>
|
||||
<div ref={refs[0]}>
|
||||
{group.tags?.year && (
|
||||
<Button primary onClick={() => toggleCreditsStartingFromAssociatedYear()}>
|
||||
{creditsStartingFromAssociatedYear ? 'Show all credits' : 'Show starting from associated year'}
|
||||
</Button>
|
||||
)}
|
||||
<CreditAccumulationGraphHighCharts
|
||||
students={custompop}
|
||||
selectedStudents={selectedStudents}
|
||||
render={false}
|
||||
trayOpen={trayOpen}
|
||||
language={language}
|
||||
students={creditsStartingFromAssociatedYear ? filteredStudentsWithFilteredCourses : filteredStudents}
|
||||
startDate={creditsStartingFromAssociatedYear && createAcademicYearStartDate(group.tags?.year)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
@@ -89,16 +123,20 @@ const SingleStudyGroupContent = ({ population, language }) => {
|
||||
title: {
|
||||
content: (
|
||||
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
|
||||
Programme distribution
|
||||
Courses of population
|
||||
</span>
|
||||
),
|
||||
},
|
||||
onTitleClick: () => handleClick(1),
|
||||
onTitleClick: () => togglePanel(1),
|
||||
content: {
|
||||
content: (
|
||||
<div ref={programmeRef}>
|
||||
<InfoBox content={infotooltips.PopulationStatistics.ProgrammeDistributionCoursePopulation} />
|
||||
<CustomPopulationProgrammeDist samples={custompop} selectedStudents={selectedStudents} />
|
||||
<div ref={refs[1]}>
|
||||
<StudyGuidanceGroupPopulationCourses
|
||||
selectedStudents={filteredStudents.map(({ studentNumber }) => studentNumber)}
|
||||
showStructured={coursesStructuredByProgramme}
|
||||
toggleShowStructured={toggleCoursesStructuredByProgramme}
|
||||
studyProgramme={group.tags?.studyProgramme}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -108,33 +146,20 @@ const SingleStudyGroupContent = ({ population, language }) => {
|
||||
title: {
|
||||
content: (
|
||||
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
|
||||
Courses of population
|
||||
Students ({filteredStudents.length})
|
||||
</span>
|
||||
),
|
||||
},
|
||||
onTitleClick: () => handleClick(2),
|
||||
onTitleClick: () => togglePanel(2),
|
||||
content: {
|
||||
content: (
|
||||
<div ref={coursesRef}>
|
||||
<CustomPopulationCourses selectedStudents={selectedStudents} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 3,
|
||||
title: {
|
||||
content: (
|
||||
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
|
||||
Students ({selectedStudents.length})
|
||||
</span>
|
||||
),
|
||||
},
|
||||
onTitleClick: () => handleClick(3),
|
||||
content: {
|
||||
content: (
|
||||
<div ref={studentRef}>
|
||||
<PopulationStudents language={language} filteredStudents={filteredStudents} customPopulation />
|
||||
<div ref={refs[2]}>
|
||||
<PopulationStudents
|
||||
variant="studyGuidanceGroupPopulation"
|
||||
language={language}
|
||||
filteredStudents={filteredStudents}
|
||||
studyGuidanceGroup={group}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -142,73 +167,73 @@ const SingleStudyGroupContent = ({ population, language }) => {
|
||||
]
|
||||
|
||||
return (
|
||||
<FilterTray filterSet={<CustomPopulationFilters />}>
|
||||
<Accordion activeIndex={activeIndex} exclusive={false} styled fluid panels={panels} />
|
||||
<FilterTray filterSet={<StudyGuidanceGroupFilters group={group} />}>
|
||||
<Segment className="contentSegment">
|
||||
<Accordion activeIndex={activeIndex} exclusive={false} styled fluid panels={panels} />
|
||||
</Segment>
|
||||
</FilterTray>
|
||||
)
|
||||
}
|
||||
|
||||
const SingleStudyGroupViewWrapper = ({ history, groupName, language, children }) => {
|
||||
const SingleStudyGroupViewWrapper = ({ group, language, isLoading, studyProgrammes, children }) => {
|
||||
const history = useHistory()
|
||||
const handleBack = () => {
|
||||
history.push('/studyguidancegroups')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="segmentContainer">
|
||||
<Segment className="contentSegment">
|
||||
<Button icon="arrow circle left" content="Back" onClick={() => history.push('/studyguidancegroups')} />
|
||||
<Divider />
|
||||
<Header size="medium">{getTextIn(groupName, language)}</Header>
|
||||
</Segment>
|
||||
</div>
|
||||
<Wrapper isLoading={isLoading}>
|
||||
<Button icon="arrow circle left" content="Back" onClick={handleBack} />
|
||||
<Divider />
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'baseline' }}>
|
||||
<Header size="medium" style={{ marginRight: 'auto' }}>
|
||||
{group.name && language && getTextIn(group.name, language)}
|
||||
</Header>
|
||||
{group.tags?.studyProgramme && (
|
||||
<Label tag content={studyProgrammes.find(p => p.value === group.tags.studyProgramme)?.text} color="blue" />
|
||||
)}
|
||||
{group.tags?.year && <Label tag content={startYearToAcademicYear(group.tags.year)} color="blue" />}
|
||||
</div>
|
||||
</Wrapper>
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const SingleStudyGuidanceGroupContainer = ({ groupid }) => {
|
||||
const dispatch = useDispatch()
|
||||
const history = useHistory()
|
||||
const SingleStudyGuidanceGroupContainer = ({ group }) => {
|
||||
const { language } = useSelector(({ settings }) => settings)
|
||||
const { data: groups } = useSelector(({ studyGuidanceGroups }) => studyGuidanceGroups)
|
||||
const group = groups.find(g => g.id === groupid)
|
||||
const groupStudentNumbers = group?.members?.map(({ personStudentNumber }) => personStudentNumber) || []
|
||||
const { data: population, pending } = useSelector(({ populations }) => populations)
|
||||
|
||||
useEffect(() => {
|
||||
if (!group) return
|
||||
const groupStudentNumbers = group?.members?.map(({ personStudentNumber }) => personStudentNumber) || []
|
||||
if (groupStudentNumbers.length === 0) return
|
||||
dispatch(getCustomPopulation({ studentnumberlist: groupStudentNumbers, usingStudyGuidanceGroups: true }))
|
||||
dispatch(
|
||||
getCustomPopulationCoursesByStudentnumbers({
|
||||
studentnumberlist: groupStudentNumbers,
|
||||
usingStudyGuidanceGroups: true,
|
||||
})
|
||||
)
|
||||
}, [])
|
||||
const studyProgrammes = useFilteredAndFormattedElementDetails(language)
|
||||
const { data, isLoading } = useGetStudyGuidanceGroupPopulationQuery(groupStudentNumbers)
|
||||
|
||||
if (!group) {
|
||||
return (
|
||||
<StyledMessage>
|
||||
Couldn't find group with id {groupid}! Please check that id is correct and you have rights to this study
|
||||
guidance group.
|
||||
</StyledMessage>
|
||||
<SingleStudyGroupViewWrapper>
|
||||
<StyledMessage>
|
||||
Couldn't find group with this id! Please check that id is correct and you have rights to this study guidance
|
||||
group.
|
||||
</StyledMessage>
|
||||
</SingleStudyGroupViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const isLoading = pending === undefined || pending === true
|
||||
const isLoaded = pending === false && Object.keys(population).length > 0
|
||||
const renderMessage = groupStudentNumbers.length === 0
|
||||
const renderLoader = groupStudentNumbers.length > 0 && isLoading
|
||||
const renderGroup = groupStudentNumbers.length > 0 && isLoaded
|
||||
if (groupStudentNumbers.length === 0) {
|
||||
return (
|
||||
<SingleStudyGroupViewWrapper>
|
||||
<StyledMessage>This study guidance group doesn't contain any students.</StyledMessage>
|
||||
</SingleStudyGroupViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SingleStudyGroupViewWrapper history={history} groupName={group.name} language={language}>
|
||||
{renderMessage ? <StyledMessage>This study guidance group doesn't contain any students.</StyledMessage> : null}
|
||||
{renderLoader ? (
|
||||
<Loader active inline="centered">
|
||||
Loading
|
||||
</Loader>
|
||||
) : null}
|
||||
{renderGroup ? <SingleStudyGroupContent population={population} language={language} /> : null}
|
||||
<SingleStudyGroupViewWrapper
|
||||
group={group}
|
||||
language={language}
|
||||
isLoading={isLoading}
|
||||
studyProgrammes={studyProgrammes}
|
||||
>
|
||||
{!isLoading && <SingleStudyGroupContent population={data} group={group} language={language} />}
|
||||
</SingleStudyGroupViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/* Hide year header from datetime picker in overview */
|
||||
.studyguidancegroupoverview__yeartagselector .rdtSwitch {
|
||||
display: none;
|
||||
}
|
||||
+178
-21
@@ -1,38 +1,191 @@
|
||||
import React from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { Link } from 'react-router-dom'
|
||||
import SortableTable from '../SortableTable'
|
||||
import { getTextIn } from '../../common'
|
||||
import StyledMessage from './StyledMessage'
|
||||
import { Link, useHistory } from 'react-router-dom'
|
||||
import { Formik } from 'formik'
|
||||
import { Form, Button, Icon } from 'semantic-ui-react'
|
||||
import Datetime from 'react-datetime'
|
||||
import { getTextIn, textAndDescriptionSearch } from 'common'
|
||||
import { useChangeStudyGuidanceGroupTagsMutation } from 'redux/studyGuidanceGroups'
|
||||
import { useFilteredAndFormattedElementDetails } from 'redux/elementdetails'
|
||||
import { useToggle } from 'common/hooks'
|
||||
import SortableTable from 'components/SortableTable'
|
||||
import { startYearToAcademicYear, StyledMessage } from './common'
|
||||
import './StudyGuidanceGroupOverview.css'
|
||||
|
||||
const StudyGuidanceGroupOverview = () => {
|
||||
const LinkToGroup = ({ group, language }) => {
|
||||
const history = useHistory()
|
||||
const dest = `/studyguidancegroups/${group.id}`
|
||||
return (
|
||||
<Link
|
||||
style={{
|
||||
color: 'black',
|
||||
display: 'inline-block',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
padding: '.78571429em .78571429em',
|
||||
}}
|
||||
to={dest}
|
||||
>
|
||||
{getTextIn(group.name, language)}
|
||||
<Icon color="blue" name="level up alternate" onClick={() => history.push(dest)} />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const prettifyCamelCase = str => {
|
||||
const splitted = str.match(/[A-Za-z][a-z]*/g) || []
|
||||
return splitted.map(w => w.charAt(0).toLowerCase() + w.substring(1)).join(' ')
|
||||
}
|
||||
|
||||
const cellWrapper = { display: 'flex', gap: '8px', width: '100%' }
|
||||
const cellContent = { flex: '0 1 50%' }
|
||||
|
||||
const AssociateTagForm = ({ group, tagName, toggleEdit, selectFieldItems }) => {
|
||||
const [changeStudyGuidanceGroupTags, { isLoading }] = useChangeStudyGuidanceGroupTagsMutation()
|
||||
return (
|
||||
<div>
|
||||
<Formik
|
||||
initialValues={{ [tagName]: '' }}
|
||||
onSubmit={values => {
|
||||
changeStudyGuidanceGroupTags({ groupId: group.id, tags: values })
|
||||
if (group.tags?.[tagName]) toggleEdit()
|
||||
}}
|
||||
validate={values => (!values[tagName] ? { [tagName]: `${tagName} is required` } : {})}
|
||||
>
|
||||
{formik => (
|
||||
<Form onSubmit={formik.handleSubmit} style={{ ...cellWrapper, alignItems: 'center' }}>
|
||||
<div style={cellContent}>
|
||||
{tagName === 'studyProgramme' ? (
|
||||
<Form.Select
|
||||
name={tagName}
|
||||
search={textAndDescriptionSearch}
|
||||
fluid
|
||||
placeholder={
|
||||
selectFieldItems.find(p => p.value === group.tags?.[tagName])?.text || 'Select study programme'
|
||||
}
|
||||
options={selectFieldItems}
|
||||
closeOnChange
|
||||
value={formik.values[tagName]}
|
||||
onChange={(_, value) => formik.setFieldValue(tagName, value?.value)}
|
||||
/>
|
||||
) : (
|
||||
<Datetime
|
||||
className="studyguidancegroupoverview__yeartagselector"
|
||||
name={tagName}
|
||||
dateFormat="YYYY"
|
||||
timeFormat={false}
|
||||
initialvalue={group.tags?.[tagName]}
|
||||
inputProps={{ readOnly: true }}
|
||||
renderYear={(props, year) => {
|
||||
const shiftBy = 2 // fix to start from 2017 instead of 2019
|
||||
const shiftedProps = {
|
||||
...props,
|
||||
key: props.key - shiftBy,
|
||||
'data-value': props['data-value'] - shiftBy,
|
||||
}
|
||||
const formattedAndShiftedYear = startYearToAcademicYear(year - shiftBy)
|
||||
return <td {...shiftedProps}> {formattedAndShiftedYear}</td>
|
||||
}}
|
||||
renderInput={({ value, ...rest }) => {
|
||||
return (
|
||||
<div>
|
||||
<input value={startYearToAcademicYear(value)} placeholder="Select year" {...rest} />
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
closeOnSelect
|
||||
value={formik.values[tagName]}
|
||||
onChange={value => formik.setFieldValue(tagName, value?.format('YYYY'))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div style={cellContent}>
|
||||
<Button type="submit" style={{ margin: '0' }} disabled={isLoading}>
|
||||
Add {prettifyCamelCase(tagName)}
|
||||
</Button>
|
||||
</div>
|
||||
{group.tags?.[tagName] && (
|
||||
<div style={cellContent}>
|
||||
<Button icon type="button" style={{ margin: '0' }} onClick={() => toggleEdit()}>
|
||||
Close
|
||||
<Icon name="close" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TagCell = ({ tagName, group, studyProgrammes }) => {
|
||||
const [showEdit, toggleEdit] = useToggle()
|
||||
const getText = () => {
|
||||
switch (tagName) {
|
||||
case 'studyProgramme':
|
||||
return studyProgrammes.find(p => p.value === group.tags?.[tagName])?.text
|
||||
case 'year':
|
||||
return startYearToAcademicYear(group.tags?.[tagName])
|
||||
default:
|
||||
throw Error(`Wrong tagname: ${tagName}`)
|
||||
}
|
||||
}
|
||||
return group.tags?.[tagName] && !showEdit ? (
|
||||
<div style={{ ...cellWrapper, alignItems: 'baseline' }}>
|
||||
<p style={{ ...cellContent, textAlign: 'center' }}>{getText()}</p>
|
||||
<div style={cellContent}>
|
||||
<Button type="button" onClick={() => toggleEdit()}>
|
||||
Edit {prettifyCamelCase(tagName)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<AssociateTagForm group={group} tagName={tagName} toggleEdit={toggleEdit} selectFieldItems={studyProgrammes} />
|
||||
)
|
||||
}
|
||||
|
||||
const StudyGuidanceGroupOverview = ({ groups }) => {
|
||||
const { language } = useSelector(({ settings }) => settings)
|
||||
const { data: groups } = useSelector(({ studyGuidanceGroups }) => studyGuidanceGroups)
|
||||
const studyProgrammes = useFilteredAndFormattedElementDetails(language)
|
||||
|
||||
const headers = [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'name',
|
||||
getRowVal: group => getTextIn(group.name, language),
|
||||
getRowContent: group => (
|
||||
<Link
|
||||
style={{
|
||||
color: 'black',
|
||||
display: 'inline-block',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
padding: '.78571429em .78571429em',
|
||||
}}
|
||||
to={`/studyguidancegroups/${group.id}`}
|
||||
>
|
||||
{getTextIn(group.name, language)}
|
||||
</Link>
|
||||
),
|
||||
getRowContent: group => <LinkToGroup group={group} language={language} />,
|
||||
cellProps: {
|
||||
style: {
|
||||
padding: '0',
|
||||
},
|
||||
className: 'iconCellNoPointer',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'students',
|
||||
title: 'students',
|
||||
getRowVal: group => group.members?.length || 0,
|
||||
getRowContent: group => group.members?.length || 0,
|
||||
cellProps: {
|
||||
style: {
|
||||
padding: '0',
|
||||
textAlign: 'center',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'studyProgramme',
|
||||
title: 'Study programme',
|
||||
getRowVal: group => group.tags?.studyProgramme,
|
||||
getRowContent: group => <TagCell tagName="studyProgramme" studyProgrammes={studyProgrammes} group={group} />,
|
||||
},
|
||||
{
|
||||
key: 'associatedyear',
|
||||
title: 'Associated starting academic year',
|
||||
getRowVal: group => group.tags?.year,
|
||||
getRowContent: group => <TagCell tagName="year" group={group} />,
|
||||
},
|
||||
]
|
||||
|
||||
if (groups.length === 0) {
|
||||
@@ -41,7 +194,11 @@ const StudyGuidanceGroupOverview = () => {
|
||||
return (
|
||||
<>
|
||||
<StyledMessage>
|
||||
<p>Tällä sivulla pääset tarkastemaan ohjattavien opiskelijoidesi etenemistä ohjausryhmittäin.</p>
|
||||
<p>
|
||||
Tällä sivulla pääset tarkastemaan ohjattavien opiskelijoidesi etenemistä ohjausryhmittäin. Voit halutessasi
|
||||
lisätä ohjausryhmään aloitusvuoden ja koulutusohjelman, jolloin yksittäisen ohjausryhmän näkymään avautuu
|
||||
lisäominaisuuksia.{' '}
|
||||
</p>
|
||||
</StyledMessage>
|
||||
<SortableTable columns={headers} getRowKey={group => group.id} data={groups} />
|
||||
</>
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { Segment, Button } from 'semantic-ui-react'
|
||||
import InfoBox from 'components/Info/InfoBox'
|
||||
import PopulationCourseStatsFlat from 'components/PopulationCourseStats/PopulationCourseStatsFlat'
|
||||
import PopulationCourseStats from 'components/PopulationCourseStats'
|
||||
import useCourseFilter from 'components/FilterTray/filters/Courses/useCourseFilter'
|
||||
import SegmentDimmer from 'components/SegmentDimmer'
|
||||
import { useGetStudyGuidanceGroupPopulationCoursesQuery } from 'redux/studyGuidanceGroups'
|
||||
import { getMandatoryCourses, getMandatoryCourseModules } from 'redux/populationMandatoryCourses'
|
||||
import infotooltips from 'common/InfoToolTips'
|
||||
|
||||
const StudyGuidanceGroupPopulationCourses = ({
|
||||
selectedStudents,
|
||||
showStructured,
|
||||
toggleShowStructured,
|
||||
studyProgramme,
|
||||
}) => {
|
||||
const { setCourses, resetCourses } = useCourseFilter()
|
||||
const dispatch = useDispatch()
|
||||
const mandatoryCourses = useSelector(({ populationMandatoryCourses }) => populationMandatoryCourses.data)
|
||||
const { data: courses, isLoading } = useGetStudyGuidanceGroupPopulationCoursesQuery(selectedStudents)
|
||||
|
||||
useEffect(() => {
|
||||
// ensure mandatory courses are available for course stats structured
|
||||
if (showStructured && studyProgramme && mandatoryCourses.length === 0) {
|
||||
dispatch(getMandatoryCourses(studyProgramme))
|
||||
dispatch(getMandatoryCourseModules(studyProgramme))
|
||||
}
|
||||
}, [showStructured])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading) setCourses(courses.coursestatistics)
|
||||
}, [courses])
|
||||
|
||||
useEffect(() => {
|
||||
return resetCourses
|
||||
}, [])
|
||||
|
||||
if (isLoading) return <SegmentDimmer isLoading={isLoading} />
|
||||
return (
|
||||
<Segment basic>
|
||||
<InfoBox content={infotooltips.PopulationStatistics.CoursesOfPopulation} />
|
||||
{studyProgramme && (
|
||||
<Button primary onClick={() => toggleShowStructured()} style={{ marginLeft: '1em' }}>
|
||||
{showStructured ? 'Show courses as flat list' : 'Show by programme structure'}
|
||||
</Button>
|
||||
)}
|
||||
{showStructured ? (
|
||||
<PopulationCourseStats courses={courses} pending={isLoading} selectedStudents={selectedStudents} />
|
||||
) : (
|
||||
<PopulationCourseStatsFlat
|
||||
courses={courses}
|
||||
pending={isLoading}
|
||||
selectedStudents={selectedStudents}
|
||||
showFilter={false}
|
||||
/>
|
||||
)}
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
export default StudyGuidanceGroupPopulationCourses
|
||||
@@ -1,4 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Message } from 'semantic-ui-react'
|
||||
|
||||
export default ({ children }) => <Message style={{ maxWidth: '800px', margin: 'auto' }}>{children}</Message>
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react'
|
||||
import { Message, Segment } from 'semantic-ui-react'
|
||||
|
||||
export const startYearToAcademicYear = year => {
|
||||
return year === '' || Number.isNaN(year) ? '' : `${year} - ${parseInt(year, 10) + 1}`
|
||||
}
|
||||
|
||||
export const StyledMessage = ({ children }) => (
|
||||
<Message style={{ maxWidth: '800px', margin: 'auto' }}>{children}</Message>
|
||||
)
|
||||
|
||||
export const Wrapper = ({ isLoading, children }) => (
|
||||
<div className="segmentContainer">
|
||||
<Segment loading={isLoading} className="contentSegment">
|
||||
{children}
|
||||
</Segment>
|
||||
</div>
|
||||
)
|
||||
@@ -1,26 +1,16 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { useRouteMatch } from 'react-router-dom'
|
||||
import { Header, Loader, Segment } from 'semantic-ui-react'
|
||||
import { useTitle } from '../../common/hooks'
|
||||
import { getStudyGuidanceGroups } from '../../redux/studyGuidanceGroups'
|
||||
import React from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Header } from 'semantic-ui-react'
|
||||
import { useGetAllStudyGuidanceGroupsQuery } from 'redux/studyGuidanceGroups'
|
||||
import { useTitle } from 'common/hooks'
|
||||
import StudyGuidanceGroupOverview from './StudyGuidanceGroupOverview'
|
||||
import SingleStudyGuidanceGroup from './SingleStudyGuidanceGroup'
|
||||
import { Wrapper } from './common'
|
||||
|
||||
const StudyGuidanceGroups = () => {
|
||||
useTitle('Study guidance groups')
|
||||
const dispatch = useDispatch()
|
||||
const { pending, data: groups } = useSelector(({ studyGuidanceGroups }) => studyGuidanceGroups)
|
||||
const match = useRouteMatch('/studyguidancegroups/:groupid')
|
||||
const groupid = match?.params?.groupid
|
||||
|
||||
useEffect(() => {
|
||||
if (groups && groups.length > 0) return
|
||||
dispatch(getStudyGuidanceGroups())
|
||||
}, [dispatch])
|
||||
|
||||
const isLoading = pending === undefined || pending === true
|
||||
const isLoaded = pending === false
|
||||
const { groupid } = useParams()
|
||||
const { data, isLoading } = useGetAllStudyGuidanceGroupsQuery()
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -28,16 +18,9 @@ const StudyGuidanceGroups = () => {
|
||||
<Header className="segmentTitle" size="large">
|
||||
Study guidance groups
|
||||
</Header>
|
||||
<Segment className="contentSegment">
|
||||
{isLoading ? (
|
||||
<Loader active inline="centered">
|
||||
Loading
|
||||
</Loader>
|
||||
) : null}
|
||||
{isLoaded && !groupid ? <StudyGuidanceGroupOverview /> : null}
|
||||
</Segment>
|
||||
</div>
|
||||
<div>{isLoaded && groupid ? <SingleStudyGuidanceGroup groupid={groupid} /> : null}</div>
|
||||
<Wrapper isLoading={isLoading}>{!isLoading && !groupid && <StudyGuidanceGroupOverview groups={data} />}</Wrapper>
|
||||
{!isLoading && groupid && <SingleStudyGuidanceGroup group={data.find(group => group.id === groupid)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from 'react'
|
||||
|
||||
const ReactHighcharts = require('react-highcharts')
|
||||
|
||||
const BarChart = ({ data, categories }) => {
|
||||
const defaultConfig = {
|
||||
title: {
|
||||
text: '',
|
||||
},
|
||||
series: data,
|
||||
credits: {
|
||||
text: 'oodikone | TOSKA',
|
||||
},
|
||||
xAxis: {
|
||||
categories,
|
||||
},
|
||||
chart: {
|
||||
type: 'column',
|
||||
width: 950,
|
||||
height: 400,
|
||||
},
|
||||
plotOptions: {
|
||||
column: {
|
||||
stacking: 'normal',
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
allowDecimals: false,
|
||||
min: 0,
|
||||
reversed: false,
|
||||
title: '',
|
||||
},
|
||||
}
|
||||
|
||||
return <ReactHighcharts config={defaultConfig} />
|
||||
}
|
||||
|
||||
export default BarChart
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from 'react'
|
||||
import { Table } from 'semantic-ui-react'
|
||||
|
||||
const DataTable = ({ data, titles }) => {
|
||||
if (!data || !titles) return null
|
||||
|
||||
return (
|
||||
<div className="table-container">
|
||||
<Table celled>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
{titles?.map(title => (
|
||||
<Table.HeaderCell>{title}</Table.HeaderCell>
|
||||
))}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
{data?.map(yearArray => (
|
||||
<Table.Row>
|
||||
{yearArray?.map(value => (
|
||||
<Table.Cell>{value}</Table.Cell>
|
||||
))}
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DataTable
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react'
|
||||
|
||||
import '../studyprogramme.css'
|
||||
|
||||
const ReactHighcharts = require('react-highcharts')
|
||||
|
||||
const LineGraph = ({ categories, data }) => {
|
||||
const defaultConfig = {
|
||||
title: {
|
||||
text: '',
|
||||
},
|
||||
series: data,
|
||||
credits: {
|
||||
text: 'oodikone | TOSKA',
|
||||
},
|
||||
chart: {
|
||||
height: '400px',
|
||||
},
|
||||
xAxis: {
|
||||
categories,
|
||||
},
|
||||
yAxis: {
|
||||
allowDecimals: false,
|
||||
min: 0,
|
||||
reversed: false,
|
||||
title: '',
|
||||
},
|
||||
}
|
||||
|
||||
if (!data || !categories) return <div>No data available for the selected studyprogramme</div>
|
||||
return (
|
||||
<div className="graph-container">
|
||||
<ReactHighcharts config={defaultConfig} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LineGraph
|
||||
@@ -0,0 +1,58 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Divider } from 'semantic-ui-react'
|
||||
|
||||
import LineGraph from './LineGraph'
|
||||
import BarChart from './BarChart'
|
||||
import DataTable from './DataTable'
|
||||
import InfoBox from '../../Info/InfoBox'
|
||||
import { getBasicStats, getCreditStats } from '../../../redux/studyProgramme'
|
||||
import InfotoolTips from '../../../common/InfoToolTips'
|
||||
import '../studyprogramme.css'
|
||||
|
||||
const basicStatsTitles = ['', 'Started', 'Graduated', 'Cancelled', 'Transferred away', 'Transferred to']
|
||||
const creditStatsTitles = ['', 'Major students credits', 'Non major students credits', 'Transferred credits']
|
||||
|
||||
const Overview = props => {
|
||||
const toolTips = InfotoolTips.Studyprogramme
|
||||
const { studyprogramme, basicStats, creditStats, dispatchGetBasicStats, dispatchGetCreditStats } = props
|
||||
|
||||
useEffect(() => {
|
||||
dispatchGetBasicStats(studyprogramme)
|
||||
dispatchGetCreditStats(studyprogramme)
|
||||
}, [])
|
||||
|
||||
const getDivider = (title, toolTipText) => (
|
||||
<>
|
||||
<div className="divider">
|
||||
<Divider horizontal>{title}</Divider>
|
||||
</div>
|
||||
<InfoBox content={toolTips[toolTipText]} />
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="studyprogramme-overview">
|
||||
{getDivider('Students of the studyprogramme', 'StudentsOfTheStudyprogramme')}
|
||||
<div className="section-container">
|
||||
<LineGraph categories={basicStats?.data?.years} data={basicStats?.data?.graphStats} />
|
||||
<DataTable titles={basicStatsTitles} data={basicStats?.data?.tableStats} />
|
||||
</div>
|
||||
{getDivider('Credits produced by the studyprogramme')}
|
||||
<div className="section-container">
|
||||
<BarChart categories={creditStats?.data?.years} data={creditStats?.data?.graphStats} />
|
||||
<DataTable titles={creditStatsTitles} data={creditStats?.data?.tableStats} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ studyProgramme }) => ({
|
||||
basicStats: studyProgramme?.basicStats,
|
||||
creditStats: studyProgramme?.creditStats,
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps, {
|
||||
dispatchGetBasicStats: getBasicStats,
|
||||
dispatchGetCreditStats: getCreditStats,
|
||||
})(Overview)
|
||||
@@ -4,7 +4,7 @@ import { Table, Header, Grid, Label, Segment, Dropdown } from 'semantic-ui-react
|
||||
import { connect } from 'react-redux'
|
||||
import { shape, number, arrayOf, bool, string, oneOfType } from 'prop-types'
|
||||
import { getProductivity } from '../../../redux/productivity'
|
||||
import InfoBox from '../../InfoBox'
|
||||
import InfoBox from '../../Info/InfoBox'
|
||||
import infotooltips from '../../../common/InfoToolTips'
|
||||
import './productivityTable.css'
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Link } from 'react-router-dom'
|
||||
import { flatten, uniq, range } from 'lodash'
|
||||
import { getUserIsAdmin } from '../../../common'
|
||||
import { isDev } from '../../../conf'
|
||||
import InfoBox from '../../InfoBox'
|
||||
import InfoBox from '../../Info/InfoBox'
|
||||
import infotooltips from '../../../common/InfoToolTips'
|
||||
|
||||
const getMonths = year => {
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import React, { useCallback, useEffect } from 'react'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import { connect } from 'react-redux'
|
||||
import { connect, useSelector } from 'react-redux'
|
||||
import { Header, Segment, Tab, Button } from 'semantic-ui-react'
|
||||
import { isEqual, uniqBy } from 'lodash'
|
||||
import DegreeCoursesTable from './DegreeCourses'
|
||||
import StudyProgrammeSelector from './StudyProgrammeSelector'
|
||||
import Overview from './Overview'
|
||||
import NewOverview from './NewOverview'
|
||||
import StudyTrackOverview from './StudyTrackOverview'
|
||||
import ThesisCourses from './ThesisCourses'
|
||||
import '../PopulationQueryCard/populationQueryCard.css'
|
||||
import { getTextIn, getUserRoles, getUserIsAdmin } from '../../common'
|
||||
import { useTabs, useTitle } from '../../common/hooks'
|
||||
import { isDev } from '../../conf'
|
||||
import TSA from '../../common/tsa'
|
||||
import Tags from './Tags'
|
||||
|
||||
@@ -23,7 +25,9 @@ import useLanguage from '../LanguagePicker/useLanguage'
|
||||
|
||||
const StudyProgramme = props => {
|
||||
const { language } = useLanguage()
|
||||
const { roles } = useSelector(state => state.auth.token)
|
||||
const [tab, setTab] = useTabs('p_tab', 0, props.history)
|
||||
|
||||
useTitle('Study programmes')
|
||||
|
||||
useEffect(() => {
|
||||
@@ -57,6 +61,8 @@ const StudyProgramme = props => {
|
||||
})
|
||||
}
|
||||
|
||||
const SHOW_NEW_OVERVIEW = getUserIsAdmin(roles) && isDev
|
||||
|
||||
const getPanes = () => {
|
||||
const { match, programmes } = props
|
||||
const { studyProgrammeId } = match.params
|
||||
@@ -71,6 +77,12 @@ const StudyProgramme = props => {
|
||||
menuItem: 'Overview',
|
||||
render: () => <Overview studyprogramme={studyProgrammeId} history={props.history} />,
|
||||
})
|
||||
if (SHOW_NEW_OVERVIEW) {
|
||||
panes.push({
|
||||
menuItem: 'New Overview',
|
||||
render: () => <NewOverview studyprogramme={studyProgrammeId} history={props.history} />,
|
||||
})
|
||||
}
|
||||
if (filteredStudytracks.length > 0) {
|
||||
panes.push({
|
||||
menuItem: 'Studytrack overview',
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
.studyprogramme-overview .divider {
|
||||
margin-top: 4em;
|
||||
}
|
||||
|
||||
.studyprogramme-overview .section-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.studyprogramme-overview .graph-container {
|
||||
width: 60%;
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.studyprogramme-overview .table-container {
|
||||
width: 35%;
|
||||
height: 400px;
|
||||
margin-top: 2em;
|
||||
margin-left: 2em;
|
||||
}
|
||||
@@ -4,18 +4,14 @@ import { Provider } from 'react-redux'
|
||||
import 'semantic-ui-css/semantic.min.css'
|
||||
import 'react-datetime/css/react-datetime.css'
|
||||
import './styles/custom.css'
|
||||
import { debugContextDevtool } from 'react-context-devtool'
|
||||
import store from './store'
|
||||
import store from './redux'
|
||||
import ErrorBoundary from './components/ErrorBoundary'
|
||||
import Main from './components/Main'
|
||||
import CommonContext from './CommonContext'
|
||||
import initializeSentry from './util/sentry'
|
||||
import { isProduction } from './conf'
|
||||
|
||||
initializeSentry()
|
||||
|
||||
const container = document.getElementById('root')
|
||||
|
||||
ReactDOM.render(
|
||||
<Provider store={store}>
|
||||
<CommonContext>
|
||||
@@ -24,8 +20,5 @@ ReactDOM.render(
|
||||
</ErrorBoundary>
|
||||
</CommonContext>
|
||||
</Provider>,
|
||||
container
|
||||
document.getElementById('root')
|
||||
)
|
||||
|
||||
// Best not to use this in production as the performance impact appears to be quite high.
|
||||
debugContextDevtool(container, { disable: isProduction })
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user