mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-13 03:13:40 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2713545eda | ||
|
|
fa619fed38 | ||
|
|
6d4f49879b | ||
|
|
37aeb9ebe4 | ||
|
|
5f5423971c | ||
|
|
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 | ||
|
|
2a6653c700 |
@@ -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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
const router = require('express').Router()
|
||||
const {
|
||||
getBasicStatsForStudytrack,
|
||||
getCreditStatsForStudytrack,
|
||||
getGraduationStatsForStudytrack,
|
||||
} = require('../services/newStudyprogramme')
|
||||
const {
|
||||
getBasicStats,
|
||||
setBasicStats,
|
||||
getCreditStats,
|
||||
setCreditStats,
|
||||
getGraduationStats,
|
||||
setGraduationStats,
|
||||
} = 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.get('/v2/studyprogrammes/:id/graduationstats', async (req, res) => {
|
||||
const code = req.params.id
|
||||
if (code) {
|
||||
let data = null
|
||||
try {
|
||||
data = await getGraduationStats(code)
|
||||
} catch (e) {
|
||||
logger.error(`Failed to get code ${code} graduation stats`)
|
||||
}
|
||||
if (!data) {
|
||||
try {
|
||||
let result
|
||||
if (code.includes('MH') || code.includes('KH')) {
|
||||
result = await getGraduationStatsForStudytrack({
|
||||
studyprogramme: req.params.id,
|
||||
startDate: new Date('2017-01-01'),
|
||||
})
|
||||
} else {
|
||||
result = await getGraduationStatsForStudytrack({
|
||||
studyprogramme: req.params.id,
|
||||
startDate: new Date('2000-01-01'),
|
||||
})
|
||||
}
|
||||
data = await setGraduationStats(result)
|
||||
} catch (e) {
|
||||
logger.error(`Failed to update code ${code} graduation stats`)
|
||||
}
|
||||
}
|
||||
return res.json({ data })
|
||||
} else {
|
||||
res.status(422).end()
|
||||
}
|
||||
})
|
||||
|
||||
router.use('*', (req, res, next) => next())
|
||||
|
||||
module.exports = router
|
||||
@@ -3,6 +3,9 @@ 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 createRedisKeyForGraduationStats = id => `GRADUATION_STATS_${id}`
|
||||
|
||||
const getProductivity = async id => {
|
||||
const redisKey = createRedisKeyForProductivity(id)
|
||||
@@ -87,6 +90,66 @@ 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
|
||||
}
|
||||
|
||||
const getGraduationStats = async id => {
|
||||
const redisKey = createRedisKeyForGraduationStats(id)
|
||||
const dataFromRedis = await redisClient.getAsync(redisKey)
|
||||
if (!dataFromRedis) return null
|
||||
return JSON.parse(dataFromRedis)
|
||||
}
|
||||
|
||||
const setGraduationStats = async data => {
|
||||
const { id } = data
|
||||
const redisKey = createRedisKeyForGraduationStats(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 +157,10 @@ module.exports = {
|
||||
getThroughput,
|
||||
setThroughput,
|
||||
patchThroughput,
|
||||
getBasicStats,
|
||||
setBasicStats,
|
||||
getCreditStats,
|
||||
setCreditStats,
|
||||
getGraduationStats,
|
||||
setGraduationStats,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
const sequelize = require('sequelize')
|
||||
const { Op } = sequelize
|
||||
const { indexOf } = require('lodash')
|
||||
const {
|
||||
Credit,
|
||||
Course,
|
||||
Organization,
|
||||
Studyright,
|
||||
StudyrightElement,
|
||||
ElementDetail,
|
||||
Transfer,
|
||||
Student,
|
||||
} = require('../models')
|
||||
const { ThesisCourse } = require('../models/models_kone')
|
||||
|
||||
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 getThesisCredits = async (studyprogramme, startDate) => {
|
||||
const thesiscourses = await ThesisCourse.findAll({
|
||||
where: {
|
||||
programmeCode: studyprogramme,
|
||||
},
|
||||
})
|
||||
return await Credit.findAll({
|
||||
include: {
|
||||
model: Course,
|
||||
required: true,
|
||||
distinct: true,
|
||||
col: 'student_studentnumber',
|
||||
where: {
|
||||
code: {
|
||||
[Op.in]: thesiscourses.map(tc => tc.courseCode),
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
credittypecode: {
|
||||
[Op.ne]: 10,
|
||||
},
|
||||
attainment_date: {
|
||||
[Op.gte]: startDate,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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 getThesisStats = async (studytrack, startDate, years) => {
|
||||
const credits = await getThesisCredits(studytrack, startDate)
|
||||
const { graphStats, tableStats } = getStatsBasis(years)
|
||||
|
||||
credits.forEach(({ attainment_date }) => {
|
||||
const attainmentYear = attainment_date.getFullYear()
|
||||
graphStats[indexOf(years, attainmentYear)] += 1
|
||||
tableStats[attainmentYear] += 1
|
||||
})
|
||||
|
||||
return { graphStats, tableStats }
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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 getGraduationStatsForStudytrack = async ({ studyprogramme, startDate }) => {
|
||||
const years = getYearsArray(startDate.getFullYear())
|
||||
const thesis = await getThesisStats(studyprogramme, startDate, years)
|
||||
const graduated = await getGraduatedStats(studyprogramme, startDate, years)
|
||||
|
||||
const reversedYears = getYearsArray(startDate.getFullYear()).reverse()
|
||||
const tableStats = reversedYears.map(year => [year, graduated.tableStats[year], thesis.tableStats[year]])
|
||||
|
||||
return {
|
||||
id: studyprogramme,
|
||||
years,
|
||||
tableStats,
|
||||
graphStats: [
|
||||
{
|
||||
name: 'Graduated students',
|
||||
data: graduated.graphStats,
|
||||
},
|
||||
{
|
||||
name: 'Wrote thesis',
|
||||
data: thesis.graphStats,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getBasicStatsForStudytrack,
|
||||
getCreditStatsForStudytrack,
|
||||
getGraduationStatsForStudytrack,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Generated
+3
-3
@@ -15611,9 +15611,9 @@
|
||||
}
|
||||
},
|
||||
"url-parse": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.1.tgz",
|
||||
"integrity": "sha512-HOfCOUJt7iSYzEx/UqgtwKRMC6EU91NFhsCHMv9oM03VJcVo2Qrp8T8kI9D7amFf1cu+/3CEhgb3rF9zL7k85Q==",
|
||||
"version": "1.5.3",
|
||||
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.3.tgz",
|
||||
"integrity": "sha512-IIORyIQD9rvj0A4CLWsHkBBJuNqWpFQe224b6j9t/ABmquIS0qDU2pY6kl6AuOrL5OkCXHMCFNe1jBcuAggjvQ==",
|
||||
"requires": {
|
||||
"querystringify": "^2.1.1",
|
||||
"requires-port": "^1.0.0"
|
||||
|
||||
@@ -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
-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'
|
||||
|
||||
@@ -12,7 +12,7 @@ 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'
|
||||
@@ -152,6 +152,7 @@ const CustomPopulation = ({
|
||||
content: (
|
||||
<div ref={studentRef}>
|
||||
<PopulationStudents
|
||||
variant="customPopulation"
|
||||
language={language}
|
||||
filteredStudents={filteredStudents}
|
||||
dataExport={<UnihowDataExport />}
|
||||
|
||||
@@ -60,15 +60,18 @@ export const CustomPopulationFilters = () => (
|
||||
</Filters>
|
||||
)
|
||||
|
||||
export const StudyGuidanceGroupFilters = () => (
|
||||
export const StudyGuidanceGroupFilters = ({ group }) => (
|
||||
<Filters name="Study Guidance Group">
|
||||
<EnrollmentStatus />
|
||||
<Age />
|
||||
<Gender />
|
||||
<StartYearAtUni />
|
||||
<AdmissionType />
|
||||
<GraduatedFromProgramme />
|
||||
{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 => (
|
||||
|
||||
@@ -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,10 +1,32 @@
|
||||
import React from 'react'
|
||||
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
|
||||
@@ -12,10 +34,7 @@ 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>
|
||||
|
||||
@@ -15,7 +15,7 @@ const FilterTray = ({ children, filterSet, visible = true }) => {
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'flex-start', width: '100%' }}>
|
||||
<Segment style={{ marginLeft: '0.5rem', width: '12rem', position: 'sticky', top: '10px' }}>
|
||||
<Segment style={{ marginLeft: '0.5rem', width: '16rem', position: 'sticky', top: '10px' }}>
|
||||
<Header size="small" style={{ textAlign: 'center' }} data-cy="filtered-students">
|
||||
Filter students
|
||||
</Header>
|
||||
|
||||
@@ -53,11 +53,11 @@ const FrontPage = () => {
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -5,7 +5,7 @@ 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'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback } from 'react'
|
||||
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'
|
||||
|
||||
@@ -7,7 +7,7 @@ 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'
|
||||
@@ -169,7 +169,12 @@ const PopulationDetails = ({ samples, queryIsSet, isLoading, query, dataExport }
|
||||
content: {
|
||||
content: (
|
||||
<div ref={studentTableRef}>
|
||||
<PopulationStudents language={language} filteredStudents={filteredStudents} dataExport={dataExport} />
|
||||
<PopulationStudents
|
||||
variant="population"
|
||||
language={language}
|
||||
filteredStudents={filteredStudents}
|
||||
dataExport={dataExport}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -5,7 +5,7 @@ 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'
|
||||
|
||||
|
||||
@@ -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,54 +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
|
||||
|
||||
if (!populationStatistics) return null
|
||||
const { data: populationStatistics, query } = populations
|
||||
|
||||
if (!populationStatistics || !populationStatistics.elementdetails) return null
|
||||
|
||||
// TODO: Refactor this away:
|
||||
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
|
||||
@@ -137,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]
|
||||
@@ -190,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'),
|
||||
@@ -224,7 +197,7 @@ const GeneralTab = ({
|
||||
),
|
||||
headerProps: { colSpan: 2 },
|
||||
},
|
||||
{
|
||||
icon: {
|
||||
key: 'icon',
|
||||
getRowVal: s =>
|
||||
!s.obfuscated && (
|
||||
@@ -239,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))
|
||||
@@ -264,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' }}>
|
||||
@@ -448,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
|
||||
|
||||
@@ -13,7 +13,7 @@ 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'
|
||||
@@ -29,9 +29,10 @@ const PopulationStudents = ({
|
||||
filteredStudents,
|
||||
studentToTargetCourseDateMap,
|
||||
dataExport,
|
||||
customPopulation = false,
|
||||
coursePopulation = false,
|
||||
contentToInclude,
|
||||
coursecode = [],
|
||||
variant,
|
||||
studyGuidanceGroup,
|
||||
}) => {
|
||||
const [state, setState] = useState({})
|
||||
const studentRef = useRef()
|
||||
@@ -287,19 +288,18 @@ const PopulationStudents = ({
|
||||
},
|
||||
mandatoryCourses.reduce((acc, e) => ({ ...acc, [e.code]: 0 }), { total: true })
|
||||
)
|
||||
const mandatoryCourseData = [totals, filteredStudents]
|
||||
const mandatoryCourseData = [totals, ...filteredStudents]
|
||||
|
||||
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>
|
||||
),
|
||||
@@ -380,12 +380,7 @@ const PopulationStudents = ({
|
||||
},
|
||||
]
|
||||
|
||||
const filteredPanes = panesToFilter => {
|
||||
if (coursePopulation || customPopulation) {
|
||||
return panesToFilter.slice(0, 1)
|
||||
}
|
||||
return panesToFilter
|
||||
}
|
||||
const panes = panesAvailable.filter(pane => contentToInclude.panesToInclude.includes(pane.menuItem))
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -401,7 +396,7 @@ const PopulationStudents = ({
|
||||
</Grid.Column>
|
||||
)}
|
||||
</Grid>
|
||||
<Tab onTabChange={handleTabChange} panes={filteredPanes(panes)} data-cy="student-table-tabs" />
|
||||
<Tab onTabChange={handleTabChange} panes={panes} data-cy="student-table-tabs" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -411,11 +406,7 @@ const PopulationStudents = ({
|
||||
return (
|
||||
<>
|
||||
<span style={{ marginRight: '0.5rem' }} ref={studentRef}>
|
||||
<InfoBox
|
||||
content={
|
||||
coursePopulation ? infotoolTips.CoursePopulation.Students : infotoolTips.PopulationStatistics.Students
|
||||
}
|
||||
/>
|
||||
<InfoBox content={contentToInclude.infotoolTipContent} />
|
||||
</span>
|
||||
{admin ? <CheckStudentList students={filteredStudents.map(stu => stu.studentNumber)} /> : null}
|
||||
{renderStudentTable()}
|
||||
@@ -423,4 +414,33 @@ const PopulationStudents = ({
|
||||
)
|
||||
}
|
||||
|
||||
export default PopulationStudents
|
||||
const PopulationStudentsContainer = ({ ...props }) => {
|
||||
const { variant } = props
|
||||
|
||||
if (!['population', 'customPopulation', 'coursePopulation', 'studyGuidanceGroupPopulation'].includes(variant)) {
|
||||
throw new Error(`${variant} is not a proper variant!`)
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
return <PopulationStudents contentToInclude={contentByVariant[variant]} {...props} />
|
||||
}
|
||||
|
||||
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,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: {
|
||||
|
||||
@@ -154,7 +154,12 @@ const SingleStudyGroupContent = ({ population, group, language }) => {
|
||||
content: {
|
||||
content: (
|
||||
<div ref={refs[2]}>
|
||||
<PopulationStudents customPopulation language={language} filteredStudents={filteredStudents} />
|
||||
<PopulationStudents
|
||||
variant="studyGuidanceGroupPopulation"
|
||||
language={language}
|
||||
filteredStudents={filteredStudents}
|
||||
studyGuidanceGroup={group}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -162,7 +167,7 @@ const SingleStudyGroupContent = ({ population, group, language }) => {
|
||||
]
|
||||
|
||||
return (
|
||||
<FilterTray filterSet={<StudyGuidanceGroupFilters />}>
|
||||
<FilterTray filterSet={<StudyGuidanceGroupFilters group={group} />}>
|
||||
<Segment className="contentSegment">
|
||||
<Accordion activeIndex={activeIndex} exclusive={false} styled fluid panels={panels} />
|
||||
</Segment>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/* Hide year header from datetime picker in overview */
|
||||
.studyguidancegroupoverview__yeartagselector .rdtSwitch {
|
||||
display: none;
|
||||
}
|
||||
+12
-1
@@ -10,6 +10,7 @@ import { useFilteredAndFormattedElementDetails } from 'redux/elementdetails'
|
||||
import { useToggle } from 'common/hooks'
|
||||
import SortableTable from 'components/SortableTable'
|
||||
import { startYearToAcademicYear, StyledMessage } from './common'
|
||||
import './StudyGuidanceGroupOverview.css'
|
||||
|
||||
const LinkToGroup = ({ group, language }) => {
|
||||
const history = useHistory()
|
||||
@@ -69,12 +70,22 @@ const AssociateTagForm = ({ group, tagName, toggleEdit, selectFieldItems }) => {
|
||||
/>
|
||||
) : (
|
||||
<Datetime
|
||||
className="studyguidancegroupoverview__yeartagselector"
|
||||
name={tagName}
|
||||
dateFormat="YYYY"
|
||||
timeFormat={false}
|
||||
initialvalue={group.tags?.[tagName]}
|
||||
inputProps={{ readOnly: true }}
|
||||
renderYear={(props, selectableYear) => <td {...props}>{startYearToAcademicYear(selectableYear)}</td>}
|
||||
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>
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { Segment, Button } from 'semantic-ui-react'
|
||||
import InfoBox from 'components/InfoBox'
|
||||
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'
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react'
|
||||
|
||||
const ReactHighcharts = require('react-highcharts')
|
||||
|
||||
const colors = ['#003E65', '#1392c2', '#036415']
|
||||
|
||||
const StackedBarChart = ({ data, categories }) => {
|
||||
const dataWithColors = data?.map((series, index) => ({ ...series, color: colors[index] }))
|
||||
|
||||
const defaultConfig = {
|
||||
title: {
|
||||
text: '',
|
||||
},
|
||||
series: dataWithColors,
|
||||
credits: {
|
||||
text: 'oodikone | TOSKA',
|
||||
},
|
||||
xAxis: {
|
||||
categories,
|
||||
},
|
||||
chart: {
|
||||
type: 'column',
|
||||
width: 950,
|
||||
height: 400,
|
||||
},
|
||||
plotOptions: {
|
||||
column: {
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
allowDecimals: false,
|
||||
min: 0,
|
||||
reversed: false,
|
||||
title: '',
|
||||
},
|
||||
}
|
||||
|
||||
return <ReactHighcharts config={defaultConfig} />
|
||||
}
|
||||
|
||||
export default StackedBarChart
|
||||
@@ -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,42 @@
|
||||
import React from 'react'
|
||||
|
||||
import '../studyprogramme.css'
|
||||
|
||||
const ReactHighcharts = require('react-highcharts')
|
||||
|
||||
const colors = ['#003E65', '#1392c2', '#E68825', '#333737', '#036415']
|
||||
|
||||
const LineGraph = ({ categories, data }) => {
|
||||
const dataWithColors = data?.map((series, index) => ({ ...series, color: colors[index] }))
|
||||
|
||||
const defaultConfig = {
|
||||
title: {
|
||||
text: '',
|
||||
},
|
||||
series: dataWithColors,
|
||||
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,48 @@
|
||||
import React from 'react'
|
||||
|
||||
const ReactHighcharts = require('react-highcharts')
|
||||
|
||||
const colors = ['#003E65', '#1392c2', '#036415']
|
||||
|
||||
const StackedBarChart = ({ data, categories }) => {
|
||||
const dataWithColors = data?.map((series, index) => ({ ...series, color: colors[index] }))
|
||||
|
||||
const defaultConfig = {
|
||||
title: {
|
||||
text: '',
|
||||
},
|
||||
series: dataWithColors,
|
||||
credits: {
|
||||
text: 'oodikone | TOSKA',
|
||||
},
|
||||
xAxis: {
|
||||
categories,
|
||||
},
|
||||
chart: {
|
||||
type: 'column',
|
||||
width: 950,
|
||||
height: 400,
|
||||
},
|
||||
plotOptions: {
|
||||
column: {
|
||||
stacking: 'normal',
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
pointFormat: '<b>{series.name}: {point.percentage:.1f} %</b>',
|
||||
},
|
||||
yAxis: {
|
||||
allowDecimals: false,
|
||||
min: 0,
|
||||
reversed: false,
|
||||
title: '',
|
||||
},
|
||||
}
|
||||
|
||||
return <ReactHighcharts config={defaultConfig} />
|
||||
}
|
||||
|
||||
export default StackedBarChart
|
||||
@@ -0,0 +1,76 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Divider } from 'semantic-ui-react'
|
||||
|
||||
import LineGraph from './LineGraph'
|
||||
import StackedBarChart from './StackedBarChart'
|
||||
import BarChart from './BarChart'
|
||||
import DataTable from './DataTable'
|
||||
import InfoBox from '../../Info/InfoBox'
|
||||
import { getBasicStats, getCreditStats, getGraduationStats } 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 graduationStatsTitles = ['', 'Graduated', 'Wrote thesis']
|
||||
|
||||
const Overview = props => {
|
||||
const toolTips = InfotoolTips.Studyprogramme
|
||||
const {
|
||||
studyprogramme,
|
||||
basicStats,
|
||||
creditStats,
|
||||
graduationStats,
|
||||
dispatchGetBasicStats,
|
||||
dispatchGetCreditStats,
|
||||
dispatchGetGetGraduationStats,
|
||||
} = props
|
||||
|
||||
useEffect(() => {
|
||||
dispatchGetBasicStats(studyprogramme)
|
||||
dispatchGetCreditStats(studyprogramme)
|
||||
dispatchGetGetGraduationStats(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">
|
||||
<StackedBarChart categories={creditStats?.data?.years} data={creditStats?.data?.graphStats} />
|
||||
<DataTable titles={creditStatsTitles} data={creditStats?.data?.tableStats} />
|
||||
</div>
|
||||
{getDivider('Graduated and thesis writers of the programme')}
|
||||
<div className="section-container">
|
||||
<BarChart categories={graduationStats?.data?.years} data={graduationStats?.data?.graphStats} />
|
||||
<DataTable titles={graduationStatsTitles} data={graduationStats?.data?.tableStats} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ studyProgramme }) => ({
|
||||
basicStats: studyProgramme?.basicStats,
|
||||
creditStats: studyProgramme?.creditStats,
|
||||
graduationStats: studyProgramme?.graduationStats,
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps, {
|
||||
dispatchGetBasicStats: getBasicStats,
|
||||
dispatchGetCreditStats: getCreditStats,
|
||||
dispatchGetGetGraduationStats: getGraduationStats,
|
||||
})(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'
|
||||
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import React from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Segment } from 'semantic-ui-react'
|
||||
import { arrayOf, shape, string, func } from 'prop-types'
|
||||
import { getCourseSearchResults } from '../../../selectors/courses'
|
||||
import useLanguage from '../../LanguagePicker/useLanguage'
|
||||
import SortableTable from '../../SortableTable'
|
||||
|
||||
const SearchResult = ({ courses, getCourseActions }) =>
|
||||
courses.length === 0 ? (
|
||||
<Segment content="No results" />
|
||||
) : (
|
||||
const SearchResult = ({ courses, getCourseActions }) => {
|
||||
const { language } = useLanguage()
|
||||
|
||||
if (!courses || courses.length === 0) return <Segment content="No results" />
|
||||
|
||||
return (
|
||||
<SortableTable
|
||||
getRowKey={c => c.code}
|
||||
tableProps={{ celled: true }}
|
||||
@@ -22,7 +24,7 @@ const SearchResult = ({ courses, getCourseActions }) =>
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Name',
|
||||
getRowVal: c => c.name,
|
||||
getRowVal: c => c.name[language],
|
||||
headerProps: { width: 12, textAlign: 'left' },
|
||||
},
|
||||
{
|
||||
@@ -35,15 +37,6 @@ const SearchResult = ({ courses, getCourseActions }) =>
|
||||
data={courses}
|
||||
/>
|
||||
)
|
||||
|
||||
SearchResult.propTypes = {
|
||||
courses: arrayOf(
|
||||
shape({
|
||||
code: string,
|
||||
name: string,
|
||||
})
|
||||
),
|
||||
getCourseActions: func.isRequired,
|
||||
}
|
||||
|
||||
SearchResult.defaultProps = {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { string, func, bool, arrayOf, shape } from 'prop-types'
|
||||
import { Dimmer, Loader, Segment, Button } from 'semantic-ui-react'
|
||||
import { getThesisCourses, deleteThesisCourse } from '../../../redux/thesisCourses'
|
||||
import SortableTable from '../../SortableTable'
|
||||
@@ -53,19 +52,6 @@ const ThesisCourseList = ({ studyprogramme, getThesisCourses, pending, data, del
|
||||
)
|
||||
}
|
||||
|
||||
ThesisCourseList.propTypes = {
|
||||
studyprogramme: string.isRequired,
|
||||
pending: bool.isRequired,
|
||||
getThesisCourses: func.isRequired,
|
||||
deleteThesisCourse: func.isRequired,
|
||||
data: arrayOf(
|
||||
shape({
|
||||
courseCode: string,
|
||||
thesisType: string,
|
||||
})
|
||||
),
|
||||
}
|
||||
|
||||
ThesisCourseList.defaultProps = {
|
||||
data: [],
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react'
|
||||
import { Button, Message } from 'semantic-ui-react'
|
||||
import { string, func } from 'prop-types'
|
||||
import { connect } from 'react-redux'
|
||||
import CourseSearchForm from '../../CourseSearchForm'
|
||||
import ThesisCourseList from './ThesisCourseList'
|
||||
@@ -34,12 +33,6 @@ const ThesisCourses = ({ studyprogramme, createThesis, getTheses }) => {
|
||||
)
|
||||
}
|
||||
|
||||
ThesisCourses.propTypes = {
|
||||
studyprogramme: string.isRequired,
|
||||
createThesis: func.isRequired,
|
||||
getTheses: func.isRequired,
|
||||
}
|
||||
|
||||
export default connect(null, {
|
||||
createThesis: createNewThesisCourse,
|
||||
getTheses: getThesisCourses,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import singleCourseStats from './singleCourseStats'
|
||||
import userAccessEmail from './userAccessEmail'
|
||||
import customPopulationSearch from './customPopulationSearch'
|
||||
import coolDataScience from './coolDataScience'
|
||||
import studyProgramme from './studyProgramme'
|
||||
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
@@ -85,6 +86,7 @@ const store = configureStore({
|
||||
customPopulationSearch,
|
||||
coolDataScience,
|
||||
[RTKApi.reducerPath]: RTKApi.reducer,
|
||||
studyProgramme,
|
||||
},
|
||||
// oodikone is currently too heavy for other middlewares than thunk, but
|
||||
// feel free to take use them at some point if possible
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createReducer } from '@reduxjs/toolkit'
|
||||
import { callController, actionTypes } from '../apiConnection'
|
||||
import { initialState, matcherReducers, defaultReducer } from './common'
|
||||
|
||||
const baseUrl = '/v2/studyprogrammes'
|
||||
const getBasicsPrefix = 'GET_STUDY_PROGRAMME_BASIC_STATS_'
|
||||
const getBasics = {
|
||||
...actionTypes(getBasicsPrefix),
|
||||
}
|
||||
|
||||
const getCreditsPrefix = 'GET_STUDY_PROGRAMME_CREDIT_STATS_'
|
||||
const getCredits = {
|
||||
...actionTypes(getCreditsPrefix),
|
||||
}
|
||||
|
||||
const getGraduationPrefix = 'GET_STUDY_PROGRAMME_GRADUATION_STATS_'
|
||||
const getGraduation = {
|
||||
...actionTypes(getGraduationPrefix),
|
||||
}
|
||||
|
||||
export const getBasicStats = id => callController(`${baseUrl}/${id}/basicstats`, getBasicsPrefix)
|
||||
export const getCreditStats = id => callController(`${baseUrl}/${id}/creditstats`, getCreditsPrefix)
|
||||
export const getGraduationStats = id => callController(`${baseUrl}/${id}/graduationstats`, getGraduationPrefix)
|
||||
|
||||
const reducer = createReducer(
|
||||
initialState,
|
||||
{
|
||||
[getBasics.success]: (state, action) => {
|
||||
state.basicStats = action.response
|
||||
},
|
||||
[getCredits.success]: (state, action) => {
|
||||
state.creditStats = action.response
|
||||
},
|
||||
[getGraduation.success]: (state, action) => {
|
||||
state.graduationStats = action.response
|
||||
},
|
||||
},
|
||||
matcherReducers,
|
||||
defaultReducer
|
||||
)
|
||||
|
||||
export default reducer
|
||||
Reference in New Issue
Block a user