mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-13 03:13:40 +03:00
Compare commits
118
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d0fd3563f | ||
|
|
a929613e83 | ||
|
|
d64aca5285 | ||
|
|
9e1307595c | ||
|
|
0e9ba8490d | ||
|
|
50b4166a7f | ||
|
|
3843937429 | ||
|
|
b60b1dd88b | ||
|
|
ed513c14bf | ||
|
|
8e2b8e5443 | ||
|
|
bb215bb785 | ||
|
|
635fb97b25 | ||
|
|
a5334c8e2f | ||
|
|
18b8daabff | ||
|
|
b181fda7fc | ||
|
|
747c37697c | ||
|
|
76d8fef4eb | ||
|
|
743ccb6797 | ||
|
|
dab81f994d | ||
|
|
b17fa2a11d | ||
|
|
ea8a84420f | ||
|
|
56d98e16cd | ||
|
|
b9b90876f1 | ||
|
|
777a19b291 | ||
|
|
362930c481 | ||
|
|
8ca1636517 | ||
|
|
d6beafa13a | ||
|
|
f92d72b77a | ||
|
|
dca144d58b | ||
|
|
9a9f723b46 | ||
|
|
e6686c024a | ||
|
|
2aa6e9c549 | ||
|
|
898a2b1f1b | ||
|
|
6a9a49952b | ||
|
|
0f76e50e4b | ||
|
|
d9695901b2 | ||
|
|
b85edaf6f0 | ||
|
|
b165ece770 | ||
|
|
a90e82a31e | ||
|
|
651e37051d | ||
|
|
77b6d1cb16 | ||
|
|
afa6804d09 | ||
|
|
a57f2f01ca | ||
|
|
1b4bdff37e | ||
|
|
1fa10f7812 | ||
|
|
fbe5db8a43 | ||
|
|
d4c7a4014b | ||
|
|
1177c2f848 | ||
|
|
0f8d72fd1d | ||
|
|
47e3e2e26e | ||
|
|
a312a14944 | ||
|
|
e3ec33f8dc | ||
|
|
9c55d3b2ab | ||
|
|
17ce83660c | ||
|
|
22d3877b8e | ||
|
|
c60bee34fd | ||
|
|
15240050b7 | ||
|
|
7da09c8b02 | ||
|
|
cfb2d4d5c0 | ||
|
|
39b594d795 | ||
|
|
ff5bce1713 | ||
|
|
8e288f3b26 | ||
|
|
4134c8b64b | ||
|
|
ddc1a22492 | ||
|
|
8ff1cb54dc | ||
|
|
777de520f9 | ||
|
|
a9e325fe72 | ||
|
|
befc3db3a8 | ||
|
|
e1b8ebd17f | ||
|
|
883129bee0 | ||
|
|
18eb04beeb | ||
|
|
eb12d7e5ec | ||
|
|
26ca0a3bc1 | ||
|
|
b79c97780b | ||
|
|
0cd3bf28ff | ||
|
|
20b0ba5a30 | ||
|
|
67001a9db1 | ||
|
|
8c268395b0 | ||
|
|
235d2b03b2 | ||
|
|
cbe20f1ad3 | ||
|
|
47211dcf89 | ||
|
|
26c6c653e5 | ||
|
|
13bdf698cf | ||
|
|
fd6a65925a | ||
|
|
435d2920fb | ||
|
|
4ae93e0b7e | ||
|
|
dc5200da2f | ||
|
|
373c8f9de8 | ||
|
|
34cc022830 | ||
|
|
ab311ddc66 | ||
|
|
833fdad3bb | ||
|
|
4b634e032d | ||
|
|
a16fd13413 | ||
|
|
58913b4684 | ||
|
|
05c8993ba3 | ||
|
|
d3090ae725 | ||
|
|
c870061fec | ||
|
|
ec43699c3c | ||
|
|
1b1e380a13 | ||
|
|
5c13b5c0ab | ||
|
|
f35fd41bfb | ||
|
|
6548cdd42b | ||
|
|
49b0da760d | ||
|
|
34e9cf5667 | ||
|
|
288d32c591 | ||
|
|
5001d55843 | ||
|
|
eaedfe99db | ||
|
|
6cf375936a | ||
|
|
8bc5c3ea65 | ||
|
|
3e4e749625 | ||
|
|
114af7522d | ||
|
|
c0b29d69c7 | ||
|
|
fb541ffa3a | ||
|
|
0ed4ce7bb4 | ||
|
|
87e1e332a8 | ||
|
|
914440cef9 | ||
|
|
901a4de21c | ||
|
|
2bc28c7dd3 |
@@ -47,4 +47,4 @@ runs:
|
||||
- name: 'Print logs on failure'
|
||||
shell: bash
|
||||
if: failure()
|
||||
run: docker compose --file docker-compose.ci.yml logs
|
||||
run: docker compose --file docker-compose.ci.yml --profile oodikone logs
|
||||
|
||||
@@ -182,7 +182,7 @@ jobs:
|
||||
- build_and_push_anon_kone_db
|
||||
- build_and_push_anon_user_db
|
||||
- lint_packages
|
||||
if: always() && !cancelled()
|
||||
if: always() && !cancelled() && needs.lint_packages.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -115,7 +115,6 @@ jobs:
|
||||
name: 'Build and release Updater worker'
|
||||
runs-on: ubuntu-latest
|
||||
needs: test_updater
|
||||
if: github.event_name == 'push'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -145,7 +144,6 @@ jobs:
|
||||
name: 'Build and release Updater scheduler'
|
||||
runs-on: ubuntu-latest
|
||||
needs: test_updater
|
||||
if: github.event_name == 'push'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ const checkGradeTable = gradesTableContents => {
|
||||
}
|
||||
|
||||
const checkTableContents = contents => {
|
||||
cy.get('table tbody').scrollIntoView()
|
||||
cy.get('table tbody').within(() => {
|
||||
contents.forEach((values, trIndex) => {
|
||||
cy.get('tr')
|
||||
@@ -396,7 +397,6 @@ describe('Basic user', () => {
|
||||
cy.contains('Search for courses')
|
||||
searchByCourseCode('TKT')
|
||||
cy.cs('select-multiple-courses-toggle').should('not.have.class', 'Mui-checked')
|
||||
cy.contains('TKT10004, BSCS2001, 581328, A581328, AYTKT10004')
|
||||
cy.contains('Select all search results').should('not.exist')
|
||||
cy.cs('select-multiple-courses-toggle').click()
|
||||
cy.cs('select-multiple-courses-toggle').should('have.class', 'Mui-checked')
|
||||
|
||||
@@ -12,7 +12,7 @@ const save = () => {
|
||||
}
|
||||
|
||||
const selectSavedPopulation = name => {
|
||||
cy.cs('history-search').click().type(`${name}{enter}`)
|
||||
cy.cs('history-search').type(`${name}{enter}`)
|
||||
}
|
||||
|
||||
const deleteAllSearches = () => {
|
||||
|
||||
@@ -93,14 +93,14 @@ describe('Degree programme overview', () => {
|
||||
it('Basic information contains correct credits', () => {
|
||||
const years = getEmptyYears()
|
||||
const tableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
[2023, 1519, 1519, 0, 0, 0, 0, 222, 0],
|
||||
[2022, 3235, 3205, 0, 30, 0, 0, 209, 0],
|
||||
[2021, 5133, 5108, 0, 25, 0, 0, 428, 25],
|
||||
[2020, 5801, 5796, 0, 5, 0, 0, 94, 0],
|
||||
[2019, 5305, 5305, 0, 0, 0, 0, 162, 0],
|
||||
[2018, 3442, 3432, 0, 10, 0, 0, 21, 0],
|
||||
[2017, 1211, 1211, 0, 0, 0, 0, 189, 0],
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
[2023, 1519, 1519, 0, 0, 0, 0, 0, 222, 0],
|
||||
[2022, 3235, 3205, 0, 0, 30, 0, 0, 209, 0],
|
||||
[2021, 5133, 5108, 0, 0, 25, 0, 0, 428, 25],
|
||||
[2020, 5801, 5796, 0, 0, 5, 0, 0, 94, 0],
|
||||
[2019, 5305, 5305, 0, 0, 0, 0, 0, 162, 0],
|
||||
[2018, 3442, 3432, 0, 0, 10, 0, 0, 21, 0],
|
||||
[2017, 1211, 1211, 0, 0, 0, 0, 0, 189, 0],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'credits-produced-by-the-study-programme')
|
||||
@@ -173,14 +173,14 @@ describe('Degree programme overview', () => {
|
||||
cy.checkTableStats(studentTableContents, 'students-of-the-study-programme')
|
||||
|
||||
const creditTableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 160, 160, 0, 0, 0, 0, 67, 0],
|
||||
['2022 - 2023', 2725, 2720, 0, 5, 0, 0, 337, 0],
|
||||
['2021 - 2022', 4092, 4042, 0, 50, 0, 0, 198, 0],
|
||||
['2020 - 2021', 5420, 5415, 0, 5, 0, 0, 321, 25],
|
||||
['2019 - 2020', 6043, 6043, 0, 0, 0, 0, 101, 0],
|
||||
['2018 - 2019', 4856, 4851, 0, 5, 0, 0, 107, 0],
|
||||
['2017 - 2018', 2350, 2345, 0, 5, 0, 0, 26, 0],
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 160, 160, 0, 0, 0, 0, 0, 67, 0],
|
||||
['2022 - 2023', 2725, 2720, 0, 0, 5, 0, 0, 337, 0],
|
||||
['2021 - 2022', 4092, 4042, 0, 0, 50, 0, 0, 198, 0],
|
||||
['2020 - 2021', 5420, 5415, 0, 0, 5, 0, 0, 321, 25],
|
||||
['2019 - 2020', 6043, 6043, 0, 0, 0, 0, 0, 101, 0],
|
||||
['2018 - 2019', 4856, 4851, 0, 0, 5, 0, 0, 107, 0],
|
||||
['2017 - 2018', 2350, 2345, 0, 0, 5, 0, 0, 26, 0],
|
||||
]
|
||||
|
||||
cy.checkTableStats(creditTableContents, 'credits-produced-by-the-study-programme')
|
||||
@@ -214,9 +214,9 @@ describe('Degree programme overview', () => {
|
||||
cy.contains('2022')
|
||||
cy.contains('48 graduated').trigger('mouseover')
|
||||
cy.contains('48 students graduated in year 2022')
|
||||
cy.contains('median study time: 36 months')
|
||||
cy.contains('27 graduated on time')
|
||||
cy.contains('9 graduated max year overtime')
|
||||
cy.contains('median study time: 6 semesters')
|
||||
cy.contains('26 graduated on time')
|
||||
cy.contains('10 graduated max year overtime')
|
||||
cy.contains('12 graduated over year late')
|
||||
})
|
||||
|
||||
@@ -245,7 +245,7 @@ describe('Degree programme overview', () => {
|
||||
cy.contains('2021')
|
||||
cy.contains('2 graduated').trigger('mouseover')
|
||||
cy.contains('2 students graduated in year 2021')
|
||||
cy.contains('median study time: 25 months')
|
||||
cy.contains('median study time: 4.5 semesters')
|
||||
cy.contains('1 graduated on time')
|
||||
cy.contains('1 graduated max year overtime')
|
||||
cy.contains('0 graduated over year late')
|
||||
@@ -257,7 +257,7 @@ describe('Degree programme overview', () => {
|
||||
cy.contains('2023')
|
||||
cy.contains('11 graduated').trigger('mouseover')
|
||||
cy.contains('11 students graduated in year 2023')
|
||||
cy.contains('median study time: 67 months')
|
||||
cy.contains('median study time: 12 semesters')
|
||||
cy.contains('4 graduated on time')
|
||||
cy.contains('6 graduated max year overtime')
|
||||
cy.contains('1 graduated over year late')
|
||||
@@ -415,12 +415,12 @@ describe('Degree programme overview', () => {
|
||||
cy.get('[aria-label="2020 - 2021, 16. On time."]').trigger('mouseover')
|
||||
cy.contains('On time: 16')
|
||||
cy.contains("Click a bar to view that year's study track level breakdown")
|
||||
cy.get('[aria-label="2019 - 2020, 23. On time."]').click()
|
||||
cy.get('[aria-label="2019 - 2020, 22. On time."]').click()
|
||||
cy.contains('Year 2019 - 2020 by start year')
|
||||
cy.get('[aria-label="MAT-MAT, 14. On time."]').trigger('mouseover')
|
||||
cy.get('[aria-label="MAT-MAT, 13. On time."]').trigger('mouseover')
|
||||
cy.contains('Matematiikka')
|
||||
cy.contains('MAT-MAT')
|
||||
cy.contains('On time: 14')
|
||||
cy.contains('On time: 13')
|
||||
})
|
||||
|
||||
cy.cs('graduation-time-toggle').click()
|
||||
@@ -429,7 +429,7 @@ describe('Degree programme overview', () => {
|
||||
cy.contains('2020 - 2021')
|
||||
cy.contains('19 graduated').trigger('mouseover')
|
||||
cy.contains('From class of 2020 - 2021, 19/30 students have graduated')
|
||||
cy.contains('median study time: 33 months')
|
||||
cy.contains('median study time: 6 semesters')
|
||||
cy.contains('16 graduated on time')
|
||||
cy.contains('2 graduated max year overtime')
|
||||
cy.contains('1 graduated over year late')
|
||||
@@ -636,10 +636,11 @@ describe('Degree programme overview', () => {
|
||||
cy.get('thead tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('th').should('have.length', 6)
|
||||
cy.get('th').should('have.length', 7)
|
||||
const headers = [
|
||||
'Degree students',
|
||||
'Open university',
|
||||
'Open university (hetu)',
|
||||
'Open university (no hetu)',
|
||||
'Exchange students',
|
||||
'Other universities',
|
||||
'Separate studies',
|
||||
@@ -660,7 +661,7 @@ describe('Degree programme overview', () => {
|
||||
'Type',
|
||||
'Code',
|
||||
'Name',
|
||||
'Total',
|
||||
'Total students',
|
||||
'Breakdown of total',
|
||||
'Breakdown of passed',
|
||||
'Not included in total or passed',
|
||||
@@ -672,12 +673,13 @@ describe('Degree programme overview', () => {
|
||||
cy.get('thead tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('th').should('have.length', 9)
|
||||
cy.get('th').should('have.length', 10)
|
||||
const headers = [
|
||||
'Passed',
|
||||
'Not completed',
|
||||
'Degree students',
|
||||
'Open uni students',
|
||||
'Open university students (with hetu)',
|
||||
'Open university students (without hetu)',
|
||||
'Exchange students',
|
||||
'Other university students',
|
||||
'Separate studies',
|
||||
@@ -708,7 +710,7 @@ describe('Degree programme overview', () => {
|
||||
})
|
||||
|
||||
// FIXME: Flaky
|
||||
it('test', () => {
|
||||
it.skip('test', () => {
|
||||
// TODO: Implement some real tests for this tab
|
||||
cy.contains('From')
|
||||
})
|
||||
|
||||
@@ -383,7 +383,7 @@ describe('Population statistics tests', () => {
|
||||
cy.contains(nonExisting).should('not.exist')
|
||||
cy.contains('button', 'Check student numbers').click()
|
||||
cy.contains('Check for student numbers')
|
||||
cy.cs('check-student-numbers').click().type(`${existing}{enter}${nonExisting}`)
|
||||
cy.cs('check-student-numbers').type(`${existing}{enter}${nonExisting}`)
|
||||
cy.contains('button', 'Check students').click()
|
||||
cy.get('#checkstudentsresults').within(() => {
|
||||
cy.cs('found-title').click()
|
||||
|
||||
+1
-1
@@ -170,7 +170,7 @@ services:
|
||||
- npm_config_cache=/tmp
|
||||
- PORT=3003
|
||||
- NODE_ENV=dev
|
||||
- DATABASE_URL=postgres://postgres:postgres@jami-db:5432/postgres
|
||||
- DATABASE_URL=postgres://postgres:postgres@jami-db:5432/jami-db-real
|
||||
ports:
|
||||
- 3003:3003
|
||||
container_name: jami
|
||||
|
||||
+8
-1
@@ -75,7 +75,14 @@ export default tseslint.config(
|
||||
'prefer-const': 'error',
|
||||
'prefer-destructuring': 'off', // Disabled in favor of @typescript-eslint
|
||||
'dot-notation': 'off', // Disabled in favor of @typescript-eslint
|
||||
'no-restricted-imports': ['error', { 'patterns': [{ 'regex': '^@mui/[^/]+$' }] }],
|
||||
'no-restricted-imports': ['error',
|
||||
{
|
||||
'patterns':
|
||||
[
|
||||
{ 'regex': '^@mui/[^/]+$', 'message': 'Import only the needed module. This has no effect in prod due to tree-shake, but speeds up development builds.' },
|
||||
{ 'regex': '^src/.*', 'message': 'Root imports will not resolve in production build (instead silently crash).' }
|
||||
]
|
||||
}],
|
||||
'quotes': ['error', 'single',
|
||||
{ avoidEscape: true, allowTemplateLiterals: false }
|
||||
],
|
||||
|
||||
+3
-2
@@ -28,9 +28,10 @@
|
||||
"prepare": "husky",
|
||||
"rapodiff": "docker exec -t oodikone-backend /opt/app-root/backend/node_modules/.bin/tsx /opt/app-root/backend/src/rapodiff/rapodiff.js",
|
||||
"install:local": "npm i && npm i --prefix services/shared && npm i --prefix services/frontend && npm i --prefix services/backend && npm i --prefix updater/sis-updater-scheduler && npm i --prefix updater/sis-updater-worker",
|
||||
"i:both": "npm run install:both",
|
||||
"ci:local": "npm ci && npm ci --prefix services/shared && npm ci --prefix services/frontend && npm ci --prefix services/backend && npm ci --prefix updater/sis-updater-scheduler && npm ci --prefix updater/sis-updater-worker",
|
||||
"install:docker": "docker exec frontend npm i && docker exec backend npm i && docker exec sis-updater-scheduler npm i && docker exec oodikone-sis-updater-worker npm i",
|
||||
"ci:docker": "docker exec frontend npm ci && docker exec backend npm ci && docker exec sis-updater-scheduler npm ci && docker exec oodikone-sis-updater-worker npm ci",
|
||||
"install:docker": "docker exec frontend npm i && docker exec backend npm i && docker exec sis-updater-scheduler npm i && docker exec sis-updater-worker npm i",
|
||||
"ci:docker": "docker exec frontend npm ci && docker exec backend npm ci && docker exec sis-updater-scheduler npm ci && docker exec sis-updater-worker npm ci",
|
||||
"install:both": "npm run install:local && npm run install:docker",
|
||||
"ci:both": "npm run ci:local && npm run ci:docker"
|
||||
},
|
||||
|
||||
@@ -11,7 +11,6 @@ PROJECT_ROOT="$(git rev-parse --show-toplevel)"
|
||||
DUMP_DIR="$PROJECT_ROOT/.databasedumps"
|
||||
S3_CONFIG_FILE=~/.s3cfg
|
||||
S3_BUCKET="s3://psyduck"
|
||||
DOCKER_COMPOSE=$PROJECT_ROOT/docker-compose.yml
|
||||
|
||||
## Following the naming convention in docker-compose, these are names for database
|
||||
## services. The real data databases inside service will have names suffixed by "-real".
|
||||
@@ -139,29 +138,6 @@ reset_databases() {
|
||||
successmsg "Database setup finished"
|
||||
}
|
||||
|
||||
reset_jami_data() {
|
||||
local database="jami-db"
|
||||
local dump_destination="$DUMP_DIR/$database.sql.gz"
|
||||
local s3_path="jami"
|
||||
|
||||
infomsg "Removing old data"
|
||||
|
||||
|
||||
|
||||
infomsg "Removing database and related volume"
|
||||
docker volume rm oodikone-jami-data || warningmsg "This is okay, continuing"
|
||||
docker-compose -f "$DOCKER_COMPOSE" down "$database" || warningmsg "This is okay, continuing"
|
||||
|
||||
infomsg "Starting postgres in the background"
|
||||
docker-compose -f "$DOCKER_COMPOSE" up -d "$database"
|
||||
retry docker-compose -f "$DOCKER_COMPOSE" exec "$database" pg_isready --dbname="postgres"
|
||||
|
||||
infomsg "Populating Jami"
|
||||
docker exec -i "$database" /bin/bash -c "gunzip | psql -U postgres" < "$dump_destination" 2> /dev/null
|
||||
docker-compose stop "$database"
|
||||
msg ""
|
||||
}
|
||||
|
||||
reset_all_real_data() {
|
||||
infomsg "Downloading real data dumps"
|
||||
for i in ${!DATABASES[*]}; do
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
*/.dockerignore
|
||||
*/.eslintrc.json
|
||||
*/.gitignore
|
||||
*/*Dockerfile
|
||||
*/build
|
||||
*/dist
|
||||
*/node_modules
|
||||
@@ -1,3 +0,0 @@
|
||||
.dockerignore
|
||||
*Dockerfile
|
||||
node_modules/
|
||||
@@ -71,6 +71,9 @@ export class SISStudyRightModel extends Model implements SISStudyRight {
|
||||
@Column(DataType.JSONB)
|
||||
declare semesterEnrollments: SISStudyRight['semesterEnrollments']
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
declare transferInfo: SISStudyRight['transferInfo']
|
||||
|
||||
@HasMany(() => CreditModel, { foreignKey: 'studyright_id', sourceKey: 'id' })
|
||||
declare credits: SISStudyRight['credits']
|
||||
|
||||
|
||||
@@ -10,9 +10,8 @@ import {
|
||||
CourseYearlyStatsQuery,
|
||||
} from '@oodikone/shared/routes/courses'
|
||||
import type { CourseWithSubsId } from '@oodikone/shared/types/course'
|
||||
import { uniq } from '@oodikone/shared/util'
|
||||
import { getCourseYearlyStats } from '../services/courses'
|
||||
import { getCoursesByNameAndOrCode, getCoursesByCodes } from '../services/courses/courseFinders'
|
||||
import { getCoursesByNameAndOrCode } from '../services/courses/courseFinders'
|
||||
import { getFullStudyProgrammeRights, hasFullAccessToStudentData, validateParamLength } from '../util'
|
||||
|
||||
const router = Router()
|
||||
@@ -20,49 +19,30 @@ const router = Router()
|
||||
router.get<never, CanError<CoursesMultiResBody>, CoursesMultiReqBody, CoursesMultiQuery>(
|
||||
'/v2/coursesmulti',
|
||||
async (req, res) => {
|
||||
const { name, code, combineSubstitutions, includeSpecial } = req.query
|
||||
const { name, code, includeSpecial } = req.query
|
||||
if (!(validateParamLength(name, 5) || validateParamLength(code, 2))) {
|
||||
return res.status(400).json({ error: 'Query parameter name or code is invalid' })
|
||||
}
|
||||
|
||||
const courses = await getCoursesByNameAndOrCode(name, code, includeSpecial === 'true')
|
||||
|
||||
if (combineSubstitutions === 'false') {
|
||||
const courseCodes = courses.map(course => course.code)
|
||||
const substitutions = uniq(
|
||||
courses.flatMap(course => course.substitutions).filter(code => !courseCodes.includes(code))
|
||||
)
|
||||
const substitutionDetails = await getCoursesByCodes(substitutions)
|
||||
for (const substitution of substitutionDetails) {
|
||||
courses.push(substitution.toJSON())
|
||||
}
|
||||
}
|
||||
|
||||
const mergedCourses: Record<string, CourseWithSubsId> = {}
|
||||
const mergedCourses = new Map<string, CourseWithSubsId>()
|
||||
|
||||
for (const course of courses) {
|
||||
if (!(course.max_attainment_date && course.min_attainment_date)) continue
|
||||
|
||||
const groupId = combineSubstitutions === 'true' ? course.subsId?.toString() : course.code
|
||||
if (!groupId) continue
|
||||
if (!mergedCourses.has(course.code)) mergedCourses.set(course.code, structuredClone(course))
|
||||
const mergedCourse = mergedCourses.get(course.code)!
|
||||
|
||||
if (!mergedCourses[groupId]) {
|
||||
mergedCourses[groupId] = {
|
||||
...course,
|
||||
substitutions: combineSubstitutions === 'true' ? course.substitutions : [],
|
||||
}
|
||||
} else {
|
||||
const mergedCourse = mergedCourses[groupId]
|
||||
if (mergedCourse.max_attainment_date < course.max_attainment_date) {
|
||||
mergedCourse.max_attainment_date = course.max_attainment_date
|
||||
}
|
||||
if (mergedCourse.min_attainment_date > course.min_attainment_date) {
|
||||
mergedCourse.min_attainment_date = course.min_attainment_date
|
||||
}
|
||||
if (mergedCourse.max_attainment_date < course.max_attainment_date) {
|
||||
mergedCourse.max_attainment_date = course.max_attainment_date
|
||||
}
|
||||
if (mergedCourse.min_attainment_date > course.min_attainment_date) {
|
||||
mergedCourse.min_attainment_date = course.min_attainment_date
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ courses: Object.values(mergedCourses) })
|
||||
res.json({ courses: Array.from(mergedCourses.values()) })
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
setFacultyProgressStats,
|
||||
} from '../services/faculty/facultyService'
|
||||
import { combineFacultyStudentProgress, FacultyProgressData } from '../services/faculty/facultyStudentProgress'
|
||||
import { GraduationTarget } from '../services/graduationHelpers'
|
||||
import { getMedian } from '../services/studyProgramme/studyProgrammeHelpers'
|
||||
import logger from '../util/logger'
|
||||
|
||||
@@ -137,7 +138,12 @@ router.get('/allgraduationstats', async (_req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
const universityData = {
|
||||
goals: { bachelor: 36, bcMsCombo: 60, master: 24, doctor: 48 },
|
||||
goals: {
|
||||
bachelor: GraduationTarget.THREE_YEARS,
|
||||
bcMsCombo: GraduationTarget.FIVE_YEARS,
|
||||
master: GraduationTarget.TWO_YEARS,
|
||||
doctor: GraduationTarget.FOUR_YEARS,
|
||||
},
|
||||
programmeNames: getProgrammeNames(allFaculties),
|
||||
byGradYear: {
|
||||
medians: {} as Record<string, LevelGraduationStats[]>,
|
||||
|
||||
@@ -47,18 +47,19 @@ const getRawCourses = async (name: string, code: string, includeSpecial: boolean
|
||||
...nameLikeTerm(name),
|
||||
...codeLikeTerm(code),
|
||||
},
|
||||
raw: true,
|
||||
})
|
||||
|
||||
const getCourses = async (name: string, code: string, includeSpecial: boolean) => {
|
||||
const rawCourses = await getRawCourses(name, code, includeSpecial)
|
||||
return rawCourses
|
||||
.map(course => ({
|
||||
...course.toJSON(),
|
||||
...course,
|
||||
substitutions: orderBy(course.substitutions, [
|
||||
substitution => {
|
||||
if (/^A/.exec(substitution)) return 4 // open university codes come last
|
||||
if (/^\d/.exec(substitution)) return 2 // old numeric codes come second
|
||||
if (/^[A-Za-z]/.exec(substitution)) return 1 // new letter based codes come first
|
||||
if (/^A/.exec(substitution.at(0) ?? '')) return 4 // open university codes come last
|
||||
if (/^\d/.exec(substitution.at(0) ?? '')) return 2 // old numeric codes come second
|
||||
if (/^[A-Za-z]/.exec(substitution.at(0) ?? '')) return 1 // new letter based codes come first
|
||||
return 3 // unknown, comes before open uni?
|
||||
},
|
||||
]),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cloneDeep, omit } from 'lodash-es'
|
||||
import { Name, DegreeProgrammeType, Unarray } from '@oodikone/shared/types'
|
||||
import { ProgrammeGraduationStats } from '@oodikone/shared/types/studyProgramme'
|
||||
import { getGraduationStats, getStudyTrackStats, setGraduationStats, setStudyTrackStats } from '../analyticsService'
|
||||
import { GraduationTarget } from '../graduationHelpers'
|
||||
import { getGraduationStatsForStudyTrack, GraduationTimes } from '../studyProgramme/studyProgrammeGraduations'
|
||||
import { getMedian } from '../studyProgramme/studyProgrammeHelpers'
|
||||
import { getStudyRightsInProgramme } from '../studyProgramme/studyRightFinders'
|
||||
@@ -253,21 +254,22 @@ export const countGraduationTimes = async (faculty: string, programmesOfFaculty:
|
||||
} = await getStatsByStartYear(programmesOfFaculty)
|
||||
|
||||
const goals = {
|
||||
bachelor: 36,
|
||||
bcMsCombo: faculty === 'H90' ? 72 : 60,
|
||||
master: 24,
|
||||
doctor: 48,
|
||||
bachelor: GraduationTarget.THREE_YEARS,
|
||||
bcMsCombo: faculty === 'H90' ? GraduationTarget.SIX_YEARS : GraduationTarget.FIVE_YEARS,
|
||||
master: GraduationTarget.TWO_YEARS,
|
||||
doctor: GraduationTarget.FOUR_YEARS,
|
||||
exceptions: {
|
||||
MH30_004: 6, // months more
|
||||
'420420-ma': 6,
|
||||
'420074-ma': 6,
|
||||
'420119-ma': 6,
|
||||
MH30_001: 48,
|
||||
'320011-ma': 48,
|
||||
'320001-ma': 48,
|
||||
MH30_003: 42,
|
||||
'320002-ma': 42,
|
||||
'320009-ma': 42,
|
||||
// Exceptions are additions to the base amount
|
||||
MH30_004: GraduationTarget.HALF_YEAR,
|
||||
'420420-ma': GraduationTarget.HALF_YEAR,
|
||||
'420074-ma': GraduationTarget.HALF_YEAR,
|
||||
'420119-ma': GraduationTarget.HALF_YEAR,
|
||||
MH30_001: GraduationTarget.FOUR_YEARS,
|
||||
'320011-ma': GraduationTarget.FOUR_YEARS,
|
||||
'320001-ma': GraduationTarget.FOUR_YEARS,
|
||||
MH30_003: GraduationTarget.THREE_POINT_FIVE_YEARS,
|
||||
'320002-ma': GraduationTarget.THREE_POINT_FIVE_YEARS,
|
||||
'320009-ma': GraduationTarget.THREE_POINT_FIVE_YEARS,
|
||||
} as Record<string, number>,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
export const countTimeCategories = (times: number[], goal: number) => {
|
||||
/** Map years to semesters */
|
||||
export const GraduationTarget = {
|
||||
HALF_YEAR: 1,
|
||||
ONE_YEAR: 2,
|
||||
TWO_YEARS: 4,
|
||||
THREE_YEARS: 6,
|
||||
THREE_POINT_FIVE_YEARS: 7,
|
||||
FOUR_YEARS: 8,
|
||||
FIVE_YEARS: 10,
|
||||
SIX_YEARS: 12,
|
||||
} as const
|
||||
|
||||
/** Uses semesters, not months */
|
||||
export const countTimeCategories = (times: number[], target: number) => {
|
||||
const statistics = { onTime: 0, yearOver: 0, wayOver: 0 }
|
||||
times.forEach(time => {
|
||||
if (time <= goal) {
|
||||
statistics.onTime += 1
|
||||
} else if (time <= goal + 12) {
|
||||
statistics.yearOver += 1
|
||||
|
||||
for (const time of times) {
|
||||
if (time <= target) {
|
||||
statistics.onTime++
|
||||
} else if (time <= target + GraduationTarget.ONE_YEAR) {
|
||||
statistics.yearOver++
|
||||
} else {
|
||||
statistics.wayOver += 1
|
||||
statistics.wayOver++
|
||||
}
|
||||
})
|
||||
}
|
||||
return statistics
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import axios from 'axios'
|
||||
|
||||
import { isProduction, pateToken } from '../config'
|
||||
import { FormattedUser } from '../types'
|
||||
import { ApplicationError } from '../util/customErrors'
|
||||
import logger from '../util/logger'
|
||||
|
||||
const pateClient = axios.create({
|
||||
baseURL: 'https://api-toska.apps.ocp-prod-0.k8s.it.helsinki.fi/pate/',
|
||||
@@ -22,8 +22,8 @@ const baseSettings = {
|
||||
}
|
||||
|
||||
const sendEmail = async (options = {}) => {
|
||||
if (!isProduction) throw new ApplicationError('Email sending is disabled in development mode.')
|
||||
if (!pateToken) throw new ApplicationError('Email sending failed because pate token is missing.')
|
||||
if (!isProduction) return logger.error('Email sending is disabled in development mode.')
|
||||
if (!pateToken) return logger.error('Email sending failed because pate token is missing.')
|
||||
return await pateClient.post('/', options)
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ export const formatStudentForAPI = (
|
||||
sis_person_id: student.sis_person_id,
|
||||
citizenships: student.citizenships,
|
||||
curriculumVersion: getCurriculumVersion(hops?.curriculum_period_id),
|
||||
hasPersonalIdentityCode: student.hasPersonalIdentityCode,
|
||||
|
||||
tags,
|
||||
transferredStudyright,
|
||||
|
||||
@@ -79,6 +79,7 @@ type StudentPersonalData = Pick<
|
||||
| 'gender_code'
|
||||
| 'birthdate'
|
||||
| 'sis_person_id'
|
||||
| 'hasPersonalIdentityCode'
|
||||
>
|
||||
|
||||
type StudentStudyRightElement = Pick<
|
||||
@@ -125,6 +126,7 @@ export const getStudents = (studentNumbers: string[]): Promise<Array<StudentData
|
||||
'gender_code',
|
||||
'birthdate',
|
||||
'sis_person_id',
|
||||
'hasPersonalIdentityCode',
|
||||
],
|
||||
include: [
|
||||
{
|
||||
|
||||
@@ -78,7 +78,7 @@ export const getOptionsForStudents = (
|
||||
|
||||
export const getCourses = (courses: string[]): Promise<Array<CourseStats>> =>
|
||||
CourseModel.findAll({
|
||||
attributes: ['code', 'name', 'substitutions', 'is_study_module'],
|
||||
attributes: ['code', 'name', 'substitutions', 'substitution_groups', 'is_study_module'],
|
||||
where: {
|
||||
code: { [Op.in]: courses },
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import { mapToProviders } from '@oodikone/shared/util'
|
||||
import { getCreditStats, setCreditStats } from './analyticsService'
|
||||
import { getCourseCodesOfProvider } from './providers'
|
||||
import { getCreditsForProvider, getTransferredCredits } from './studyProgramme/creditGetters'
|
||||
import { getStudentHetuStateMap } from './studyProgramme/studentGetters'
|
||||
import { defineYear } from './studyProgramme/studyProgrammeHelpers'
|
||||
import { getSISStudyRightsOfStudents } from './studyProgramme/studyRightFinders'
|
||||
|
||||
@@ -47,13 +48,17 @@ const basicDegreeExtentCodes = [ExtentCode.BACHELOR, ExtentCode.MASTER, ExtentCo
|
||||
This is probably very rare to cause differences, only one such was found in MH60_001
|
||||
*/
|
||||
|
||||
const getCategory = (extentCode?: ExtentCode, degreeStudyRightExtentCode?: ExtentCode) => {
|
||||
const getCategory = (hasHetu: boolean, extentCode?: ExtentCode, degreeStudyRightExtentCode?: ExtentCode) => {
|
||||
if (degreeStudyRightExtentCode && basicDegreeExtentCodes.includes(degreeStudyRightExtentCode)) {
|
||||
return 'basic'
|
||||
}
|
||||
switch (extentCode) {
|
||||
case ExtentCode.OPEN_UNIVERSITY_STUDIES:
|
||||
return 'open-uni'
|
||||
if (hasHetu) {
|
||||
return 'open-uni-hetu'
|
||||
} else {
|
||||
return 'open-uni-no-hetu'
|
||||
}
|
||||
case ExtentCode.MASTER:
|
||||
case ExtentCode.BACHELOR_AND_MASTER:
|
||||
case ExtentCode.BACHELOR:
|
||||
@@ -121,38 +126,43 @@ export const computeCreditsProduced = async (providerCode: string, isAcademicYea
|
||||
|
||||
const students = [...new Set(credits.map(({ studentNumber }) => studentNumber))]
|
||||
|
||||
const studyRights = await getSISStudyRightsOfStudents(students)
|
||||
const [studyRights, studentToHetuStateMap] = await Promise.all([
|
||||
getSISStudyRightsOfStudents(students),
|
||||
getStudentHetuStateMap(students),
|
||||
])
|
||||
|
||||
const studyRightIdToStudyRightMap = studyRights.reduce<Record<string, (typeof studyRights)[number]>>((obj, cur) => {
|
||||
obj[cur.id] = cur
|
||||
return obj
|
||||
}, {})
|
||||
const studyRightIdToStudyRightMap = new Map(studyRights.map(sr => [sr.id, sr]))
|
||||
|
||||
const studentNumberToStudyRightsMap = studyRights.reduce<Record<string, typeof studyRights>>((obj, cur) => {
|
||||
obj[cur.studentNumber] ??= []
|
||||
obj[cur.studentNumber].push(cur)
|
||||
return obj
|
||||
}, {})
|
||||
const studentNumberToStudyRightsMap = studyRights.reduce((map, cur) => {
|
||||
// Node does not support getOrInsert as of writing :-(
|
||||
if (map.has(cur.studentNumber)) {
|
||||
map.get(cur.studentNumber)!.push(cur)
|
||||
} else {
|
||||
map.set(cur.studentNumber, [cur])
|
||||
}
|
||||
return map
|
||||
}, new Map<string, [(typeof studyRights)[number]]>())
|
||||
|
||||
const stats: Record<string, Record<string, number>> = {}
|
||||
|
||||
for (const { attainmentDate, studentNumber, credits: numOfCredits, studyrightId, semestercode } of credits) {
|
||||
const studyRightLinkedToAttainment = studyRightIdToStudyRightMap[studyrightId]
|
||||
const studyRightLinkedToAttainment = studyRightIdToStudyRightMap.get(studyrightId)
|
||||
const attainmentYear = defineYear(attainmentDate, isAcademicYear)
|
||||
const degreeStudyRight = getBasicDegreeStudyRight(
|
||||
studentNumberToStudyRightsMap[studentNumber],
|
||||
studentNumberToStudyRightsMap.get(studentNumber),
|
||||
attainmentDate,
|
||||
semestercode
|
||||
)
|
||||
const hasHetu = !!studentToHetuStateMap.get(studentNumber)
|
||||
|
||||
if (!studyRightLinkedToAttainment && !degreeStudyRight) {
|
||||
continue
|
||||
}
|
||||
|
||||
const category = getCategory(studyRightLinkedToAttainment?.extentCode, degreeStudyRight?.extentCode)
|
||||
const category = getCategory(hasHetu, studyRightLinkedToAttainment?.extentCode, degreeStudyRight?.extentCode)
|
||||
stats[attainmentYear] ??= {}
|
||||
stats[attainmentYear][category] ??= 0
|
||||
stats[attainmentYear][category] += numOfCredits || 0
|
||||
stats[attainmentYear][category] += numOfCredits
|
||||
}
|
||||
|
||||
const transferredCredits = await getTransferredCredits(rapoFormattedProviderCode, since)
|
||||
@@ -164,7 +174,7 @@ export const computeCreditsProduced = async (providerCode: string, isAcademicYea
|
||||
const attainmentYear = defineYear(createdate, isAcademicYear)
|
||||
stats[attainmentYear] ??= {}
|
||||
stats[attainmentYear].transferred ??= 0
|
||||
stats[attainmentYear].transferred += credits || 0
|
||||
stats[attainmentYear].transferred += credits
|
||||
})
|
||||
|
||||
return { stats, id: providerCode }
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { QueryTypes } from 'sequelize'
|
||||
import { Op, QueryTypes } from 'sequelize'
|
||||
|
||||
import { CreditTypeCode, ExtentCode, Name } from '@oodikone/shared/types'
|
||||
import { dbConnections } from '../../database/connection'
|
||||
import { StudentModel } from '../../models'
|
||||
import logger from '../../util/logger'
|
||||
|
||||
// Categories here are as per Linjaukset.md in internal docs.
|
||||
@@ -235,3 +236,26 @@ export const getTransferCourseAggregates = async (params: {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export const getStudentHetuStateMap = async (studentNumbers: string[]): Promise<Map<string, boolean>> => {
|
||||
if (!studentNumbers.length) return new Map()
|
||||
|
||||
try {
|
||||
return new Map(
|
||||
(
|
||||
await StudentModel.findAll({
|
||||
attributes: ['studentnumber', 'hasPersonalIdentityCode'],
|
||||
where: {
|
||||
studentnumber: {
|
||||
[Op.in]: studentNumbers,
|
||||
},
|
||||
},
|
||||
raw: true,
|
||||
})
|
||||
).map(student => [student.studentnumber, student.hasPersonalIdentityCode])
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('getOpenUniHetutonSplit failed', { error })
|
||||
return new Map()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { mapToProviders } from '@oodikone/shared/util'
|
||||
import { createEmptyStats, YearStats } from '@oodikone/shared/util/studyProgramme'
|
||||
|
||||
import { getAllProgrammeCourses, getCurrentStudyYearStartDate, getNotCompletedForProgrammeCourses } from '.'
|
||||
import { getProgrammeCourseAggregates, getTransferCourseAggregates } from './studentGetters'
|
||||
import { getStudentHetuStateMap, getProgrammeCourseAggregates, getTransferCourseAggregates } from './studentGetters'
|
||||
|
||||
const START_YEAR = 2017
|
||||
const JULY = 6
|
||||
@@ -52,7 +52,8 @@ type YearAccumulator = {
|
||||
separateStudents: Set<string>
|
||||
otherStudents: Set<string>
|
||||
|
||||
openStudents: Set<string>
|
||||
openStudentsHasHetu: Set<string>
|
||||
openStudentsNoHetu: Set<string>
|
||||
transferStudents: Set<string>
|
||||
}
|
||||
|
||||
@@ -72,7 +73,8 @@ const createYearAccumulator = (isStudyModule: boolean): YearAccumulator => ({
|
||||
separateStudents: new Set(),
|
||||
otherStudents: new Set(),
|
||||
|
||||
openStudents: new Set(),
|
||||
openStudentsHasHetu: new Set(),
|
||||
openStudentsNoHetu: new Set(),
|
||||
transferStudents: new Set(),
|
||||
})
|
||||
|
||||
@@ -117,7 +119,8 @@ const finalizeYearStats = (yearAccumulator: YearAccumulator): YearStats => {
|
||||
stats.separateStudents = yearAccumulator.separateStudents.size
|
||||
stats.otherStudents = yearAccumulator.otherStudents.size
|
||||
|
||||
stats.openStudents = yearAccumulator.openStudents.size
|
||||
stats.openStudentsHasHetu = yearAccumulator.openStudentsHasHetu.size
|
||||
stats.openStudentsNoHetu = yearAccumulator.openStudentsNoHetu.size
|
||||
stats.transferStudents = yearAccumulator.transferStudents.size
|
||||
|
||||
stats.allStudents = stats.allPassed + stats.allNotPassed
|
||||
@@ -131,6 +134,9 @@ const getYearRange = async (unixMillis: number, isAcademicYear: boolean) => {
|
||||
return range(START_YEAR, lastYear + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* @endpoint "programme courses"
|
||||
*/
|
||||
export const getStudyProgrammeCoursesForStudyTrack = async (
|
||||
unixMillis: number,
|
||||
studyProgramme: string,
|
||||
@@ -169,6 +175,10 @@ export const getStudyProgrammeCoursesForStudyTrack = async (
|
||||
}),
|
||||
])
|
||||
|
||||
const openUniStudentHetuMap = await getStudentHetuStateMap(
|
||||
programmeCredits.filter(c => c.variant === 'openUni').map(c => c.studentNumber)
|
||||
)
|
||||
|
||||
const courseMap = new Map<string, CourseAccumulator>()
|
||||
let maxYear = 0
|
||||
|
||||
@@ -208,8 +218,13 @@ export const getStudyProgrammeCoursesForStudyTrack = async (
|
||||
stats.otherStudentsCredits += row.credits
|
||||
break
|
||||
case 'openUni':
|
||||
yearAccumulator.openStudents.add(row.studentNumber)
|
||||
stats.openStudentsCredits += row.credits
|
||||
if (openUniStudentHetuMap.get(row.studentNumber)) {
|
||||
stats.openStudentsHasHetuCredits += row.credits
|
||||
yearAccumulator.openStudentsHasHetu.add(row.studentNumber)
|
||||
} else {
|
||||
stats.openStudentsNoHetuCredits += row.credits
|
||||
yearAccumulator.openStudentsNoHetu.add(row.studentNumber)
|
||||
}
|
||||
break
|
||||
default:
|
||||
break
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import dayjs from 'dayjs'
|
||||
import dayjs, { Dayjs } from 'dayjs'
|
||||
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
|
||||
import { indexOf, orderBy } from 'lodash-es'
|
||||
|
||||
import {
|
||||
@@ -10,12 +11,14 @@ import {
|
||||
ExtentCode,
|
||||
Phase,
|
||||
EnrollmentType,
|
||||
TransferInfo,
|
||||
} from '@oodikone/shared/types'
|
||||
import { ChartGraduationTimes } from '@oodikone/shared/types/graduations'
|
||||
import { ProgrammeGraduationStats } from '@oodikone/shared/types/studyProgramme'
|
||||
import { mapToProviders } from '@oodikone/shared/util'
|
||||
import { getDegreeProgrammeType, getMinimumCreditsOfProgramme, sortByProgrammeCode } from '../../util'
|
||||
import { countTimeCategories } from '../graduationHelpers'
|
||||
import logger from '../../util/logger'
|
||||
import { countTimeCategories, GraduationTarget } from '../graduationHelpers'
|
||||
import { getSemestersAndYears } from '../semesters'
|
||||
import { getStudyRightThesisCredits, getOrganizationThesisCredits } from './creditGetters'
|
||||
import { getGraduatedStats } from './studyProgrammeBasics'
|
||||
@@ -34,6 +37,8 @@ import {
|
||||
} from './studyProgrammeHelpers'
|
||||
import { getStudyRightsInProgramme } from './studyRightFinders'
|
||||
|
||||
dayjs.extend(isSameOrAfter)
|
||||
|
||||
/**
|
||||
* For bachelor + master study right combo. This rules out master's programmes that don't
|
||||
* follow the two-phase degree structure (e.g. degree programmes in medicine and dentistry)
|
||||
@@ -47,19 +52,6 @@ export const shouldIncludeComboStats = async (studyProgramme: string) => {
|
||||
return minimumCredits !== null && minimumCredits <= 180
|
||||
}
|
||||
|
||||
const calculateAbsenceInMonths = (
|
||||
absence: Awaited<ReturnType<typeof getSemestersAndYears>>['semesters'][string],
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
) => {
|
||||
if (endDate < absence.startdate || absence.enddate < startDate) {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Without 'true' as the third argument, the result will be truncated, not rounded (e.g. 4.999 would be 4, not 5). This is why we use Math.round() instead
|
||||
return Math.round(dayjs(absence.enddate).diff(absence.startdate, 'months', true))
|
||||
}
|
||||
|
||||
/**
|
||||
* Absence rules:
|
||||
* 1. Student can have any number of statutory absences (lakisääteinen poissaolo)
|
||||
@@ -67,29 +59,58 @@ const calculateAbsenceInMonths = (
|
||||
* 2b. If study right started before 1.8.2015, student can have 4 non-statutory absences instead of 2
|
||||
*
|
||||
* Additional _non-statutory_ absences will consume semesters
|
||||
*
|
||||
* @returns duration of studies in SEMESTERS (not months!)
|
||||
*/
|
||||
export const calculateDurationOfStudies = (
|
||||
startDate: Date,
|
||||
graduationDate: Date,
|
||||
semesterEnrollments: SemesterEnrollment[],
|
||||
semesters: Awaited<ReturnType<typeof getSemestersAndYears>>['semesters']
|
||||
semesters: Awaited<ReturnType<typeof getSemestersAndYears>>['semesters'],
|
||||
transferInfo?: TransferInfo | null
|
||||
) => {
|
||||
const allowedNonStatutoryAbsences = dayjs(startDate).isBefore(dayjs('2015-08-01'), 'day') ? 4 : 2
|
||||
/** Transferred students: unclear whether this should be based on the original start date or not. Likely doesn't matter. */
|
||||
const allowedNonStatutoryAbsences = dayjs(transferInfo?.originalStartDate ?? startDate).isBefore(
|
||||
dayjs('2015-08-01'),
|
||||
'day'
|
||||
)
|
||||
? 4
|
||||
: 2
|
||||
|
||||
const semestersWithStatutoryAbsence = semesterEnrollments
|
||||
.filter(enrollment => enrollment.statutoryAbsence)
|
||||
.map(enrollment => enrollment.semester)
|
||||
/** A semester ending and the next one starting overlap, this takes care of that */
|
||||
const getCorrectSemester = (date: Dayjs) =>
|
||||
Object.values(semesters).find(
|
||||
({ startdate: semesterStart, enddate: semesterEnd }) =>
|
||||
date.isSameOrAfter(semesterStart, 'day') && date.isBefore(semesterEnd, 'day')
|
||||
)?.semestercode
|
||||
|
||||
const semestersWithNonStatutoryAbsence = semesterEnrollments
|
||||
.filter(enrollment => enrollment.type === EnrollmentType.ABSENT && !enrollment.statutoryAbsence)
|
||||
.map(enrollment => enrollment.semester)
|
||||
.slice(0, allowedNonStatutoryAbsences)
|
||||
const startSemester = getCorrectSemester(dayjs(startDate))
|
||||
const endSemester = getCorrectSemester(dayjs(graduationDate))
|
||||
|
||||
const monthsToSubtract = [...semestersWithStatutoryAbsence, ...semestersWithNonStatutoryAbsence].reduce(
|
||||
(acc, semester) => acc + calculateAbsenceInMonths(semesters[semester], startDate, graduationDate),
|
||||
0
|
||||
if (!startSemester || !endSemester) {
|
||||
logger.error(`calculateDurationOfStudies(): No semesters found! start={${startDate}}, end={${graduationDate}}`)
|
||||
return -1
|
||||
}
|
||||
|
||||
const validSemesters = semesterEnrollments.filter(se => se.semester <= endSemester && se.semester >= startSemester)
|
||||
|
||||
const semestersWithStatutoryAbsence =
|
||||
validSemesters.filter(e => e.statutoryAbsence).length + (transferInfo?.usedStatutoryAbsenceTerms ?? 0)
|
||||
|
||||
const semestersWithNonStatutoryAbsence = Math.min(
|
||||
validSemesters.filter(enrollment => enrollment.type === EnrollmentType.ABSENT && !enrollment.statutoryAbsence)
|
||||
.length + (transferInfo?.usedAbsenceTerms ?? 0),
|
||||
allowedNonStatutoryAbsences
|
||||
)
|
||||
|
||||
return (
|
||||
(transferInfo?.usedTerms ?? 0) +
|
||||
endSemester -
|
||||
startSemester -
|
||||
semestersWithStatutoryAbsence -
|
||||
semestersWithNonStatutoryAbsence +
|
||||
1
|
||||
)
|
||||
return Math.round(dayjs(graduationDate).subtract(monthsToSubtract, 'months').diff(startDate, 'months', true))
|
||||
}
|
||||
|
||||
export type GraduationTimes = {
|
||||
@@ -188,7 +209,13 @@ const getGraduationTimeAndThesisWriterStats = async ({
|
||||
|
||||
const graduationDate = correctStudyRightElement.endDate
|
||||
|
||||
const duration = calculateDurationOfStudies(startDate, graduationDate, studyRight.semesterEnrollments, semesters)
|
||||
const duration = calculateDurationOfStudies(
|
||||
startDate,
|
||||
graduationDate,
|
||||
studyRight.semesterEnrollments,
|
||||
semesters,
|
||||
studyRight.transferInfo
|
||||
)
|
||||
const graduationYear = defineYear(graduationDate, isAcademicYear)
|
||||
|
||||
if (!graduationTimes[graduationYear] || !graduationTimesCombo[graduationYear]) {
|
||||
@@ -204,7 +231,7 @@ const getGraduationTimeAndThesisWriterStats = async ({
|
||||
|
||||
const goal = await getGoal(studyProgramme)
|
||||
const times: ChartGraduationTimes = { medians: [], goal }
|
||||
const comboTimes: ChartGraduationTimes = { medians: [], goal: goal + 36 }
|
||||
const comboTimes: ChartGraduationTimes = { medians: [], goal: goal + GraduationTarget.THREE_YEARS }
|
||||
|
||||
for (const year of years.toReversed()) {
|
||||
const median = getMedian(graduationTimes[year])
|
||||
@@ -219,7 +246,7 @@ const getGraduationTimeAndThesisWriterStats = async ({
|
||||
|
||||
if (doCombo) {
|
||||
const median = getMedian(graduationTimesCombo[year])
|
||||
const statistics = countTimeCategories(graduationTimesCombo[year], goal + 36)
|
||||
const statistics = countTimeCategories(graduationTimesCombo[year], goal + GraduationTarget.THREE_YEARS)
|
||||
comboTimes.medians.push({
|
||||
y: median,
|
||||
amount: graduationTimesCombo[year].length,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { DegreeProgrammeType, Phase } from '@oodikone/shared/types'
|
||||
import { programmeCodes } from '../../config/programmeCodes'
|
||||
import { ProgrammeModuleModel } from '../../models'
|
||||
import { getDegreeProgrammeType } from '../../util'
|
||||
import { GraduationTarget } from '../graduationHelpers'
|
||||
|
||||
export function getYearsArray(since: number, isAcademicYear: true, yearsCombined?: boolean): string[]
|
||||
export function getYearsArray(since: number, isAcademicYear: false, yearsCombined: true): Array<'Total' | number>
|
||||
@@ -44,15 +45,16 @@ export const getStatsBasis = (years: Array<string | number>) => {
|
||||
}
|
||||
|
||||
export const getMedian = (values: number[]) => {
|
||||
if (values.length === 0) {
|
||||
return 0
|
||||
if (!values.length) return 0
|
||||
|
||||
// compareFn mandatory
|
||||
const sorted = values.toSorted((a, b) => a - b)
|
||||
|
||||
const half = Math.floor(sorted.length / 2)
|
||||
if (sorted.length % 2) {
|
||||
return sorted[half]
|
||||
}
|
||||
values.sort((a, b) => a - b)
|
||||
const half = Math.floor(values.length / 2)
|
||||
if (values.length % 2) {
|
||||
return values[half]
|
||||
}
|
||||
return (values[half - 1] + values[half]) / 2.0
|
||||
return (sorted[half - 1] + sorted[half]) / 2.0
|
||||
}
|
||||
|
||||
export function defineYear(date: Date, isAcademicYear: true): string
|
||||
@@ -183,9 +185,9 @@ export const getGoal = async (programme?: string) => {
|
||||
return 0
|
||||
}
|
||||
if ([DegreeProgrammeType.DOCTOR, DegreeProgrammeType.LICENTIATE].includes(degreeProgrammeType)) {
|
||||
return 48
|
||||
return GraduationTarget.FOUR_YEARS
|
||||
}
|
||||
return (minimumCredits / 60) * 12
|
||||
return ((minimumCredits / 60) * 12) / 6 // 60 Credits per year divided to semesters
|
||||
}
|
||||
|
||||
export const isRelevantProgramme = (code: string) => /^(KH|MH)\d{2}_\d{3}$/.test(code) || /^T\d{6}$/.test(code)
|
||||
|
||||
@@ -14,15 +14,18 @@ export const getStudyRightsInProgramme = async (
|
||||
where.graduated = true
|
||||
}
|
||||
|
||||
const studyRights = await SISStudyRightModel.findAll({
|
||||
attributes: ['id'],
|
||||
include: {
|
||||
model: SISStudyRightElementModel,
|
||||
as: 'studyRightElements',
|
||||
attributes: [],
|
||||
where,
|
||||
},
|
||||
})
|
||||
const studyRightIds = (
|
||||
await SISStudyRightModel.findAll({
|
||||
attributes: ['id'],
|
||||
include: {
|
||||
model: SISStudyRightElementModel,
|
||||
as: 'studyRightElements',
|
||||
attributes: [],
|
||||
where,
|
||||
},
|
||||
raw: true,
|
||||
})
|
||||
).map(sr => sr.id)
|
||||
|
||||
const include: Includeable[] = [
|
||||
{
|
||||
@@ -54,11 +57,11 @@ export const getStudyRightsInProgramme = async (
|
||||
|
||||
return (
|
||||
await SISStudyRightModel.findAll({
|
||||
attributes: ['id', 'extentCode', 'semesterEnrollments', 'studentNumber'],
|
||||
attributes: ['id', 'extentCode', 'semesterEnrollments', 'studentNumber', 'transferInfo'],
|
||||
include,
|
||||
where: {
|
||||
id: {
|
||||
[Op.in]: studyRights.map(studyRight => studyRight.toJSON().id),
|
||||
[Op.in]: studyRightIds,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
SemesterEnrollment,
|
||||
} from '@oodikone/shared/types'
|
||||
import { createLocaleComparator, keysOf } from '../../util'
|
||||
import { countTimeCategories } from '../graduationHelpers'
|
||||
import { countTimeCategories, GraduationTarget } from '../graduationHelpers'
|
||||
import { getSemestersAndYears } from '../semesters'
|
||||
import { getDateOfFirstSemesterPresent } from './studyProgrammeBasics'
|
||||
import { calculateDurationOfStudies, shouldIncludeComboStats } from './studyProgrammeGraduations'
|
||||
@@ -39,7 +39,7 @@ const getGraduationTimeStats = async (
|
||||
combinedProgramme?: string
|
||||
) => {
|
||||
const goal = await getGoal(combinedProgramme ?? studyProgramme)
|
||||
const finalGraduationTimes = { goals: { basic: goal, combo: goal + 36 } }
|
||||
const finalGraduationTimes = { goals: { basic: goal, combo: goal + GraduationTarget.THREE_YEARS } }
|
||||
|
||||
const calculateGraduationTimes = (
|
||||
graduationTimes: Record<string, Record<string, number[]>>,
|
||||
@@ -276,7 +276,13 @@ const getMainStatsByTrackAndYear = async (
|
||||
|
||||
const graduationDate = studyRightElement.endDate
|
||||
|
||||
const duration = calculateDurationOfStudies(startDate, graduationDate, studyRight.semesterEnrollments, semesters)
|
||||
const duration = calculateDurationOfStudies(
|
||||
startDate,
|
||||
graduationDate,
|
||||
studyRight.semesterEnrollments,
|
||||
semesters,
|
||||
studyRight.transferInfo
|
||||
)
|
||||
|
||||
if (countAsBachelorMaster) {
|
||||
if (!graduationTimesCombo[programmeOrStudyTrack]) {
|
||||
@@ -313,7 +319,8 @@ const getMainStatsByTrackAndYear = async (
|
||||
bachelorStartDate,
|
||||
studyRight.studyRightElements.find(element => element.code === combinedProgramme)!.endDate,
|
||||
studyRight.semesterEnrollments,
|
||||
semesters
|
||||
semesters,
|
||||
studyRight.transferInfo
|
||||
)
|
||||
if (graduationTimesCombinedProgrammeCombo[combinedProgramme!][startYearInNewBachelorProgramme]) {
|
||||
graduationTimesCombinedProgrammeCombo[combinedProgramme!][startYearInNewBachelorProgramme].push(combinedDuration)
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
.dockerignore
|
||||
.eslintrc.json
|
||||
.gitignore
|
||||
*Dockerfile
|
||||
build/
|
||||
node_modules/
|
||||
@@ -28,6 +28,8 @@ ENV REACT_APP_LANGUAGE_CENTER_VIEW_ENABLED=$LANGUAGE_CENTER_VIEW_ENABLED
|
||||
ARG SIS_URL='https://sisu.helsinki.fi'
|
||||
ENV REACT_APP_SIS_URL=$SIS_URL
|
||||
|
||||
RUN npm install -g serve@^14
|
||||
|
||||
WORKDIR /opt/app-root/shared
|
||||
COPY ./shared .
|
||||
|
||||
@@ -36,7 +38,7 @@ COPY ./frontend/package*.json .
|
||||
RUN npm ci --include=dev
|
||||
COPY ./frontend .
|
||||
|
||||
RUN npm run build && npm install -g serve@^14
|
||||
RUN npm run build
|
||||
|
||||
EXPOSE 5000
|
||||
CMD ["serve", "--single", "--listen", "5000", "build"]
|
||||
|
||||
Generated
+53
-61
@@ -19,13 +19,13 @@
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"axios": "^1.9.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"highcharts": "^11.4.8",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.6",
|
||||
"lodash-es": "^4.17.23",
|
||||
"material-react-table": "^3.2.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-highcharts": "^16.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-range": "^1.10.0",
|
||||
"react-redux": "^9.2.0",
|
||||
@@ -37,7 +37,6 @@
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^18.3.23",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@types/react-highcharts": "^16.0.7",
|
||||
"@vitejs/plugin-react-swc": "^4.2.3",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.3.1"
|
||||
@@ -1864,9 +1863,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1884,9 +1880,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1904,9 +1897,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1924,9 +1914,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2104,13 +2091,6 @@
|
||||
"@types/estree": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/hast": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
|
||||
@@ -2120,16 +2100,6 @@
|
||||
"@types/unist": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/highcharts": {
|
||||
"version": "5.0.44",
|
||||
"resolved": "https://registry.npmjs.org/@types/highcharts/-/highcharts-5.0.44.tgz",
|
||||
"integrity": "sha512-Rby1gj3GbK7zzsbK/g9T9hoybUfM+x0sBIXrOlTnj25TIvuTkLend3nKRLLz2upvLJNo36GntO6KmTFoVQXWZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz",
|
||||
@@ -2202,17 +2172,6 @@
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-highcharts": {
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-highcharts/-/react-highcharts-16.0.7.tgz",
|
||||
"integrity": "sha512-xQSSWvZjZaK+j6/OBAR016KsC/cKptGbzxE4e6uSAuXpiz65PUAtSTpvT22nkvCR2DGx+5+faYwNkn2C/si4/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/highcharts": "^5.0.44",
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-transition-group": {
|
||||
"version": "4.4.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
|
||||
@@ -2539,6 +2498,30 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/echarts": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
|
||||
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0",
|
||||
"zrender": "6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/echarts-for-react": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/echarts-for-react/-/echarts-for-react-3.0.6.tgz",
|
||||
"integrity": "sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"size-sensor": "^1.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"echarts": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0",
|
||||
"react": "^15.0.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/error-ex": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
|
||||
@@ -2663,6 +2646,12 @@
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -2875,12 +2864,6 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/highcharts": {
|
||||
"version": "11.4.8",
|
||||
"resolved": "https://registry.npmjs.org/highcharts/-/highcharts-11.4.8.tgz",
|
||||
"integrity": "sha512-5Tke9LuzZszC4osaFisxLIcw7xgNGz4Sy3Jc9pRMV+ydm6sYqsPYdU8ELOgpzGNrbrRNDRBtveoR5xS3SzneEA==",
|
||||
"license": "https://www.highcharts.com/license"
|
||||
},
|
||||
"node_modules/highlight-words": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/highlight-words/-/highlight-words-2.0.0.tgz",
|
||||
@@ -3988,18 +3971,6 @@
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-highcharts": {
|
||||
"version": "16.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-highcharts/-/react-highcharts-16.1.0.tgz",
|
||||
"integrity": "sha512-CHpCSMN96lXKeTIpx8UJPsUgeNeJqh81NN6cbzraiHQBiQz2mzXa5aaIWYbMEQ2NHhAEWU5uj5DPhZY1f1Rq+A==",
|
||||
"dependencies": {
|
||||
"highcharts": "^6.0.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "~0.14 || ^15.0.0 || ^16.0.0",
|
||||
"react-dom": "~0.14 || ^15.0.0 || ^16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "19.1.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.1.tgz",
|
||||
@@ -4240,6 +4211,12 @@
|
||||
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/size-sensor": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.3.tgz",
|
||||
"integrity": "sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.5.7",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
|
||||
@@ -4356,6 +4333,12 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
|
||||
@@ -4594,6 +4577,15 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/zrender": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
|
||||
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zwitch": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
|
||||
|
||||
@@ -22,13 +22,13 @@
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"axios": "^1.9.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"highcharts": "^11.4.8",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.6",
|
||||
"lodash-es": "^4.17.23",
|
||||
"material-react-table": "^3.2.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-highcharts": "^16.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-range": "^1.10.0",
|
||||
"react-redux": "^9.2.0",
|
||||
@@ -40,16 +40,8 @@
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^18.3.23",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@types/react-highcharts": "^16.0.7",
|
||||
"@vitejs/plugin-react-swc": "^4.2.3",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"overrides": {
|
||||
"react-highcharts": {
|
||||
"highcharts": "$highcharts",
|
||||
"react": "$react",
|
||||
"react-dom": "$react-dom"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,12 +47,11 @@ export const facultyToolTips: Record<string, string> = {
|
||||
Toisin sanoen luvut vastaavat *Degree programme overview* -näkymän lukuja, kun näkymässä on valittuna *Special study rights excluded*.
|
||||
|
||||
- **Breakdown**/**Median study times**:
|
||||
- **Breakdown**: Näyttää, kuinka moni opiskelija valmistui tavoiteajassa (vihreä palkki), 12 kuukauden sisällä tavoiteajasta (keltainen palkki) tai tätä myöhemmin (punainen palkki).
|
||||
- **Median study times**: Näyttää kyseisenä kalenteri- tai lukuvuonna aloittaneiden/valmistuneiden opiskelijoiden opintojen keston **mediaanin** kuukausina. Vihreä **katkoviiva** kuvastaa tavoiteaikaa. Keltainen katkoviiva on tavoiteaika + 12 kuukautta.
|
||||
- **Breakdown**: Näyttää, kuinka moni opiskelija valmistui tavoiteajassa (vihreä palkki), kahden lukukauden sisällä tavoiteajasta (keltainen palkki) tai tätä myöhemmin (punainen palkki).
|
||||
- **Median study times**: Näyttää kyseisenä kalenteri- tai lukuvuonna aloittaneiden/valmistuneiden opiskelijoiden opintojen keston **mediaanin** lukukausina. Vihreä **katkoviiva** kuvastaa tavoiteaikaa. Keltainen katkoviiva on tavoiteaika + 2 lukukautta.
|
||||
- **Graduation year**/**Starting year**:
|
||||
- **Graduation year**: Ryhmittelee opiskelijat valmistumisvuosittain (kalenterivuosi).
|
||||
- **Starting year**: Ryhmittelee opiskelijat opiskeluoikeuden alkamisvuoden (lukuvuosi) perusteella. Bachelor + Master -kuvaajassa aloitusvuosi on aloitusvuosi kandiohjelmassa ja koulutusohjelma on maisteriohjelma, johon opiskelija on siirtynyt kandidaatiksi valmistumisen jälkeen.
|
||||
`,
|
||||
- **Starting year**: Ryhmittelee opiskelijat opiskeluoikeuden alkamisvuoden (lukuvuosi) perusteella. Bachelor + Master -kuvaajassa aloitusvuosi on aloitusvuosi kandiohjelmassa ja koulutusohjelma on maisteriohjelma, johon opiskelija on siirtynyt kandidaatiksi valmistumisen jälkeen.`,
|
||||
studentProgress: `
|
||||
Kuvaa tiedekuntaan kuuluvien eri ohjelmien opinto-oikeudella varustettujen opiskelijoiden etenemistä opintopisteissä mitattuna.
|
||||
Esimerkiksi 2021-2022 aloittaneiden opintopistejakaumaa.
|
||||
|
||||
@@ -8,6 +8,7 @@ export const filterToolTips = {
|
||||
|
||||
Jos opiskelijan opinto-oikeus ei ole ollut voimassa valitun lukukauden aikana, häntä ei lasketa mukaan mihinkään kategoriaan. Huomioi myös, että osa ilmoittautumisen laiminlyöneistä opiskelijoista on saattanut valmistua valitusta ohjelmasta.
|
||||
`,
|
||||
hetu: 'Rajaa opiskelijoita sen mukaan onko heillä suomalaista henkilötunnusta. Filters students based on if they have a Finnish personal identity code.',
|
||||
startingYear:
|
||||
'Valitse opiskelijoita sen mukaan, minä vuonna heidän ensimmäinen opinto-oikeutensa yliopistolla alkoi (ei siis ainoastaan tarkasteltavan tutkinnon oikeus). Avoimia opinto-oikeuksia ei oteta huomioon.',
|
||||
studentNumber:
|
||||
|
||||
@@ -20,11 +20,11 @@ export const populationStatisticsToolTips = {
|
||||
creditAccumulation: `
|
||||
Opiskelijoiden kumulatiiviset opintopistekertymät opiskelijakohtaisina kuvaajina.
|
||||
Kun hiiren vie kuvaajien päälle, saa näkyviin jokaista kuvaajaa vastaavan opiskelijanumeron.
|
||||
Opiskelijanumeron yhteydessä olevaa ympyrää klikkaamalla siirtyy ”student statistics”-sivulle, ja näkee valitun opiskelijan opintotiedot.
|
||||
Yksittäisen opiskelijan kuvaajaa klikkaamalla siirtyy **student statistics**-sivulle, ja näkee valitun opiskelijan opintotiedot.
|
||||
Timantin muotoinen ikoni kertoo opiskelijan valmistuneen valitusta koulutusohjelmasta kyseisellä hetkellä.
|
||||
|
||||
X-akselin skaalaa voi säätää harmaista painikkeista ”1m”, ”3m”, ”6m”, ”YTD”, ”1y” ja ”ALL” tai kuvaajan alla olevan sinisen näkymän harmaita ”kahvoja” siirtämällä.
|
||||
Y-akselin skaalaa voi säätää harmaista painikkeista ”small” ”medium” ja "large”
|
||||
X-akselin skaalaa voi säätää kuvaajan alla olevaa liukusäädintä käyttäen.
|
||||
Taulukon korkeutta voi säätää viereisistä painikkeista (small, medium, large).
|
||||
`,
|
||||
creditStatistics: `
|
||||
**Credits gained**
|
||||
@@ -58,7 +58,7 @@ export const populationStatisticsToolTips = {
|
||||
**Distribution development**
|
||||
|
||||
Pylväsdiagrammi kertoo opiskelijoiden jakauman opintopistehaarukoihin kalenterivuosittain, lukuvuosittain tai lukukausittain eriteltynä.
|
||||
Välien järjestystä pylväässä voi muuttaa valitsemalla **Stack ordering** -valikosta haluamansa järjestyksen.
|
||||
Välien järjestystä pylväässä voi muuttaa valitsemalla **Chart settings** -valikosta haluamansa järjestyksen.
|
||||
|
||||
Pylväiden osat toimivat myös suodattimina. Klikkaamalla pylvään osaa näkyviin jäävät vain kyseiset opiskelijat (esimerkiksi keväällä 2024 0–30 opintopistettä suorittaneet).
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ export const studyProgrammeToolTips: Record<string, string> = {
|
||||
|
||||
**Degree students**: Niiden opiskelijoiden opintopisteet, joilla on oikeus suorittaa alempi tai ylempi korkeakoulututkinto.
|
||||
|
||||
**Open university**: Avoimen opiskeluoikeus, ei tutkinto-opiskelija: opintosuoritukset, joiden suorituksen luokittelu on "avoimena yliopisto-opintona suoritettu" ja opiskelija ei ole tutkinto-opiskelija tai kansainvälinen vaihto-opiskelija.
|
||||
**Open university**: Avoimen opiskeluoikeus, ei tutkinto-opiskelija: opintosuoritukset, joiden suorituksen luokittelu on "avoimena yliopisto-opintona suoritettu" ja opiskelija ei ole tutkinto-opiskelija tai kansainvälinen vaihto-opiskelija. Avoimen opiskelijat ovat eroteltu suomalaisen henkilötunnuksen omaaviin / hetuttomiin.
|
||||
|
||||
**Exchange students**: Saapuvat vaihto-opiskelijat: opintosuoritukset, jotka ovat saapuvan kansainvälisen opiskelijan suorittamia. Sisältää kaikki vaihto-opiskelijan opiskeluoikeuden aikana syntyneet opintopisteet.
|
||||
|
||||
@@ -72,22 +72,22 @@ export const studyProgrammeToolTips: Record<string, string> = {
|
||||
averageGraduationTimes: `
|
||||
Yksittäinen palkki kertoo, kuinka moni opiskelija on valmistunut kyseisenä vuonna/lukuvuonna.
|
||||
|
||||
Vihreä **vaakapalkki** kuvaa tavoiteajassa valmistuneita, keltainen 12 kuukauden sisällä tavoiteajasta valmistuneita ja punainen yli 12 kuukautta tavoiteajan ylittäneitä.
|
||||
Vihreä **vaakapalkki** kuvaa tavoiteajassa valmistuneita, keltainen kahden lukukauden sisällä tavoiteajasta valmistuneita ja punainen yli 2 lukukautta tavoiteajan ylittäneitä.
|
||||
|
||||
**Breakdown**: Näyttää, kuinka moni kyseisenä kalenteri- tai lukuvuotena valmistuneista opiskelijoista valmistui tavoiteajassa, 12 kuukauden sisällä tavoiteajasta tai tätä myöhemmin.
|
||||
**Breakdown**: Näyttää, kuinka moni kyseisenä kalenteri- tai lukuvuotena valmistuneista opiskelijoista valmistui tavoiteajassa, kahden lukukauden sisällä tavoiteajasta tai tätä myöhemmin.
|
||||
|
||||
**Median time**: Näyttää kyseisenä kalenteri- tai lukuvuotena valmistuneiden opiskelijoiden opintojen keston **mediaanin** kuukausina. Vihreä **katkoviiva** kuvastaa tavoiteaikaa. Keltainen katkoviiva on tavoiteaika + 12 kuukautta.
|
||||
**Median time**: Näyttää kyseisenä kalenteri- tai lukuvuotena valmistuneiden opiskelijoiden opintojen keston **mediaanin** kuukausina. Vihreä **katkoviiva** kuvastaa tavoiteaikaa. Keltainen katkoviiva on tavoiteaika + 2 lukukautta.
|
||||
|
||||
Valmistumisajoista on vähennetty lakisääteiset poissaolot.
|
||||
`,
|
||||
averageGraduationTimesStudyTracks: `
|
||||
Yksittäinen palkki kertoo, kuinka moni kyseisenä **lukuvuotena ALOITTANEISTA** opiskelijoista on valmistunut.
|
||||
|
||||
Vihreä **vaakapalkki** kuvaa tavoiteajassa valmistuneita, keltainen 12 kuukauden sisällä tavoiteajasta valmistuneita ja punainen yli 12 kuukautta tavoiteajan ylittäneitä.
|
||||
Vihreä **vaakapalkki** kuvaa tavoiteajassa valmistuneita, keltainen kahden lukukauden sisällä tavoiteajasta valmistuneita ja punainen yli 2 lukukautta tavoiteajan ylittäneitä.
|
||||
|
||||
**Breakdown**: Näyttää, kuinka moni kyseisenä lukuvuotena aloittaneista, jo valmistuneista, opiskelijoista valmistui tavoiteajassa, 12 kuukauden sisällä tavoiteajasta tai tätä myöhemmin.
|
||||
**Breakdown**: Näyttää, kuinka moni kyseisenä lukuvuotena aloittaneista, jo valmistuneista, opiskelijoista valmistui tavoiteajassa, kahden lukukauden sisällä tavoiteajasta tai tätä myöhemmin.
|
||||
|
||||
**Median time**: Näyttää valmistuneiden opiskelijoiden opintojen keston **mediaanin** kuukausina sekä heidän prosentuaalisen osuutensa koko vuosikurssista. Vihreä **katkoviiva** kuvastaa tavoiteaikaa. Keltainen katkoviiva on tavoiteaika + 12 kuukautta.
|
||||
**Median time**: Näyttää valmistuneiden opiskelijoiden opintojen keston **mediaanin** kuukausina sekä heidän prosentuaalisen osuutensa koko vuosikurssista. Vihreä **katkoviiva** kuvastaa tavoiteaikaa. Keltainen katkoviiva on tavoiteaika + 2 lukukautta.
|
||||
|
||||
Valmistumisajoista on vähennetty lakisääteiset poissaolot.
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
|
||||
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
|
||||
import { maxBy, orderBy, range } from 'lodash-es'
|
||||
|
||||
import { GetTextIn } from '@/components/LanguagePicker/useLanguage'
|
||||
import { SemestersData } from '@/redux/semesters'
|
||||
import type { Absence } from '@/types/students'
|
||||
import type { SISStudyRightElement } from '@oodikone/shared/models'
|
||||
@@ -87,23 +88,25 @@ export const getStudentGradeMeanWeightedByCredits = (student: FormattedStudent,
|
||||
return mean
|
||||
}
|
||||
|
||||
const openUniTexts = { fi: 'Avoin yo', en: 'Open uni', sv: 'Öppna uni' } as const
|
||||
const studyModuleTexts = { en: 'Study module', fi: 'Opintokokonaisuus', sv: 'Studiehelhet' } as const
|
||||
|
||||
export const getTextInWithOpen = (
|
||||
course: { code: string; name: Name },
|
||||
getTextIn: any,
|
||||
getTextIn: GetTextIn,
|
||||
isOpenCourse: boolean,
|
||||
isStudyModuleCredit: boolean
|
||||
) => {
|
||||
if (!course) return ''
|
||||
const openUniTexts = { fi: 'Avoin yo', en: 'Open uni', sv: 'Öppna uni' }
|
||||
let courseName = getTextIn(course.name)
|
||||
if (
|
||||
isOpenCourse &&
|
||||
!Object.values(openUniTexts).some(text => courseName.toLowerCase().startsWith(text.toLowerCase()))
|
||||
!Object.values(openUniTexts).some(text => courseName?.toLowerCase().startsWith(text.toLowerCase()))
|
||||
) {
|
||||
courseName = `${getTextIn(openUniTexts)}: ${courseName}`
|
||||
}
|
||||
if (isStudyModuleCredit) {
|
||||
courseName += ` [${getTextIn({ en: 'Study module', fi: 'Opintokokonaisuus', sv: 'Studiehelhet' })}]`
|
||||
courseName += ` [${getTextIn(studyModuleTexts)}]`
|
||||
}
|
||||
courseName += ` (${course.code})`
|
||||
return courseName
|
||||
|
||||
@@ -6,7 +6,6 @@ import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider/L
|
||||
import * as Sentry from '@sentry/browser'
|
||||
import dayjs, { extend as dayjsExtend } from 'dayjs'
|
||||
import updateLocale from 'dayjs/plugin/updateLocale'
|
||||
import HighCharts from 'highcharts' // eslint-disable-line import-x/default
|
||||
import { useEffect } from 'react'
|
||||
import { initShibbolethPinger } from 'unfuck-spa-shibboleth-session'
|
||||
|
||||
@@ -22,21 +21,6 @@ import { isProduction } from '@/conf'
|
||||
import { useGetAuthorizedUserQuery } from '@/redux/auth'
|
||||
import { theme } from '@/theme'
|
||||
|
||||
// TODO: What other options should be set globally?
|
||||
HighCharts.setOptions({
|
||||
credits: {
|
||||
enabled: false,
|
||||
},
|
||||
plotOptions: {
|
||||
series: {
|
||||
animation: false,
|
||||
},
|
||||
},
|
||||
title: {
|
||||
text: '',
|
||||
},
|
||||
})
|
||||
|
||||
dayjsExtend(updateLocale)
|
||||
dayjs.updateLocale('en', {
|
||||
week: {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
|
||||
import { Section } from '@/components/Section'
|
||||
import { GraphStat } from '@/types/graphStat'
|
||||
|
||||
const colors = ['#003E65', '#1392c2', '#036415']
|
||||
|
||||
const formatNumber = (value: unknown) => {
|
||||
const numericValue = typeof value === 'number' ? value : Number(value)
|
||||
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (Number.isInteger(numericValue)) {
|
||||
return `${numericValue}`
|
||||
}
|
||||
|
||||
return `${numericValue.toFixed(1)}`
|
||||
}
|
||||
|
||||
export const BarChart = ({ id, graphStats, years }: { id: string; graphStats: GraphStat[]; years: number[] }) => {
|
||||
if (!graphStats || !id || !years) {
|
||||
return null
|
||||
}
|
||||
|
||||
const series = graphStats.map((graphSeries, index) => ({
|
||||
...graphSeries,
|
||||
type: 'bar',
|
||||
itemStyle: {
|
||||
color: colors[index % colors.length],
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
distance: 8,
|
||||
formatter: (params: { value?: unknown }) => formatNumber(params.value),
|
||||
},
|
||||
labelLayout: {
|
||||
hideOverlap: true,
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
},
|
||||
}))
|
||||
|
||||
const option = {
|
||||
legend: {},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
saveAsImage: {
|
||||
name: `oodikone_graduations_and_thesis_of_degree_programme_${id}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
top: 10,
|
||||
left: 10,
|
||||
right: 10,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: years.map(String),
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
minInterval: 1,
|
||||
},
|
||||
series,
|
||||
}
|
||||
|
||||
return (
|
||||
<Section cypress="graduated-and-thesis-writers-of-the-programme-bar-chart">
|
||||
<ReactECharts option={option} opts={{ renderer: 'svg' }} style={{ height: 450, width: '100%' }} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import Button from '@mui/material/Button'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Switch from '@mui/material/Switch'
|
||||
import dayjs from 'dayjs'
|
||||
import ReactECharts, { EChartsInstance } from 'echarts-for-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { populationStatisticsToolTips } from '@/common/InfoToolTips'
|
||||
import { DateFormat } from '@/constants/date'
|
||||
import { reformatDate } from '@/util/timeAndDate'
|
||||
import { CreditTypeCode, FormattedStudent } from '@oodikone/shared/types'
|
||||
import { InfoBox } from '../InfoBox/InfoBoxWithTooltip'
|
||||
import { getGraduationDataPoints, getGraduationsByCodes, getIncludedCourseCodesByProgrammeCodes } from './util'
|
||||
|
||||
const INITIAL_GRAPH_HEIGHT = 600
|
||||
|
||||
/**
|
||||
* Credit accumulation graph for **class statistics**
|
||||
*
|
||||
* @param students filteredStudents (size must be more than 0)
|
||||
* @param programmeCodes array of 1 or 2 (for combined programme) codes
|
||||
* @param studyPlanFilter whether hops filter is active
|
||||
* @param showBachelorAndMaster (optional)
|
||||
*/
|
||||
export const CreditAccumulationGraph = ({
|
||||
students,
|
||||
programmeCodes,
|
||||
studyPlanFilter,
|
||||
showBachelorAndMaster,
|
||||
}: {
|
||||
students: FormattedStudent[]
|
||||
programmeCodes: string[]
|
||||
studyPlanFilter: boolean
|
||||
showBachelorAndMaster?: boolean
|
||||
}) => {
|
||||
const [graphHeight, setGraphHeight] = useState(INITIAL_GRAPH_HEIGHT)
|
||||
const [cutStudyplanCredits, setCutStudyplanCredits] = useState(false)
|
||||
|
||||
// Is same for all students in a population
|
||||
const populationStudyStart = new Date(students[0].studyrightStart).getTime()
|
||||
const creditDateThreshold = useMemo(
|
||||
() =>
|
||||
studyPlanFilter && !cutStudyplanCredits
|
||||
? Math.min(...students.flatMap(s => s.courses.map(c => new Date(c.date).getTime())))
|
||||
: populationStudyStart,
|
||||
[studyPlanFilter, cutStudyplanCredits, students, populationStudyStart]
|
||||
)
|
||||
|
||||
const studyRightStartMarker = studyPlanFilter
|
||||
? [
|
||||
{
|
||||
name: 'Population study start',
|
||||
type: 'line',
|
||||
|
||||
markLine: {
|
||||
silent: true,
|
||||
symbol: 'none',
|
||||
lineStyle: {
|
||||
color: '#ff0000',
|
||||
type: 'dashed',
|
||||
width: 3,
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
formatter: 'Population study start',
|
||||
position: 'insideEndTop',
|
||||
},
|
||||
data: [{ xAxis: populationStudyStart }],
|
||||
},
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
const creditPoints = useMemo(
|
||||
() =>
|
||||
students.map(student => {
|
||||
const includedCourseCodes = getIncludedCourseCodesByProgrammeCodes(student, programmeCodes)
|
||||
|
||||
const courses = student.courses
|
||||
.filter(c => {
|
||||
const courseCode = c.course_code ?? (c as { course?: { code?: string } }).course?.code
|
||||
|
||||
return (
|
||||
dayjs(c.date).isSameOrAfter(creditDateThreshold) &&
|
||||
!c.isStudyModuleCredit &&
|
||||
(studyPlanFilter ? !!courseCode && includedCourseCodes.has(courseCode) : true)
|
||||
)
|
||||
})
|
||||
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||
|
||||
let credits = 0
|
||||
const points: number[][] = []
|
||||
const markPointsForStudent: any[] = []
|
||||
const graduations = getGraduationsByCodes(student, programmeCodes, !!showBachelorAndMaster)
|
||||
|
||||
for (const c of courses) {
|
||||
if (c.credittypecode === CreditTypeCode.PASSED || c.credittypecode === CreditTypeCode.APPROVED) {
|
||||
credits += c.credits
|
||||
points.push([new Date(c.date).getTime(), credits])
|
||||
}
|
||||
}
|
||||
|
||||
// Guard: students can graduate without having a single course in hops
|
||||
if (points.length) {
|
||||
graduations.forEach(grad => getGraduationDataPoints(points, markPointsForStudent, grad))
|
||||
points.sort((a, b) => a[0] - b[0])
|
||||
}
|
||||
|
||||
return {
|
||||
name: student.studentNumber,
|
||||
type: 'line',
|
||||
showSymbol: false,
|
||||
markPoint: markPointsForStudent.length
|
||||
? {
|
||||
symbol: 'diamond',
|
||||
symbolSize: 20,
|
||||
data: markPointsForStudent,
|
||||
}
|
||||
: undefined,
|
||||
symbolSize: 8,
|
||||
symbol: 'emptyCircle',
|
||||
data: points,
|
||||
smooth: 0.1,
|
||||
emphasis: { focus: 'series' },
|
||||
sampling: 'lttb',
|
||||
}
|
||||
}),
|
||||
[creditDateThreshold, students, studyPlanFilter, programmeCodes, showBachelorAndMaster]
|
||||
)
|
||||
|
||||
const [chartInstance, setChartInstance] = useState<EChartsInstance | null>(null)
|
||||
const lastHighlightedRef = useRef<{ seriesIndex: number; dataIndex: number } | null>(null)
|
||||
|
||||
const handleChartReady = useCallback((chart: EChartsInstance) => {
|
||||
setChartInstance(chart)
|
||||
}, [])
|
||||
|
||||
const resetHighlight = useCallback((chart: any) => {
|
||||
const last = lastHighlightedRef.current
|
||||
if (last) {
|
||||
chart.dispatchAction({ type: 'downplay', seriesIndex: last.seriesIndex, dataIndex: last.dataIndex })
|
||||
lastHighlightedRef.current = null
|
||||
}
|
||||
chart.dispatchAction({ type: 'hideTip' })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const chart = chartInstance
|
||||
if (!chart) return () => undefined
|
||||
|
||||
const zr = chart.getZr()
|
||||
|
||||
const handleMouseMove = (event: { offsetX?: number; offsetY?: number }) => {
|
||||
const offsetX = event?.offsetX
|
||||
const offsetY = event?.offsetY
|
||||
|
||||
if (offsetX == null || offsetY == null) return
|
||||
|
||||
if (!chart.containPixel('grid', [offsetX, offsetY])) {
|
||||
resetHighlight(chart)
|
||||
return
|
||||
}
|
||||
|
||||
let nearestSeriesIndex: number | null = null
|
||||
let nearestDataIndex: number | null = null
|
||||
let nearestDist2 = Number.POSITIVE_INFINITY
|
||||
|
||||
creditPoints.forEach((series, seriesIndex) => {
|
||||
if (!Array.isArray(series.data) || series.data.length === 0) return
|
||||
|
||||
series.data.forEach((point, dataIndex) => {
|
||||
if (!Array.isArray(point) || point.length < 2) return
|
||||
|
||||
const pixel = chart.convertToPixel({ xAxisIndex: 0, yAxisIndex: 0 }, point)
|
||||
if (!Array.isArray(pixel)) return
|
||||
|
||||
const dx = pixel[0] - offsetX
|
||||
const dy = pixel[1] - offsetY
|
||||
const dist2 = dx * dx + dy * dy
|
||||
|
||||
if (dist2 < nearestDist2) {
|
||||
nearestSeriesIndex = seriesIndex
|
||||
nearestDataIndex = dataIndex
|
||||
nearestDist2 = dist2
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (nearestSeriesIndex == null || nearestDataIndex == null) return
|
||||
|
||||
const next = { seriesIndex: nearestSeriesIndex, dataIndex: nearestDataIndex }
|
||||
|
||||
const last = lastHighlightedRef.current
|
||||
if (last && last.seriesIndex === next.seriesIndex && last.dataIndex === next.dataIndex) return
|
||||
|
||||
if (last) {
|
||||
chart.dispatchAction({ type: 'downplay', seriesIndex: last.seriesIndex, dataIndex: last.dataIndex })
|
||||
}
|
||||
|
||||
chart.dispatchAction({ type: 'highlight', seriesIndex: next.seriesIndex, dataIndex: next.dataIndex })
|
||||
chart.dispatchAction({ type: 'showTip', seriesIndex: next.seriesIndex, dataIndex: next.dataIndex })
|
||||
lastHighlightedRef.current = next
|
||||
}
|
||||
|
||||
const handleGlobalOut = () => resetHighlight(chart)
|
||||
const handleClick = (event: { offsetX?: number; offsetY?: number }) => {
|
||||
const offsetX = event?.offsetX
|
||||
const offsetY = event?.offsetY
|
||||
|
||||
if (offsetX == null || offsetY == null) return
|
||||
if (!chart.containPixel('grid', [offsetX, offsetY])) return
|
||||
|
||||
const last = lastHighlightedRef.current
|
||||
if (!last) return
|
||||
|
||||
const studentNumber = creditPoints[last.seriesIndex]?.name
|
||||
if (!studentNumber) return
|
||||
|
||||
window.open(`/students/${studentNumber}`, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
zr.on('mousemove', handleMouseMove)
|
||||
zr.on('globalout', handleGlobalOut)
|
||||
zr.on('click', handleClick)
|
||||
|
||||
return () => {
|
||||
zr.off('mousemove', handleMouseMove)
|
||||
zr.off('globalout', handleGlobalOut)
|
||||
zr.off('click', handleClick)
|
||||
}
|
||||
}, [chartInstance, creditPoints, resetHighlight])
|
||||
|
||||
const option = useMemo(
|
||||
() => ({
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
triggerOn: 'none',
|
||||
position: (_point: unknown, params: { value?: [number, number] }) => {
|
||||
const value = params?.value
|
||||
if (!Array.isArray(value)) return null
|
||||
|
||||
const chart = chartInstance
|
||||
if (!chart) return null
|
||||
|
||||
const pixel = chart.convertToPixel({ xAxisIndex: 0, yAxisIndex: 0 }, value)
|
||||
if (!Array.isArray(pixel)) return null
|
||||
|
||||
return [pixel[0], pixel[1]]
|
||||
},
|
||||
formatter: (params: { seriesName?: string; value?: [number, number] }) => {
|
||||
const { value } = params
|
||||
if (!Array.isArray(value)) return params.seriesName ?? ''
|
||||
|
||||
const [timestamp, credits] = value
|
||||
const dateLabel = reformatDate(new Date(timestamp), DateFormat.DISPLAY_DATE)
|
||||
return `<b>${params.seriesName ?? ''}</b><br/>${dateLabel}: ${credits} cr`
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
show: true,
|
||||
width: '100%',
|
||||
left: 'left',
|
||||
top: 55,
|
||||
},
|
||||
toolbox: {
|
||||
left: 'left',
|
||||
feature: {
|
||||
dataZoom: {
|
||||
yAxisIndex: 'none',
|
||||
},
|
||||
restore: {},
|
||||
saveAsImage: {},
|
||||
},
|
||||
},
|
||||
dataZoom: [
|
||||
{ type: 'inside', xAxisIndex: 0 },
|
||||
{ type: 'slider', xAxisIndex: 0 },
|
||||
],
|
||||
xAxis: {
|
||||
type: 'time',
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: 'Credits',
|
||||
position: 'right',
|
||||
min: 0,
|
||||
splitLine: { show: true },
|
||||
scale: true,
|
||||
},
|
||||
series: [...creditPoints, ...studyRightStartMarker],
|
||||
animation: true,
|
||||
}),
|
||||
[chartInstance, creditPoints, studyRightStartMarker]
|
||||
)
|
||||
|
||||
const GraphSizeButton = ({ height, label }) => (
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={() => setGraphHeight(height)}
|
||||
variant={height === graphHeight ? 'contained' : 'outlined'}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
justifyContent: 'space-between',
|
||||
mx: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<GraphSizeButton height={400} label="Small" />
|
||||
<GraphSizeButton height={INITIAL_GRAPH_HEIGHT} label="Medium" />
|
||||
<GraphSizeButton height={900} label="Large" />
|
||||
{studyPlanFilter ? (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch checked={cutStudyplanCredits} onChange={e => setCutStudyplanCredits(e.target.checked)} />
|
||||
}
|
||||
label="Display credits from study right start"
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
<InfoBox content={populationStatisticsToolTips.creditAccumulation} />
|
||||
</Stack>
|
||||
|
||||
<ReactECharts onChartReady={handleChartReady} option={option} style={{ height: graphHeight }} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import useTheme from '@mui/material/styles/useTheme'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { GraduationStats, Name, NameWithCode } from '@oodikone/shared/types'
|
||||
import { getOnEvents, getSeriesLabel } from './util'
|
||||
|
||||
|
||||
type GraduationBreakdownProps =
|
||||
| {
|
||||
cypress: string
|
||||
data: GraduationStats[]
|
||||
fullWidth: true
|
||||
mode: 'faculty' | 'programme' | 'study track'
|
||||
|
||||
yearLabel?: never
|
||||
handleClick?: never
|
||||
names?: never
|
||||
expandKey?: never
|
||||
}
|
||||
| {
|
||||
cypress: string
|
||||
data: GraduationStats[]
|
||||
handleClick: (seriesCategory: string) => void
|
||||
mode: 'faculty' | 'programme' | 'study track'
|
||||
yearLabel: 'Graduation year' | 'Start year'
|
||||
|
||||
names?: never
|
||||
expandKey?: never
|
||||
fullWidth?: never
|
||||
}
|
||||
| {
|
||||
cypress: string
|
||||
data: GraduationStats[]
|
||||
handleClick: (seriesCategory: string) => void
|
||||
mode: 'faculty' | 'programme' | 'study track'
|
||||
names: Record<string, Name | NameWithCode> | Record<string, string | Name> | undefined
|
||||
expandKey: string | null
|
||||
yearLabel: 'Graduation year' | 'Start year'
|
||||
|
||||
fullWidth?: never
|
||||
}
|
||||
|
||||
export const GraduationBreakdown = ({
|
||||
cypress,
|
||||
data,
|
||||
fullWidth,
|
||||
handleClick,
|
||||
mode,
|
||||
names,
|
||||
expandKey,
|
||||
yearLabel,
|
||||
}: GraduationBreakdownProps) => {
|
||||
const { language } = useLanguage()
|
||||
const theme = useTheme()
|
||||
|
||||
const categories = data.map(item => item.name)
|
||||
const codeMap: Record<string, string | undefined> = {}
|
||||
|
||||
if (expandKey) {
|
||||
for (const item of data) {
|
||||
codeMap[item.name || item.code!] = item.code
|
||||
}
|
||||
}
|
||||
|
||||
const getHeight = () => {
|
||||
if (data.length === 1) {
|
||||
return 250
|
||||
}
|
||||
const multiplier = data.length > 8 ? 80 : 110
|
||||
return data.length * multiplier + 100
|
||||
}
|
||||
|
||||
const getFacultyOrProgrammeName = (code: string) => {
|
||||
if (!names) {
|
||||
return ''
|
||||
}
|
||||
const name = names[code]
|
||||
if (typeof name === 'string') {
|
||||
return name
|
||||
}
|
||||
return name?.[language] ?? name?.fi ?? ''
|
||||
}
|
||||
|
||||
const getTooltipText = (id: string, seriesName: string, amount: number) => {
|
||||
if (expandKey) {
|
||||
const code = codeMap[id] ?? ''
|
||||
return `<b>${getFacultyOrProgrammeName(code)}</b> • ${code}<br /><b>${seriesName}</b>: ${amount}`
|
||||
}
|
||||
return `<b>${seriesName}</b>: ${amount}`
|
||||
}
|
||||
|
||||
const barWidth = data.length > 8 ? 16 : 20
|
||||
|
||||
const series = [
|
||||
{
|
||||
name: 'On time',
|
||||
color: theme.palette.graduationTimes.onTime,
|
||||
data: data.map(item => item.statistics.onTime),
|
||||
},
|
||||
{
|
||||
name: 'Max. year overtime',
|
||||
color: theme.palette.graduationTimes.yearOver,
|
||||
data: data.map(item => item.statistics.yearOver),
|
||||
},
|
||||
{
|
||||
name: 'Overtime',
|
||||
color: theme.palette.graduationTimes.wayOver,
|
||||
data: data.map(item => item.statistics.wayOver),
|
||||
},
|
||||
].map(seriesItem => ({
|
||||
type: 'bar',
|
||||
name: seriesItem.name,
|
||||
barWidth,
|
||||
data: seriesItem.data,
|
||||
itemStyle: {
|
||||
color: seriesItem.color,
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'right',
|
||||
color: '#424949',
|
||||
formatter: (params: { value?: number }) => {
|
||||
const value = typeof params.value === 'number' ? params.value : Number(params.value)
|
||||
return value ? `${value}` : ''
|
||||
},
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
},
|
||||
}))
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
text: expandKey ? `Year ${expandKey} by ${yearLabel?.toLowerCase()}` : '',
|
||||
left: 'center',
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
backgroundColor: 'white',
|
||||
formatter: (params: { name?: string; seriesName?: string; value?: number }) => {
|
||||
const id = params.name ?? ''
|
||||
const seriesName = params.seriesName ?? ''
|
||||
const amount = typeof params.value === 'number' ? params.value : Number(params.value)
|
||||
return getTooltipText(id, seriesName, amount)
|
||||
},
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
dataView: {
|
||||
readOnly: true,
|
||||
title: 'View table data',
|
||||
},
|
||||
saveAsImage: {
|
||||
name: 'Oodikone-graduation-breakdown',
|
||||
},
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
top: 60,
|
||||
left: 10,
|
||||
right: 20,
|
||||
bottom: 60,
|
||||
containLabel: true,
|
||||
},
|
||||
legend: {
|
||||
bottom: 0,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
minInterval: 1,
|
||||
name: 'Number of students',
|
||||
nameLocation: 'middle',
|
||||
nameGap: 32,
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: categories,
|
||||
name: getSeriesLabel(expandKey, yearLabel, mode),
|
||||
nameLocation: 'start',
|
||||
nameGap: 12,
|
||||
inverse: true,
|
||||
z: 10,
|
||||
},
|
||||
series,
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Box data-cy={cypress} width={{ sm: '100%', md: fullWidth ? '100%' : '50%' }}>
|
||||
<ReactECharts onEvents={getOnEvents(handleClick)} option={option} opts={{ renderer: 'svg' }} style={{ height: getHeight() }} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
|
||||
import { Section } from '@/components/Section'
|
||||
import { theme } from '@/theme'
|
||||
import { GraphStat } from '@/types/graphStat'
|
||||
|
||||
export const LineGraph = ({
|
||||
cypress,
|
||||
exportFileName,
|
||||
graphStats,
|
||||
years,
|
||||
}: {
|
||||
cypress: string
|
||||
exportFileName: string
|
||||
graphStats: GraphStat[]
|
||||
years: number[]
|
||||
}) => {
|
||||
if (!graphStats) {
|
||||
return null
|
||||
}
|
||||
|
||||
const series = graphStats.map((graphStat, index) => {
|
||||
const color = theme.palette.graphColors[index % theme.palette.graphColors.length]
|
||||
|
||||
return {
|
||||
...graphStat,
|
||||
type: 'line',
|
||||
showSymbol: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 7,
|
||||
itemStyle: {
|
||||
color,
|
||||
},
|
||||
lineStyle: {
|
||||
color,
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const option = {
|
||||
legend: {},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
saveAsImage: {
|
||||
name: exportFileName,
|
||||
},
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
left: 10,
|
||||
top: 10,
|
||||
right: 10,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: years.map(String),
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
minInterval: 1,
|
||||
},
|
||||
series,
|
||||
}
|
||||
|
||||
return (
|
||||
<Section cypress={`${cypress}-line-graph`}>
|
||||
<ReactECharts option={option} opts={{ renderer: 'svg' }} style={{ height: 450 }} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import useTheme from '@mui/material/styles/useTheme'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import {
|
||||
FacultyClassSizes,
|
||||
GraduationStatistics,
|
||||
GraduationStats,
|
||||
Name,
|
||||
NameWithCode,
|
||||
ProgrammeClassSizes,
|
||||
} from '@oodikone/shared/types'
|
||||
import { getOnEvents, getSeriesLabel } from './util'
|
||||
|
||||
type GraduationPoint = GraduationStats & { color?: string; realGoal?: number }
|
||||
|
||||
export const MedianGraduations = ({
|
||||
classSizes,
|
||||
cypress,
|
||||
data,
|
||||
expandKey,
|
||||
fullWidth,
|
||||
goal,
|
||||
goalExceptions,
|
||||
handleClick,
|
||||
level,
|
||||
mode,
|
||||
names,
|
||||
title,
|
||||
yearLabel,
|
||||
}: {
|
||||
classSizes?: FacultyClassSizes['programmes'] | ProgrammeClassSizes['studyTracks' | 'programme']
|
||||
cypress: string
|
||||
data: GraduationStats[]
|
||||
expandKey?: string
|
||||
facultyGraph?: boolean
|
||||
fullWidth?: true,
|
||||
goal: number
|
||||
goalExceptions?: Record<string, number> | { needed: boolean }
|
||||
handleClick: (seriesCategory: string) => void | undefined
|
||||
level?: string
|
||||
mode: 'faculty' | 'programme' | 'study track'
|
||||
names?: Record<string, Name | NameWithCode>
|
||||
title: string
|
||||
yearLabel: 'Graduation year' | 'Start year'
|
||||
}) => {
|
||||
const { language } = useLanguage()
|
||||
const theme = useTheme()
|
||||
|
||||
let modData: GraduationPoint[] | null = null
|
||||
if (!expandKey && goalExceptions?.needed && level && ['master', 'bcMsCombo'].includes(level)) {
|
||||
// change colors for longer medicine goal times
|
||||
modData = JSON.parse(JSON.stringify(data))
|
||||
if (modData) {
|
||||
for (const item of modData) {
|
||||
if (item.code && Object.keys(goalExceptions).includes(item.code)) {
|
||||
const realGoal = goal + goalExceptions[item.code]
|
||||
if (item.median <= realGoal) {
|
||||
item.color = theme.palette.graduationTimes.onTime
|
||||
} else if (item.median <= realGoal + 12) {
|
||||
item.color = theme.palette.graduationTimes.yearOver
|
||||
} else {
|
||||
item.color = theme.palette.graduationTimes.wayOver
|
||||
}
|
||||
item.realGoal = realGoal
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const maxValue = data.reduce((max, { median }) => {
|
||||
return median > max ? median : max
|
||||
}, goal * 2)
|
||||
|
||||
const getClassSize = (category: string) => {
|
||||
return expandKey ? classSizes?.[category]?.[expandKey ?? ''] : classSizes?.[category]
|
||||
}
|
||||
|
||||
const getPercentage = (amount: number, category: string) => {
|
||||
const percent = Math.round((amount / getClassSize(category)) * 100 * 10) / 10
|
||||
return Number.isNaN(percent) ? 0 : percent
|
||||
}
|
||||
|
||||
const getDataLabel = (amount: number, category: string) => {
|
||||
if (yearLabel === 'Start year' && title === 'Bachelor study right') {
|
||||
return `${amount} graduated (${getPercentage(amount, category)} % of class)`
|
||||
}
|
||||
return `${amount} graduated`
|
||||
}
|
||||
|
||||
const getHeight = () => {
|
||||
const multiplier = data.length > 8 ? 35 : 55
|
||||
return data.length * multiplier + 100
|
||||
}
|
||||
|
||||
const getFacultyOrProgrammeName = (code: string) => {
|
||||
if (!names) {
|
||||
return ''
|
||||
}
|
||||
return names[code]?.[language] ?? names[code]?.fi
|
||||
}
|
||||
|
||||
const getTooltipText = (
|
||||
name: string,
|
||||
code: string,
|
||||
amount: number,
|
||||
median: number,
|
||||
statistics: GraduationStatistics | undefined,
|
||||
realGoal: number | undefined
|
||||
) => {
|
||||
const sortingText =
|
||||
yearLabel === 'Start year'
|
||||
? `<b>From class of ${!expandKey ? name : expandKey}, ${amount}/${getClassSize(code)} students have graduated</b>`
|
||||
: `<b>${amount} students graduated in year ${!expandKey ? name : expandKey}</b>`
|
||||
const timeText = `<br />${sortingText}<br /><b>median study time: ${median} semesters</b><br />`
|
||||
const statisticsText = `<br />${statistics?.onTime} graduated on time<br />${statistics?.yearOver} graduated max year overtime<br />${statistics?.wayOver} graduated over year late`
|
||||
|
||||
if (expandKey) {
|
||||
const goalText = realGoal ? `<br /><p><b>** Exceptional goal time: ${realGoal} semesters **</b></p>` : ''
|
||||
return `<b>${getFacultyOrProgrammeName(code)}</b> • ${code}<br />${timeText}${statisticsText}${goalText}`
|
||||
}
|
||||
return `${timeText}${statisticsText}`
|
||||
}
|
||||
|
||||
const resolveBarColor = (median: number, override?: string) => {
|
||||
if (override) {
|
||||
return override
|
||||
}
|
||||
if (median <= goal) {
|
||||
return theme.palette.graduationTimes.onTime
|
||||
}
|
||||
if (median <= goal + 2) {
|
||||
return theme.palette.graduationTimes.yearOver
|
||||
}
|
||||
return theme.palette.graduationTimes.wayOver
|
||||
}
|
||||
|
||||
const dataWithOverrides: GraduationPoint[] = modData ?? data
|
||||
const seriesData = dataWithOverrides.map(item => ({
|
||||
value: item.median,
|
||||
name: item.name.toString(),
|
||||
code: item.code ?? item.name,
|
||||
amount: item.amount,
|
||||
statistics: item.statistics,
|
||||
realGoal: item.realGoal,
|
||||
itemStyle: {
|
||||
color: resolveBarColor(item.median, item.color),
|
||||
},
|
||||
}))
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
text: expandKey ? `Year ${expandKey} by ${yearLabel.toLowerCase()}` : '',
|
||||
left: 'center',
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
backgroundColor: 'white',
|
||||
confine: true,
|
||||
formatter: (params: {
|
||||
name?: string
|
||||
value?: number
|
||||
data?: {
|
||||
code?: string
|
||||
amount?: number
|
||||
statistics?: GraduationStatistics
|
||||
realGoal?: number
|
||||
name?: string
|
||||
}
|
||||
}) => {
|
||||
const chartData = params.data
|
||||
if (!chartData) {
|
||||
return ''
|
||||
}
|
||||
const name = params.name ?? chartData.name ?? ''
|
||||
const code = chartData.code ?? name
|
||||
const amount = chartData.amount ?? 0
|
||||
const median = typeof params.value === 'number' ? params.value : Number(params.value)
|
||||
return getTooltipText(name, code, amount, median, chartData.statistics, chartData.realGoal)
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
top: 60,
|
||||
left: 15,
|
||||
right: 20,
|
||||
bottom: 30,
|
||||
containLabel: true,
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
saveAsImage: {
|
||||
name: 'Oodikone-graduation-medians',
|
||||
},
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: maxValue,
|
||||
name: 'Graduation time (semesters)',
|
||||
nameLocation: 'middle',
|
||||
nameGap: 32,
|
||||
minInterval: 1,
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: seriesData.map(item => item.name),
|
||||
name: getSeriesLabel(expandKey, yearLabel, mode),
|
||||
nameLocation: 'start',
|
||||
nameGap: 12,
|
||||
inverse: true,
|
||||
z: 10,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: seriesData,
|
||||
label: {
|
||||
show: true,
|
||||
position: 'insideLeft',
|
||||
color: '#424949',
|
||||
formatter: (params: { name?: string; data?: { amount?: number; code?: string } }) => {
|
||||
const amount = params.data?.amount ?? 0
|
||||
const code = params.data?.code ?? params.name ?? ''
|
||||
return getDataLabel(amount, code)
|
||||
},
|
||||
},
|
||||
labelLayout: {
|
||||
hideOverlap: true,
|
||||
},
|
||||
markLine: {
|
||||
symbol: 'none',
|
||||
label: {
|
||||
show: false,
|
||||
},
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
},
|
||||
data: [
|
||||
{
|
||||
xAxis: goal,
|
||||
lineStyle: {
|
||||
color: theme.palette.graduationTimes.onTime,
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
{
|
||||
xAxis: goal + 2,
|
||||
lineStyle: {
|
||||
color: theme.palette.graduationTimes.yearOver,
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
return (
|
||||
<Box data-cy={cypress} width={{ sm: '100%', md: fullWidth ? '100%' : '50%' }}>
|
||||
<ReactECharts onEvents={getOnEvents(handleClick)} option={option} opts={{ renderer: 'svg' }} style={{ height: getHeight() }} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Section } from '@/components/Section'
|
||||
import { generateGradientColors } from '@/util/color'
|
||||
|
||||
const getNumericValue = (value: unknown) => {
|
||||
const numericValue = typeof value === 'number' ? value : Number(value)
|
||||
return Number.isFinite(numericValue) ? numericValue : 0
|
||||
}
|
||||
|
||||
const formatPercentage = (value: number) => `${value.toFixed(1)}%`
|
||||
|
||||
type ProgressData = {
|
||||
id: string
|
||||
stats: {
|
||||
data: number[]
|
||||
name: string
|
||||
}[]
|
||||
years: string[]
|
||||
}
|
||||
|
||||
export const ProgressBarChart = ({ cypress, data }: { cypress: string; data: ProgressData }) => {
|
||||
const [legendSelection, setLegendSelection] = useState<Record<string, boolean>>({})
|
||||
const stats = data.stats ?? []
|
||||
const seriesNamesKey = stats.map(stat => stat.name).join('|')
|
||||
|
||||
useEffect(() => {
|
||||
setLegendSelection({})
|
||||
}, [data.id, seriesNamesKey])
|
||||
|
||||
const seriesCount = stats.length
|
||||
const gradientColors = seriesCount > 1 ? generateGradientColors(seriesCount - 1) : []
|
||||
const colors = [...gradientColors, '#ddd']
|
||||
|
||||
const pointCount = Math.max(data.years?.length ?? 0, ...stats.map(series => series.data.length))
|
||||
const categories = Array.from({ length: pointCount }, (_, index) => data.years?.[index] ?? '')
|
||||
|
||||
const isSeriesVisible = (name: string) => legendSelection[name] !== false
|
||||
|
||||
const totals = Array.from({ length: pointCount }, (_, index) =>
|
||||
stats.reduce((sum, currentSeries) => {
|
||||
if (!isSeriesVisible(currentSeries.name)) {
|
||||
return sum
|
||||
}
|
||||
|
||||
return sum + getNumericValue(currentSeries.data[index])
|
||||
}, 0)
|
||||
)
|
||||
|
||||
const series = stats.map((graphSeries, index) => ({
|
||||
name: graphSeries.name,
|
||||
type: 'bar',
|
||||
stack: 'total',
|
||||
itemStyle: {
|
||||
color: colors[index],
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'inside',
|
||||
formatter: (params: { data?: { percentage?: number } }) => {
|
||||
const percentage = getNumericValue(params.data?.percentage)
|
||||
|
||||
return formatPercentage(percentage)
|
||||
},
|
||||
},
|
||||
labelLayout: {
|
||||
hideOverlap: true,
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'none',
|
||||
},
|
||||
data: Array.from({ length: pointCount }, (_, pointIndex) => {
|
||||
const rawValue = getNumericValue(graphSeries.data[pointIndex])
|
||||
const total = totals[pointIndex]
|
||||
const percentage = isSeriesVisible(graphSeries.name) && total ? (rawValue / total) * 100 : 0
|
||||
|
||||
return {
|
||||
value: percentage,
|
||||
rawValue,
|
||||
percentage,
|
||||
}
|
||||
}),
|
||||
}))
|
||||
|
||||
const onEvents = useMemo(
|
||||
() => ({
|
||||
legendselectchanged: (params: { selected?: Record<string, boolean> }) => {
|
||||
if (!params.selected) {
|
||||
return
|
||||
}
|
||||
|
||||
setLegendSelection(params.selected)
|
||||
},
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const option = {
|
||||
legend: {
|
||||
selected: Object.keys(legendSelection).length ? legendSelection : undefined,
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: (params: { data?: { rawValue?: number; percentage?: number }; seriesName?: string }) => {
|
||||
const rawValue = getNumericValue(params.data?.rawValue)
|
||||
const percentage = getNumericValue(params.data?.percentage)
|
||||
|
||||
return `<b>${params.seriesName ?? ''}: ${rawValue}</b><br/>${formatPercentage(percentage)}`
|
||||
},
|
||||
},
|
||||
toolbox: {
|
||||
top: -10,
|
||||
feature: {
|
||||
saveAsImage: {
|
||||
name: `oodikone_progress_of_students_in_${data.id}_by_starting_year`,
|
||||
},
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
left: 10,
|
||||
top: 48,
|
||||
right: 10,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: categories,
|
||||
z: 10,
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
inverse: true,
|
||||
min: 0,
|
||||
max: 100,
|
||||
axisLabel: {
|
||||
formatter: '{value}%',
|
||||
},
|
||||
},
|
||||
series,
|
||||
}
|
||||
|
||||
return (
|
||||
<Section cypress={`${cypress}-progress-bar-chart`}>
|
||||
<ReactECharts notMerge onEvents={onEvents} option={option} opts={{ renderer: 'svg' }} style={{ height: 500 }} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
|
||||
import { Section } from '@/components/Section'
|
||||
import { theme } from '@/theme'
|
||||
|
||||
const formatNumber = (value: unknown) => {
|
||||
const numericValue = typeof value === 'number' ? value : Number(value)
|
||||
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (Number.isInteger(numericValue)) {
|
||||
return `${numericValue}`
|
||||
}
|
||||
|
||||
return `${numericValue.toFixed(1)}`
|
||||
}
|
||||
|
||||
const getNumericValue = (value: unknown) => {
|
||||
const numericValue = typeof value === 'number' ? value : Number(value)
|
||||
return Number.isFinite(numericValue) ? numericValue : 0
|
||||
}
|
||||
|
||||
export const StackedBarChart = ({
|
||||
cypress,
|
||||
data,
|
||||
labels,
|
||||
exportFileName = 'chart',
|
||||
}: {
|
||||
cypress: string
|
||||
data?: { data: number[]; name: string }[]
|
||||
labels?: string[]
|
||||
exportFileName: string
|
||||
}) => {
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
|
||||
const series = data.map((graphSeries, index) => {
|
||||
const color = theme.palette.graphColors[index % theme.palette.graphColors.length]
|
||||
|
||||
return {
|
||||
...graphSeries,
|
||||
type: 'bar',
|
||||
stack: 'total',
|
||||
itemStyle: {
|
||||
color,
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
formatter: (params: { value?: unknown }) => formatNumber(params.value),
|
||||
},
|
||||
labelLayout: {
|
||||
hideOverlap: true,
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const option = {
|
||||
legend: {},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: (params: { seriesName?: string; value?: unknown; dataIndex?: number }) => {
|
||||
const dataIndex = params.dataIndex ?? 0
|
||||
const value = getNumericValue(params.value)
|
||||
|
||||
const total = data.reduce((sum, currentSeries) => sum + getNumericValue(currentSeries.data[dataIndex]), 0)
|
||||
const percentage = total ? ((value / total) * 100).toFixed(1) : '0.0'
|
||||
|
||||
return `<b>${params.seriesName ?? ''}: ${percentage} %</b>`
|
||||
},
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
saveAsImage: {
|
||||
name: exportFileName,
|
||||
},
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
left: 10,
|
||||
top: 10,
|
||||
right: 10,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: labels,
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
minInterval: 1,
|
||||
},
|
||||
series,
|
||||
}
|
||||
|
||||
return (
|
||||
<Section cypress={`${cypress}-stacked-bar-chart`}>
|
||||
<ReactECharts option={option} opts={{ renderer: 'svg' }} style={{ height: 450 }} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
import Button from '@mui/material/Button'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { getStudyRightElementTargetDates } from '@/common'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { DateFormat } from '@/constants/date'
|
||||
import type { Absence } from '@/types/students'
|
||||
import { reformatDate } from '@/util/timeAndDate'
|
||||
import { CreditTypeCode } from '@oodikone/shared/types'
|
||||
import type { StudentPageStudent } from '@oodikone/shared/types/studentData'
|
||||
|
||||
import {
|
||||
createGoalLineData,
|
||||
filterCoursesByStudyPlan,
|
||||
getGraduationDataPoints,
|
||||
getStudentTransferMarkers,
|
||||
} from './util'
|
||||
|
||||
const INITIAL_GRAPH_HEIGHT = 700
|
||||
|
||||
type StudentCreditAccumulationProps = {
|
||||
student: StudentPageStudent
|
||||
absences: Absence[]
|
||||
startDate: Date
|
||||
endDate: number
|
||||
studyRightId?: string
|
||||
selectedStudyPlan?: {
|
||||
programme_code: string
|
||||
included_courses: string[]
|
||||
}
|
||||
}
|
||||
|
||||
const GraphSizeButton = ({
|
||||
graphHeight,
|
||||
height,
|
||||
label,
|
||||
onSelect,
|
||||
}: {
|
||||
graphHeight: number
|
||||
height: number
|
||||
label: string
|
||||
onSelect: (height: number) => void
|
||||
}) => (
|
||||
<Button color="primary" onClick={() => onSelect(height)} variant={height === graphHeight ? 'contained' : 'outlined'}>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
|
||||
export const StudentCreditAccumulation = ({
|
||||
student,
|
||||
absences,
|
||||
startDate,
|
||||
endDate,
|
||||
studyRightId,
|
||||
selectedStudyPlan,
|
||||
}: StudentCreditAccumulationProps) => {
|
||||
const { getTextIn } = useLanguage()
|
||||
const [graphHeight, setGraphHeight] = useState(INITIAL_GRAPH_HEIGHT)
|
||||
const hasStudyRights = student.studyRights.length > 0
|
||||
|
||||
const selectedStudyRight = useMemo(
|
||||
() => (studyRightId ? student.studyRights.find(studyRight => studyRight.id === studyRightId) : undefined),
|
||||
[student.studyRights, studyRightId]
|
||||
)
|
||||
|
||||
const earliestStudyRightStart = useMemo(() => {
|
||||
const scope = selectedStudyRight ? [selectedStudyRight] : student.studyRights
|
||||
const times = scope
|
||||
.map(studyRight => new Date(studyRight.startDate).getTime())
|
||||
.filter(time => Number.isFinite(time))
|
||||
|
||||
return times.length ? Math.min(...times) : undefined
|
||||
}, [selectedStudyRight, student.studyRights])
|
||||
|
||||
const pointData = useMemo(() => {
|
||||
const filteredCourses = filterCoursesByStudyPlan(student.courses, selectedStudyPlan)
|
||||
.filter(course => new Date(course.date).getTime() <= Date.now())
|
||||
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||
|
||||
let credits = 0
|
||||
const points: Array<{ value: [number, number]; tooltipHtml: string }> = []
|
||||
const graduationMarkPoints: Array<{ coord: number[]; name: string }> = []
|
||||
|
||||
for (const course of filteredCourses) {
|
||||
if (![CreditTypeCode.PASSED, CreditTypeCode.APPROVED].includes(course.credittypecode)) continue
|
||||
|
||||
const gainedCredits = !course.isStudyModuleCredit ? course.credits : 0
|
||||
credits += gainedCredits
|
||||
const x = new Date(course.date).getTime()
|
||||
const y = credits
|
||||
const courseCode = course.course?.code
|
||||
const courseName = course.course?.name ? getTextIn(course.course.name) : ''
|
||||
const tooltipHtml = [
|
||||
`<strong>${courseName}${courseCode ? ` (${courseCode})` : ''}</strong>`,
|
||||
`Credits: ${course.credits}`,
|
||||
`Grade: ${course.grade || '-'}`,
|
||||
`Date: ${reformatDate(course.date, DateFormat.DISPLAY_DATE)}`,
|
||||
course.isStudyModuleCredit ? '[Study Module]' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('<br/>')
|
||||
|
||||
points.push({
|
||||
value: [x, y],
|
||||
tooltipHtml,
|
||||
})
|
||||
}
|
||||
|
||||
const graduationData = (selectedStudyRight ? [selectedStudyRight] : student.studyRights)
|
||||
.flatMap(studyRight => studyRight.studyRightElements)
|
||||
.filter(element => !!element.graduated)
|
||||
.map(element => ({
|
||||
date: new Date(element.endDate).getTime(),
|
||||
label: `Graduated from ${getTextIn(element.name) ?? ''}`.trim(),
|
||||
}))
|
||||
.sort((a, b) => a.date - b.date)
|
||||
|
||||
if (points.length) {
|
||||
const numericalPoints = points.map(point => [point.value[0], point.value[1]])
|
||||
graduationData.forEach(graduation =>
|
||||
getGraduationDataPoints(numericalPoints, graduationMarkPoints, graduation.date)
|
||||
)
|
||||
|
||||
const pointLookup = new Map(points.map(point => [`${point.value[0]}-${point.value[1]}`, point]))
|
||||
const pointsWithGraduationNodes = numericalPoints.map(value => {
|
||||
const key = `${value[0]}-${value[1]}`
|
||||
return pointLookup.get(key) ?? { value: [value[0], value[1]] as [number, number], tooltipHtml: '' }
|
||||
})
|
||||
|
||||
return { points: pointsWithGraduationNodes, graduationMarkPoints, graduationData }
|
||||
}
|
||||
|
||||
return { points, graduationMarkPoints, graduationData }
|
||||
}, [student, selectedStudyPlan, selectedStudyRight, getTextIn])
|
||||
|
||||
const goalSeries = useMemo(() => {
|
||||
const studyRightElement = selectedStudyRight?.studyRightElements.find(
|
||||
element => element.code === selectedStudyPlan?.programme_code
|
||||
)
|
||||
|
||||
const [targetStart, targetEnd] = getStudyRightElementTargetDates(studyRightElement, absences)
|
||||
const graphStart = (targetStart ? new Date(targetStart) : new Date(earliestStudyRightStart ?? startDate)).getTime()
|
||||
const graphEnd = (targetEnd ? new Date(targetEnd) : new Date(endDate)).getTime()
|
||||
|
||||
const filteredAbsences = absences.filter(absence => {
|
||||
const absenceStart = new Date(absence.startDate).getTime()
|
||||
const absenceEnd = new Date(absence.endDate).getTime()
|
||||
return absenceStart <= graphEnd && absenceEnd >= graphStart
|
||||
})
|
||||
|
||||
return createGoalLineData(graphStart, graphEnd, filteredAbsences)
|
||||
}, [selectedStudyRight, selectedStudyPlan, absences, startDate, endDate, earliestStudyRightStart])
|
||||
|
||||
const xAxisMin = useMemo(() => {
|
||||
const defaultMin = new Date(startDate).getTime()
|
||||
if (!hasStudyRights) return defaultMin
|
||||
|
||||
const goalStart = goalSeries.points[0]?.[0]
|
||||
if (typeof goalStart !== 'number' || Number.isNaN(goalStart)) return defaultMin
|
||||
|
||||
return Math.min(defaultMin, goalStart)
|
||||
}, [startDate, hasStudyRights, goalSeries.points])
|
||||
|
||||
const transferMarkers = useMemo(
|
||||
() => getStudentTransferMarkers(student, text => getTextIn(text as never)),
|
||||
[student, getTextIn]
|
||||
)
|
||||
|
||||
const yAxisMax = useMemo(() => {
|
||||
const studentMax = pointData.points.reduce((max, point) => Math.max(max, point.value[1]), 0)
|
||||
const goalMax = hasStudyRights
|
||||
? goalSeries.points.reduce((max, [, credits]) => (Number.isFinite(credits) ? Math.max(max, credits) : max), 0)
|
||||
: 0
|
||||
|
||||
const maxCredits = Math.max(studentMax, goalMax)
|
||||
if (maxCredits <= 0) return undefined
|
||||
|
||||
return Math.ceil(maxCredits / 10) * 10
|
||||
}, [pointData.points, goalSeries.points, hasStudyRights])
|
||||
|
||||
const tooltipsByTimestamp = useMemo(() => {
|
||||
const byTimestamp = new Map<number, string[]>()
|
||||
|
||||
for (const point of pointData.points) {
|
||||
if (!point.tooltipHtml) continue
|
||||
const timestamp = point.value[0]
|
||||
const existing = byTimestamp.get(timestamp) ?? []
|
||||
existing.push(point.tooltipHtml)
|
||||
byTimestamp.set(timestamp, existing)
|
||||
}
|
||||
|
||||
return byTimestamp
|
||||
}, [pointData.points])
|
||||
|
||||
const option = useMemo(
|
||||
() => ({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'line',
|
||||
snap: true,
|
||||
},
|
||||
formatter: (
|
||||
rawParams:
|
||||
| {
|
||||
seriesName?: string
|
||||
data?: { tooltipHtml?: string }
|
||||
value?: [number, number]
|
||||
}
|
||||
| Array<{
|
||||
seriesName?: string
|
||||
data?: { tooltipHtml?: string }
|
||||
value?: [number, number]
|
||||
}>
|
||||
) => {
|
||||
const params = Array.isArray(rawParams) ? rawParams : [rawParams]
|
||||
|
||||
const timestamp = params.find(param => param.value)?.value?.[0]
|
||||
if (typeof timestamp === 'number') {
|
||||
const tooltipsAtTimestamp = tooltipsByTimestamp.get(timestamp)
|
||||
if (tooltipsAtTimestamp?.length) {
|
||||
return tooltipsAtTimestamp.join('<br/><br/>')
|
||||
}
|
||||
}
|
||||
|
||||
const studentPoint = params.find(param => param.data?.tooltipHtml)
|
||||
if (studentPoint?.data?.tooltipHtml) return studentPoint.data.tooltipHtml
|
||||
|
||||
return ''
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
show: true,
|
||||
width: '100%',
|
||||
left: 'left',
|
||||
top: 55,
|
||||
},
|
||||
toolbox: {
|
||||
left: 'left',
|
||||
feature: {
|
||||
dataZoom: {
|
||||
yAxisIndex: 'none',
|
||||
},
|
||||
restore: {},
|
||||
saveAsImage: {},
|
||||
},
|
||||
},
|
||||
dataZoom: [
|
||||
{ type: 'inside', xAxisIndex: [0, 1], filterMode: 'none' },
|
||||
{ type: 'slider', xAxisIndex: [0, 1], filterMode: 'none' },
|
||||
],
|
||||
xAxis: [
|
||||
{
|
||||
type: 'time',
|
||||
min: xAxisMin,
|
||||
max: endDate,
|
||||
},
|
||||
{
|
||||
type: 'time',
|
||||
min: xAxisMin,
|
||||
max: endDate,
|
||||
show: false,
|
||||
axisPointer: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: 'Credits',
|
||||
position: 'right',
|
||||
min: 0,
|
||||
max: yAxisMax,
|
||||
splitLine: { show: true },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: student.studentNumber,
|
||||
type: 'line',
|
||||
showSymbol: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 8,
|
||||
smooth: 0.1,
|
||||
itemStyle: {
|
||||
borderWidth: 2,
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'self',
|
||||
scale: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 11,
|
||||
itemStyle: {
|
||||
color: '#1976d2',
|
||||
borderColor: '#1976d2',
|
||||
},
|
||||
},
|
||||
data: pointData.points,
|
||||
markPoint: pointData.graduationMarkPoints.length
|
||||
? {
|
||||
symbol: 'diamond',
|
||||
symbolSize: 20,
|
||||
data: pointData.graduationMarkPoints,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
...(hasStudyRights
|
||||
? [
|
||||
{
|
||||
name: 'Goal',
|
||||
type: 'line',
|
||||
xAxisIndex: 1,
|
||||
data: goalSeries.points,
|
||||
showSymbol: false,
|
||||
silent: true,
|
||||
tooltip: { show: false },
|
||||
lineStyle: {
|
||||
color: '#99d8c4',
|
||||
width: 2,
|
||||
},
|
||||
markArea: {
|
||||
silent: true,
|
||||
label: {
|
||||
show: true,
|
||||
position: 'insideTop',
|
||||
color: '#1f2937',
|
||||
fontSize: 11,
|
||||
padding: [2, 4],
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.65)',
|
||||
borderRadius: 3,
|
||||
},
|
||||
data: goalSeries.markAreas.map(area => [
|
||||
{
|
||||
name: area.label,
|
||||
xAxis: area.range[0].xAxis,
|
||||
itemStyle: {
|
||||
color: `${area.color}33`,
|
||||
borderColor: area.color,
|
||||
borderType: area.borderType,
|
||||
},
|
||||
},
|
||||
{ xAxis: area.range[1].xAxis },
|
||||
]),
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: 'Graduations and transfers',
|
||||
type: 'line',
|
||||
data: [],
|
||||
silent: true,
|
||||
markLine: {
|
||||
silent: true,
|
||||
symbol: 'none',
|
||||
data: [
|
||||
...transferMarkers.map(marker => ({
|
||||
xAxis: marker.value,
|
||||
label: {
|
||||
formatter: marker.label,
|
||||
position: 'insideEndTop',
|
||||
},
|
||||
lineStyle: { color: '#cbd128', type: 'dashed', width: 2 },
|
||||
})),
|
||||
...pointData.graduationMarkPoints.map(marker => ({
|
||||
xAxis: marker.coord[0],
|
||||
label: {
|
||||
formatter:
|
||||
pointData.graduationData.find(graduation => graduation.date === marker.coord[0])?.label ??
|
||||
'Graduation',
|
||||
position: 'insideEndTop',
|
||||
},
|
||||
lineStyle: { color: '#a333c8', type: 'dashed', width: 2 },
|
||||
})),
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
animation: true,
|
||||
}),
|
||||
[
|
||||
student.studentNumber,
|
||||
pointData,
|
||||
goalSeries,
|
||||
transferMarkers,
|
||||
endDate,
|
||||
tooltipsByTimestamp,
|
||||
hasStudyRights,
|
||||
xAxisMin,
|
||||
yAxisMax,
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack direction="row" spacing={1} sx={{ mx: '0.5rem' }}>
|
||||
<GraphSizeButton graphHeight={graphHeight} height={400} label="Small" onSelect={setGraphHeight} />
|
||||
<GraphSizeButton
|
||||
graphHeight={graphHeight}
|
||||
height={INITIAL_GRAPH_HEIGHT}
|
||||
label="Medium"
|
||||
onSelect={setGraphHeight}
|
||||
/>
|
||||
<GraphSizeButton graphHeight={graphHeight} height={1000} label="Large" onSelect={setGraphHeight} />
|
||||
</Stack>
|
||||
<ReactECharts option={option} style={{ height: graphHeight }} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import dayjs, { extend as dayjsExtend } from 'dayjs'
|
||||
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
|
||||
import chunk from 'lodash-es/chunk'
|
||||
import groupBy from 'lodash-es/groupBy'
|
||||
import { useMemo } from 'react'
|
||||
import { DateFormat } from '@/constants/date'
|
||||
import { SemestersData, useGetSemestersQuery } from '@/redux/semesters'
|
||||
import { reformatDate } from '@/util/timeAndDate'
|
||||
import { StudentPageStudent } from '@oodikone/shared/types/studentData'
|
||||
import { type GetTextIn, useLanguage } from '../LanguagePicker/useLanguage'
|
||||
|
||||
dayjsExtend(isSameOrAfter)
|
||||
|
||||
type SeriesData = {
|
||||
x: number
|
||||
y: number
|
||||
name?: string
|
||||
}
|
||||
|
||||
const semesterChunkify = (courses, semesters, getTextIn: GetTextIn): SeriesData[] => {
|
||||
const semesterChunks = courses.reduce((acc, curr) => {
|
||||
const semester = semesters.find(
|
||||
semester => dayjs(curr.date).isSameOrAfter(semester.startdate) && dayjs(curr.date).isBefore(semester.enddate)
|
||||
)
|
||||
const semesterData = acc.find(data => data.semester === semester.name)
|
||||
if (semesterData) {
|
||||
semesterData.data.push(curr)
|
||||
} else {
|
||||
acc.push({ data: [curr], semester: semester.name })
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
const semesterMeans = semesterChunks.reduce((acc, curr) => {
|
||||
const gradeSum = curr.data.reduce((a, b) => a + b.grade * b.credits, 0)
|
||||
const creditSum = curr.data.reduce((a, b) => a + b.credits, 0)
|
||||
if (curr.data.length > 0)
|
||||
acc.push({
|
||||
name: getTextIn(curr.semester),
|
||||
y: gradeSum / creditSum,
|
||||
x: new Date(curr.data[curr.data.length - 1].date).getTime(),
|
||||
})
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
return semesterMeans
|
||||
}
|
||||
|
||||
const gradeMeanSeries = (
|
||||
student: StudentPageStudent,
|
||||
chunksize: number,
|
||||
semesters: SemestersData['semesters'] | undefined,
|
||||
getTextIn: ReturnType<typeof useLanguage>['getTextIn']
|
||||
) => {
|
||||
const filteredCourses = student.courses.filter(
|
||||
course => !Number.isNaN(Number(course.grade)) && !course.isStudyModuleCredit && course.passed
|
||||
)
|
||||
|
||||
const coursesGroupedByDate = groupBy(filteredCourses, 'date')
|
||||
|
||||
const gradesAndMeans = Object.values(coursesGroupedByDate).reduce(
|
||||
(acc, courses) => {
|
||||
for (const course of courses) {
|
||||
acc.grades.push({
|
||||
grade: Number(course.grade),
|
||||
date: course.date.toString(),
|
||||
credits: course.credits,
|
||||
})
|
||||
// Weighted average: each grade is multiplied by the amount of credits the course is worth
|
||||
acc.totalGradeSum += Number(course.grade) * course.credits
|
||||
acc.totalCredits += course.credits
|
||||
}
|
||||
acc.mean.push({ y: acc.totalGradeSum / acc.totalCredits, x: new Date(courses[0].date).getTime() })
|
||||
return acc
|
||||
},
|
||||
{
|
||||
grades: [] as Array<{ grade: number; date: string; credits: number }>,
|
||||
mean: [] as SeriesData[],
|
||||
totalGradeSum: 0,
|
||||
totalCredits: 0,
|
||||
}
|
||||
)
|
||||
|
||||
const size = Number(chunksize) ? chunksize : 3
|
||||
const chunks = chunk(gradesAndMeans.grades, size)
|
||||
|
||||
const groupMeans = chunks.reduce<SeriesData[]>((acc, curr) => {
|
||||
const gradeSum = curr.reduce((a, b) => a + b.grade * b.credits, 0)
|
||||
const creditSum = curr.reduce((a, b) => a + b.credits, 0)
|
||||
if (curr.length > 0)
|
||||
acc.push({
|
||||
name: `${curr.length} courses between ${reformatDate(curr[0].date, DateFormat.DISPLAY_DATE)} and ${reformatDate(curr[curr.length - 1].date, DateFormat.DISPLAY_DATE)}`,
|
||||
y: gradeSum / creditSum,
|
||||
x: new Date(curr[curr.length - 1].date).getTime(),
|
||||
})
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
const semesterMeans = semesterChunkify(gradesAndMeans.grades, Object.values(semesters ?? {}), getTextIn)
|
||||
|
||||
return {
|
||||
totalMeans: [{ data: gradesAndMeans.mean }],
|
||||
groupMeans: [{ data: groupMeans }],
|
||||
semesterMeans: [{ data: semesterMeans }],
|
||||
}
|
||||
}
|
||||
|
||||
export const StudentGradeGraphs = ({
|
||||
student,
|
||||
graphMode,
|
||||
groupSize,
|
||||
}: {
|
||||
student: StudentPageStudent
|
||||
graphMode: 'total' | 'group' | 'semester'
|
||||
groupSize: number
|
||||
}) => {
|
||||
const { getTextIn } = useLanguage()
|
||||
const { data: semesters } = useGetSemestersQuery()
|
||||
const { semesters: allSemesters } = semesters ?? { semesters: {} }
|
||||
|
||||
const { totalMeans, groupMeans, semesterMeans } = useMemo(
|
||||
() => gradeMeanSeries(student, groupSize, allSemesters, getTextIn),
|
||||
[student, groupSize, allSemesters, getTextIn]
|
||||
)
|
||||
|
||||
const selectedSeries: SeriesData[] =
|
||||
graphMode === 'group' ? groupMeans[0].data : graphMode === 'semester' ? semesterMeans[0].data : totalMeans[0].data
|
||||
|
||||
const gradeOption = useMemo(
|
||||
() => ({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'line',
|
||||
snap: true,
|
||||
},
|
||||
formatter: (
|
||||
rawParams:
|
||||
| { data?: { x?: number; y?: number; name?: string } }
|
||||
| Array<{ data?: { x?: number; y?: number; name?: string } }>
|
||||
) => {
|
||||
const params = Array.isArray(rawParams) ? rawParams : [rawParams]
|
||||
const point = params.find(param => param.data)
|
||||
const data = point?.data
|
||||
if (!data) return ''
|
||||
const date = data.x ? reformatDate(new Date(data.x), DateFormat.DISPLAY_DATE) : ''
|
||||
const description = data.name ? `<br/>${data.name}` : ''
|
||||
return `${data.y?.toFixed(2) ?? ''}${date ? `<br/>${date}` : ''}${description}`
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
show: true,
|
||||
width: '100%',
|
||||
left: 'left',
|
||||
top: 55,
|
||||
},
|
||||
toolbox: {
|
||||
left: 'left',
|
||||
feature: {
|
||||
dataZoom: {
|
||||
yAxisIndex: 'none',
|
||||
},
|
||||
restore: {},
|
||||
saveAsImage: {},
|
||||
},
|
||||
},
|
||||
dataZoom: [
|
||||
{ type: 'inside', xAxisIndex: 0, filterMode: 'none' },
|
||||
{ type: 'slider', xAxisIndex: 0, filterMode: 'none' },
|
||||
],
|
||||
xAxis: {
|
||||
type: 'time',
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
position: 'right',
|
||||
min: 1,
|
||||
max: 5.1,
|
||||
splitLine: { show: true },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'line',
|
||||
smooth: 0.1,
|
||||
showSymbol: false,
|
||||
data: selectedSeries.map(point => ({
|
||||
value: [point.x, point.y],
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
name: point.name,
|
||||
})),
|
||||
},
|
||||
],
|
||||
animation: true,
|
||||
}),
|
||||
[selectedSeries]
|
||||
)
|
||||
|
||||
return <ReactECharts option={gradeOption} style={{ height: 420 }} />
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { FormattedStudent, Phase } from '@oodikone/shared/types'
|
||||
|
||||
type CourseWithCode = {
|
||||
course_code?: string
|
||||
course?: {
|
||||
code?: string
|
||||
}
|
||||
}
|
||||
|
||||
type StudyPlanLike = {
|
||||
included_courses: string[]
|
||||
}
|
||||
|
||||
export const getOnEvents = (handleClick: ((category: string) => void) | undefined) => (
|
||||
handleClick ? {
|
||||
click: (params: { name: string }) => {
|
||||
handleClick(params.name)
|
||||
},
|
||||
} : undefined
|
||||
)
|
||||
|
||||
const resolveCourseCode = (course: CourseWithCode) => course.course_code ?? course.course?.code
|
||||
|
||||
const toMillis = (date: Date | string) => new Date(date).getTime()
|
||||
|
||||
|
||||
/** expandKey is only provided to the expanded instance of a chart */
|
||||
export const getSeriesLabel = (expandKey: string | undefined | null, yearLabel: string | undefined, mode: string) => (
|
||||
expandKey ? `${mode.charAt(0).toUpperCase()}${mode.slice(1)}` : yearLabel
|
||||
)
|
||||
|
||||
const getEnrollmentLabel = (enrollmentType: number, statutoryAbsence: boolean) => {
|
||||
if (enrollmentType === 2) return statutoryAbsence ? 'Absent (statutory)' : 'Absent'
|
||||
if (enrollmentType === 3) return 'No enrollment'
|
||||
return 'No study right'
|
||||
}
|
||||
|
||||
const getTermLabel = (startDate: number) => {
|
||||
const date = new Date(startDate)
|
||||
const month = date.getMonth()
|
||||
const year = date.getFullYear()
|
||||
const season = month >= 7 ? 'Fall' : 'Spring'
|
||||
return `${season} ${year}`
|
||||
}
|
||||
|
||||
const getTermRangeLabel = (startDate: number, endTermDate: number) => {
|
||||
const startTerm = getTermLabel(startDate)
|
||||
const endTerm = getTermLabel(endTermDate)
|
||||
if (startTerm === endTerm) return startTerm
|
||||
return `${startTerm} - ${endTerm}`
|
||||
}
|
||||
|
||||
export const getIncludedCourseCodesByProgrammeCodes = (
|
||||
student: FormattedStudent,
|
||||
programmeCodes: string[]
|
||||
): Set<string> => {
|
||||
if (!programmeCodes) return new Set()
|
||||
const studyplans = student.studyplans.filter(studyplan => programmeCodes.includes(studyplan.programme_code))
|
||||
return new Set(studyplans.flatMap(studyplan => studyplan.included_courses))
|
||||
}
|
||||
|
||||
export const getGraduationsByCodes = (
|
||||
student: FormattedStudent,
|
||||
programmeCodes: string[],
|
||||
showBachelorAndMaster: boolean
|
||||
): number[] => {
|
||||
if (!programmeCodes) return []
|
||||
if (showBachelorAndMaster) {
|
||||
return (
|
||||
student.studyRights
|
||||
.find(studyRight => studyRight.studyRightElements.some(element => programmeCodes.includes(element.code)))
|
||||
?.studyRightElements.filter(({ graduated }) => !!graduated) ?? []
|
||||
)
|
||||
.map(({ endDate }) => new Date(endDate).getTime())
|
||||
.sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
return student.studyRights
|
||||
.flatMap(studyRight => studyRight.studyRightElements)
|
||||
.filter(({ graduated, code }) => !!graduated && programmeCodes.includes(code))
|
||||
.map(({ endDate }) => new Date(endDate).getTime())
|
||||
.sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
export const filterCoursesByStudyPlan = <TCourse extends CourseWithCode>(
|
||||
courses: TCourse[],
|
||||
studyPlan?: StudyPlanLike
|
||||
): TCourse[] => {
|
||||
if (!studyPlan) return courses
|
||||
return courses.filter(course => {
|
||||
const code = resolveCourseCode(course)
|
||||
return !!code && studyPlan.included_courses.includes(code)
|
||||
})
|
||||
}
|
||||
|
||||
export const createGoalLineData = (
|
||||
graphStartDate: number,
|
||||
graphEndDate: number,
|
||||
absences: {
|
||||
startDate: Date | string
|
||||
endDate: Date | string
|
||||
enrollmenttype: number
|
||||
statutoryAbsence: boolean
|
||||
}[]
|
||||
) => {
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
const getColor = (type: number) => {
|
||||
if (type === 2) return '#ffb300'
|
||||
if (type === 3) return '#e53935'
|
||||
return '#96d7c3'
|
||||
}
|
||||
|
||||
let totalAbsenceYears = 0
|
||||
const now = Date.now()
|
||||
type ClippedAbsence = {
|
||||
start: number
|
||||
end: number
|
||||
enrollmenttype: number
|
||||
statutoryAbsence: boolean
|
||||
labelStart: number
|
||||
labelEnd: number
|
||||
}
|
||||
|
||||
const relevantAbsences = absences
|
||||
.map(absence => {
|
||||
const start = toMillis(absence.startDate)
|
||||
const end = toMillis(absence.endDate)
|
||||
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) return null
|
||||
|
||||
const clippedStart = Math.max(start, graphStartDate)
|
||||
const clippedEndByGraph = Math.min(end, graphEndDate)
|
||||
const clippedEnd = absence.enrollmenttype === 3 ? Math.min(clippedEndByGraph, now) : clippedEndByGraph
|
||||
|
||||
if (clippedEnd <= clippedStart) return null
|
||||
|
||||
return {
|
||||
start: clippedStart,
|
||||
end: clippedEnd,
|
||||
enrollmenttype: absence.enrollmenttype,
|
||||
statutoryAbsence: absence.statutoryAbsence,
|
||||
labelStart: clippedStart,
|
||||
labelEnd: clippedStart,
|
||||
}
|
||||
})
|
||||
.filter((absence): absence is ClippedAbsence => absence !== null)
|
||||
.sort((a, b) => a.start - b.start)
|
||||
|
||||
const mergedAbsences = relevantAbsences.reduce<ClippedAbsence[]>((merged, absence) => {
|
||||
const previous = merged[merged.length - 1]
|
||||
|
||||
if (
|
||||
previous &&
|
||||
previous.enrollmenttype === absence.enrollmenttype &&
|
||||
previous.statutoryAbsence === absence.statutoryAbsence &&
|
||||
absence.start <= previous.end + DAY_MS
|
||||
) {
|
||||
previous.end = Math.max(previous.end, absence.end)
|
||||
previous.labelEnd = absence.labelEnd
|
||||
return merged
|
||||
}
|
||||
|
||||
merged.push({ ...absence })
|
||||
return merged
|
||||
}, [])
|
||||
|
||||
const points: number[][] = [[graphStartDate, 0]]
|
||||
const markAreas: Array<{
|
||||
range: [{ xAxis: number }, { xAxis: number }]
|
||||
color: string
|
||||
borderType: 'solid' | 'dashed'
|
||||
label: string
|
||||
}> = []
|
||||
|
||||
for (const absence of mergedAbsences) {
|
||||
const absenceStart = absence.start
|
||||
const absenceEnd = absence.end
|
||||
const targetCreditsBeforeAbsence =
|
||||
((absenceStart - graphStartDate) / (365.25 * 24 * 60 * 60 * 1000) - totalAbsenceYears) * 60
|
||||
|
||||
const absenceInYears = (absenceEnd - absenceStart) / (365.25 * 24 * 60 * 60 * 1000)
|
||||
totalAbsenceYears += absenceInYears
|
||||
|
||||
points.push([absenceStart, targetCreditsBeforeAbsence])
|
||||
points.push([absenceEnd, targetCreditsBeforeAbsence])
|
||||
const enrollmentLabel = getEnrollmentLabel(absence.enrollmenttype, absence.statutoryAbsence)
|
||||
const termLabel = getTermRangeLabel(absence.labelStart, absence.labelEnd)
|
||||
const text = `${enrollmentLabel}\n(${termLabel})`
|
||||
|
||||
markAreas.push({
|
||||
range: [{ xAxis: absenceStart }, { xAxis: absenceEnd }],
|
||||
color: getColor(absence.enrollmenttype),
|
||||
borderType: absence.statutoryAbsence ? 'dashed' : 'solid',
|
||||
label: text,
|
||||
})
|
||||
}
|
||||
|
||||
const yearsFromStart = (graphEndDate - graphStartDate) / (365.25 * 24 * 60 * 60 * 1000)
|
||||
const endingCredits = (yearsFromStart - totalAbsenceYears) * 60
|
||||
points.push([graphEndDate, endingCredits])
|
||||
points.sort((a, b) => a[0] - b[0])
|
||||
|
||||
return { points, markAreas }
|
||||
}
|
||||
|
||||
export const getStudentTransferMarkers = (
|
||||
student: Pick<FormattedStudent, 'studyRights'>,
|
||||
getTextIn: (text: unknown) => string | null | undefined
|
||||
): Array<{ value: number; label: string }> =>
|
||||
student.studyRights.reduce<Array<{ value: number; label: string }>>((transfers, studyRight) => {
|
||||
const phase1Programmes = studyRight.studyRightElements.filter(element => element.phase === Phase.ANY)
|
||||
const phase2Programmes = studyRight.studyRightElements.filter(element => element.phase === Phase.MASTER)
|
||||
|
||||
for (const programmes of [phase1Programmes, phase2Programmes]) {
|
||||
for (const element of programmes.slice(0, -1)) {
|
||||
transfers.push({
|
||||
value: new Date(element.startDate).getTime(),
|
||||
label: `Transfer to ${getTextIn(element.name) ?? ''}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return transfers
|
||||
}, [])
|
||||
|
||||
/** HACK: markPoint (in this case the graduation marker) needs a datapoint to attach to. This creates a synthetic data point
|
||||
to the time of graduation and attaches the markPoint to it (remember to sort the datapoints again) */
|
||||
export const getGraduationDataPoints = (
|
||||
dataPoints: number[][],
|
||||
markPoints: { coord: number[]; name: string }[],
|
||||
graduationDate: number
|
||||
) => {
|
||||
dataPoints.sort((a, b) => a[0] - b[0])
|
||||
|
||||
const index = dataPoints.findIndex(point => point[0] > graduationDate)
|
||||
let y: number
|
||||
if (index <= 0) {
|
||||
y = graduationDate > (dataPoints.at(0)?.[0] ?? 0) ? dataPoints[dataPoints.length - 1][1] : dataPoints[0][1]
|
||||
} else {
|
||||
const yBefore = dataPoints[index - 1][1]
|
||||
const yAfter = dataPoints[index][1]
|
||||
y = Math.round((yBefore + yAfter) / 2)
|
||||
}
|
||||
|
||||
const coord = [graduationDate, y]
|
||||
dataPoints.push(coord)
|
||||
dataPoints.sort((a, b) => a[0] - b[0])
|
||||
markPoints.push({ coord, name: `Graduation` })
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
courseFilter,
|
||||
creditsEarnedFilter,
|
||||
genderFilter,
|
||||
hetuFilter,
|
||||
gradeFilter,
|
||||
programmeFilter,
|
||||
startYearAtUniFilter,
|
||||
@@ -208,6 +209,7 @@ export const CoursePopulation = () => {
|
||||
ageFilter(),
|
||||
courseFilter({ courses: population?.coursestatistics.courses }),
|
||||
creditsEarnedFilter(),
|
||||
hetuFilter(),
|
||||
startYearAtUniFilter(),
|
||||
programmeFilter({
|
||||
additionalModes: [
|
||||
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
import Card from '@mui/material/Card'
|
||||
import CardContent from '@mui/material/CardContent'
|
||||
import CardHeader from '@mui/material/CardHeader'
|
||||
|
||||
const Tile = ({ children }) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', fontWeight: 'bold' }}>{children}</div>
|
||||
)
|
||||
const TileValue = ({ children }) => <div style={{ fontSize: '1.25em' }}>{children}</div>
|
||||
|
||||
export const CreditGraphTooltip = ({ credits, date, grade, isStudyModuleCredit, courseCode, courseName }) => {
|
||||
const title = `${courseName} (${courseCode})`
|
||||
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
<CardHeader
|
||||
content={`${title}${isStudyModuleCredit ? ' [Study Module]' : ''}`}
|
||||
style={{ whiteSpace: 'normal' }}
|
||||
/>
|
||||
<CardContent style={{ display: 'grid', grid: '1fr 1fr 1fr', gridRowGap: '0.25rem' }}>
|
||||
<Tile>
|
||||
Credits <TileValue>{credits}</TileValue>
|
||||
</Tile>
|
||||
<Tile>
|
||||
Grade <TileValue>{grade}</TileValue>
|
||||
</Tile>
|
||||
<Tile>
|
||||
Date <TileValue>{date}</TileValue>
|
||||
</Tile>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,569 +0,0 @@
|
||||
// @ts-check
|
||||
import Button from '@mui/material/Button'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
import Switch from '@mui/material/Switch'
|
||||
import dayjs from 'dayjs'
|
||||
import exportData from 'highcharts/modules/export-data'
|
||||
import exporting from 'highcharts/modules/exporting'
|
||||
import { flatten } from 'lodash-es'
|
||||
import { useRef, useState } from 'react'
|
||||
import { renderToString } from 'react-dom/server'
|
||||
import ReactHighstock from 'react-highcharts/ReactHighstock'
|
||||
|
||||
import { getStudyRightElementTargetDates } from '@/common'
|
||||
import { InfoBox } from '@/components/InfoBox/InfoBoxWithTooltip'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { DateFormat } from '@/constants/date'
|
||||
import { useDeepMemo } from '@/hooks/deepMemo'
|
||||
import { reformatDate } from '@/util/timeAndDate'
|
||||
import { CreditGraphTooltip } from './CreditGraphTooltip'
|
||||
|
||||
exporting(ReactHighstock.Highcharts)
|
||||
exportData(ReactHighstock.Highcharts)
|
||||
|
||||
const SINGLE_GRAPH_GOAL_SERIES_NAME = 'Goal'
|
||||
|
||||
const createGraphOptions = ({
|
||||
singleStudent,
|
||||
seriesData,
|
||||
graphHeight,
|
||||
tooltipFormatter,
|
||||
onPointClicked,
|
||||
startDate,
|
||||
endDate,
|
||||
graduations,
|
||||
transfers,
|
||||
studyRightStartLine,
|
||||
}) => {
|
||||
const tooltip = {
|
||||
shared: false,
|
||||
split: false,
|
||||
}
|
||||
|
||||
if (singleStudent) {
|
||||
Object.assign(tooltip, {
|
||||
formatter() {
|
||||
return tooltipFormatter(this)
|
||||
},
|
||||
useHTML: true,
|
||||
style: {
|
||||
all: 'unset',
|
||||
display: 'none',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
plotOptions: {
|
||||
series: {
|
||||
findNearestPointBy: 'xy',
|
||||
point: {
|
||||
events: {
|
||||
click() {
|
||||
onPointClicked(this)
|
||||
},
|
||||
},
|
||||
cursor: 'pointer',
|
||||
},
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
title: { text: 'Credits' },
|
||||
min: 0,
|
||||
max: undefined,
|
||||
},
|
||||
xAxis: {
|
||||
ordinal: false,
|
||||
min: startDate,
|
||||
max: endDate,
|
||||
plotLines: graduations
|
||||
.map(graduation => ({
|
||||
value: graduation.value,
|
||||
color: '#a333c8',
|
||||
width: 2,
|
||||
dashStyle: 'dash',
|
||||
label: {
|
||||
text: `Graduation ${graduation.studyright}`,
|
||||
fontSize: 30,
|
||||
},
|
||||
}))
|
||||
.concat(
|
||||
studyRightStartLine.map(value => ({
|
||||
value,
|
||||
color: 'red',
|
||||
width: 3,
|
||||
dashStyle: 'dash',
|
||||
label: {
|
||||
text: 'Population study start',
|
||||
fontSize: 30,
|
||||
},
|
||||
}))
|
||||
)
|
||||
.concat(
|
||||
transfers.map(transfer => ({
|
||||
value: transfer.value,
|
||||
color: '#cbd128',
|
||||
width: 2,
|
||||
dashStyle: 'dash',
|
||||
label: {
|
||||
text: `Transfer ${transfer.studyright}`,
|
||||
fontSize: 30,
|
||||
},
|
||||
}))
|
||||
),
|
||||
},
|
||||
rangeSelector: {
|
||||
selected: 5, // Set default zoom to 'All'
|
||||
},
|
||||
series: seriesData,
|
||||
chart: {
|
||||
height: graphHeight,
|
||||
},
|
||||
tooltip,
|
||||
accessibility: {
|
||||
// Accessibility module doesn't seem to work with Highstock, at least for large amounts of data
|
||||
enabled: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const filterCoursesByStudyPlan = (plan, courses) => {
|
||||
if (!plan) {
|
||||
return courses
|
||||
}
|
||||
return courses.filter(({ course, course_code }) => {
|
||||
if (!course?.code && !course_code) {
|
||||
return false
|
||||
}
|
||||
return plan.included_courses.includes(course_code ?? course.code)
|
||||
})
|
||||
}
|
||||
|
||||
const filterCoursesByDate = (courses, date) => courses.filter(course => dayjs(course.date).isSameOrAfter(dayjs(date)))
|
||||
|
||||
const filterCourses = (student, singleStudent, byStudyPlanOfCode, cutStudyPlanCredits, startDate, studyrightid) => {
|
||||
if (byStudyPlanOfCode && cutStudyPlanCredits)
|
||||
return filterCoursesByDate(
|
||||
filterCoursesByStudyPlan(
|
||||
student.studyplans.find(
|
||||
plan => plan.programme_code === byStudyPlanOfCode && plan.sis_study_right_id === studyrightid
|
||||
),
|
||||
student.courses
|
||||
),
|
||||
student.studyrightStart
|
||||
)
|
||||
if (byStudyPlanOfCode)
|
||||
return filterCoursesByStudyPlan(
|
||||
student.studyplans.find(
|
||||
plan => plan.programme_code === byStudyPlanOfCode && plan.sis_study_right_id === studyrightid
|
||||
),
|
||||
student.courses
|
||||
)
|
||||
if (singleStudent) return student.courses
|
||||
return filterCoursesByDate(student.courses, startDate)
|
||||
}
|
||||
|
||||
const singleStudentTooltipFormatter = (point, student, getTextIn) => {
|
||||
const targetCourse = student.courses.find(
|
||||
({ course, date }) => point.key === course.code && point.x === new Date(date).getTime()
|
||||
)
|
||||
|
||||
if (!targetCourse.course) return null
|
||||
|
||||
const payload = {
|
||||
...targetCourse,
|
||||
courseCode: targetCourse.course.code,
|
||||
courseName: getTextIn(targetCourse.course.name),
|
||||
date: reformatDate(targetCourse.date, DateFormat.DISPLAY_DATE),
|
||||
}
|
||||
|
||||
return renderToString(<CreditGraphTooltip {...payload} />)
|
||||
}
|
||||
|
||||
const createGoalSeries = (graphStartDate, graphEndDate, absences) => {
|
||||
const getColor = type => {
|
||||
if (type === 2) return '#ffb300'
|
||||
if (type === 3) return '#e53935'
|
||||
return '#96d7c3'
|
||||
}
|
||||
|
||||
const absenceIsBetweenGraphDates = (startDate, endDate) =>
|
||||
(graphStartDate <= startDate && startDate <= graphEndDate) || (graphStartDate <= endDate && endDate <= graphEndDate)
|
||||
|
||||
let totalAbsenceYears = 0
|
||||
const absencePoints = absences
|
||||
.filter(a =>
|
||||
a.enrollmenttype === 3
|
||||
? absenceIsBetweenGraphDates(a.startDate, a.endDate) && a.startDate <= new Date()
|
||||
: absenceIsBetweenGraphDates(a.startDate, a.endDate)
|
||||
)
|
||||
.reduce((res, { startDate, endDate, enrollmenttype, statutoryAbsence }) => {
|
||||
const targetCreditsBeforeAbsence =
|
||||
(dayjs(startDate).diff(dayjs(graphStartDate), 'years', true) - totalAbsenceYears) * 60
|
||||
|
||||
const absenceInYears = dayjs(endDate).diff(dayjs(startDate), 'years', true)
|
||||
totalAbsenceYears += absenceInYears
|
||||
|
||||
res.push([startDate.getTime(), targetCreditsBeforeAbsence, null, null])
|
||||
res.push([endDate.getTime(), targetCreditsBeforeAbsence, enrollmenttype, statutoryAbsence])
|
||||
return res
|
||||
}, [])
|
||||
|
||||
const zones = []
|
||||
for (const [start, , enrollmenttype, statutoryAbsence] of absencePoints) {
|
||||
zones.push({
|
||||
value: start,
|
||||
color: getColor(enrollmenttype),
|
||||
dashStyle: statutoryAbsence ? 'Dash' : 'Solid',
|
||||
})
|
||||
}
|
||||
zones.push({ color: '#96d7c3' })
|
||||
|
||||
const yearsFromStart = dayjs(graphEndDate).diff(dayjs(graphStartDate), 'years', true)
|
||||
const endingCredits = (yearsFromStart - totalAbsenceYears) * 60
|
||||
const data = [[graphStartDate, 0], ...absencePoints, [graphEndDate, endingCredits]].sort((a, b) => a[0] - b[0])
|
||||
|
||||
return {
|
||||
name: SINGLE_GRAPH_GOAL_SERIES_NAME,
|
||||
data,
|
||||
enableMouseTracking: false,
|
||||
zones,
|
||||
zoneAxis: 'x',
|
||||
}
|
||||
}
|
||||
|
||||
const filterGraduations = (student, selectedStudyRight, getTextIn) => {
|
||||
const graduations = student.studyRights
|
||||
.flatMap(studyRight => studyRight.studyRightElements)
|
||||
.filter(element => element.graduated)
|
||||
if (!selectedStudyRight)
|
||||
return graduations.map(({ endDate, name }) => ({
|
||||
value: new Date(endDate).getTime(),
|
||||
studyright: getTextIn(name),
|
||||
}))
|
||||
const selectedGraduation = graduations.filter(({ studyRightId }) => studyRightId === selectedStudyRight.id)
|
||||
if (selectedGraduation.length === 0) return []
|
||||
return selectedGraduation.map(({ endDate, name }) => ({
|
||||
value: new Date(endDate).getTime(),
|
||||
studyright: getTextIn(name),
|
||||
}))
|
||||
}
|
||||
|
||||
const addGraduation = (points, graduationDate, notFirst) => {
|
||||
const index = points.findIndex(point => point.x > graduationDate)
|
||||
let graduationY
|
||||
if (index <= 0) {
|
||||
graduationY = graduationDate > points[0].x ? points[points.length - 1].y : points[0].y
|
||||
} else {
|
||||
const yBefore = points[index - 1].y
|
||||
const yAfter = points[index].y
|
||||
graduationY = Math.round((yBefore + yAfter) / 2)
|
||||
}
|
||||
|
||||
const marker = {
|
||||
radius: 10,
|
||||
symbol: 'diamond',
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
const masterMarker = {
|
||||
radius: 12,
|
||||
symbol: 'circle',
|
||||
lineWidth: 2,
|
||||
lineColor: '#ffffff',
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
points.push({
|
||||
x: graduationDate,
|
||||
y: graduationY,
|
||||
marker: notFirst ? masterMarker : marker,
|
||||
})
|
||||
points.sort((a, b) => a.x - b.x)
|
||||
}
|
||||
|
||||
const findGraduationsByCodes = (student, programmeCodes, showBachelorAndMaster) => {
|
||||
if (showBachelorAndMaster) {
|
||||
return (
|
||||
student.studyRights
|
||||
.find(studyRight => studyRight.studyRightElements.some(element => programmeCodes.includes(element.code)))
|
||||
?.studyRightElements.filter(({ graduated }) => graduated === true) ?? []
|
||||
)
|
||||
.map(({ endDate }) => new Date(endDate).getTime())
|
||||
.sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
return student.studyRights
|
||||
.flatMap(studyRight => studyRight.studyRightElements)
|
||||
.filter(({ graduated, code }) => graduated === true && programmeCodes.includes(code))
|
||||
.map(({ endDate }) => new Date(endDate).getTime())
|
||||
.sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
const createStudentCreditLines = (
|
||||
students,
|
||||
singleStudent,
|
||||
studyRightId,
|
||||
studyPlanFilterIsActive,
|
||||
cutStudyPlanCredits,
|
||||
programmeCodes,
|
||||
selectedStudyPlan,
|
||||
showBachelorAndMaster
|
||||
) =>
|
||||
students.map(student => {
|
||||
const { studyrightStart } = student
|
||||
const code = selectedStudyPlan?.programme_code
|
||||
const studyPlanProgrammeCode = singleStudent
|
||||
? code
|
||||
: studyPlanFilterIsActive && programmeCodes?.length > 0 && programmeCodes[0]
|
||||
|
||||
const { points } = filterCourses(
|
||||
student,
|
||||
singleStudent,
|
||||
studyPlanProgrammeCode,
|
||||
cutStudyPlanCredits,
|
||||
studyrightStart,
|
||||
studyRightId
|
||||
)
|
||||
.filter(({ date }) => new Date(date) <= new Date())
|
||||
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||
.reduce(
|
||||
(acc, course) => {
|
||||
// Only include passed and transferred courses
|
||||
if (![4, 9].includes(course.credittypecode)) return acc
|
||||
|
||||
const gainedCredits = !course.isStudyModuleCredit ? course.credits : 0
|
||||
|
||||
const point = {
|
||||
x: new Date(course.date).getTime(),
|
||||
y: acc.credits + gainedCredits,
|
||||
}
|
||||
|
||||
if (singleStudent) point.name = course.course.code
|
||||
|
||||
acc.points.push(point)
|
||||
|
||||
return {
|
||||
...acc,
|
||||
credits: acc.credits + gainedCredits,
|
||||
}
|
||||
},
|
||||
{ credits: 0, points: [] }
|
||||
)
|
||||
|
||||
const graduationDates = programmeCodes ? findGraduationsByCodes(student, programmeCodes, showBachelorAndMaster) : []
|
||||
|
||||
if (points?.length > 0) {
|
||||
if (!singleStudent && points[0].y !== 0 && students.length < 100) {
|
||||
const xMinusTwoMonths = dayjs(new Date(points[0].x)).subtract(2, 'months').toDate().getTime()
|
||||
points.unshift({
|
||||
x: studyPlanFilterIsActive ? xMinusTwoMonths : Math.max(new Date(studyrightStart).getTime(), xMinusTwoMonths),
|
||||
y: 0,
|
||||
})
|
||||
}
|
||||
graduationDates.forEach((graduationDate, index) => addGraduation(points, graduationDate, index > 0))
|
||||
}
|
||||
|
||||
return {
|
||||
name: student.studentNumber,
|
||||
data: points,
|
||||
seriesThreshold: 150,
|
||||
marker: {
|
||||
enabled: !!singleStudent,
|
||||
radius: 4,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const filterTransfers = (student, getTextIn) =>
|
||||
student.studyRights.reduce((transfers, studyRight) => {
|
||||
const phase1Programmes = studyRight.studyRightElements.filter(element => element.phase === 1)
|
||||
const phase2Programmes = studyRight.studyRightElements.filter(element => element.phase === 2)
|
||||
for (const programmes of [phase1Programmes, phase2Programmes]) {
|
||||
// The oldest study right element is ignored, as there can be no transfer to it
|
||||
for (const element of programmes.toSpliced(-1)) {
|
||||
transfers.push({
|
||||
value: new Date(element.startDate),
|
||||
studyright: `to ${getTextIn(element.name)}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
return transfers
|
||||
}, [])
|
||||
|
||||
export const CreditAccumulationGraphHighCharts = ({
|
||||
students,
|
||||
singleStudent,
|
||||
absences,
|
||||
startDate,
|
||||
endDate,
|
||||
studyRightId,
|
||||
programmeCodes,
|
||||
customPopulation = false,
|
||||
studyPlanFilterIsActive,
|
||||
selectedStudyPlan,
|
||||
showBachelorAndMaster,
|
||||
infoBoxContent = '',
|
||||
}) => {
|
||||
const chartRef = useRef()
|
||||
const { getTextIn } = useLanguage()
|
||||
const [graphHeight, setGraphHeight] = useState(700)
|
||||
const [cutStudyPlanCredits, setCutStudyPlanCredits] = useState(false)
|
||||
const selectedStudyRight =
|
||||
singleStudent && studyRightId ? students[0].studyRights.find(({ id }) => id === studyRightId) : null
|
||||
|
||||
const seriesData = useDeepMemo(
|
||||
() =>
|
||||
createStudentCreditLines(
|
||||
students,
|
||||
singleStudent,
|
||||
studyRightId,
|
||||
studyPlanFilterIsActive,
|
||||
cutStudyPlanCredits,
|
||||
programmeCodes,
|
||||
selectedStudyPlan,
|
||||
showBachelorAndMaster
|
||||
),
|
||||
[
|
||||
students,
|
||||
singleStudent,
|
||||
studyRightId,
|
||||
studyPlanFilterIsActive,
|
||||
cutStudyPlanCredits,
|
||||
programmeCodes,
|
||||
selectedStudyPlan,
|
||||
showBachelorAndMaster,
|
||||
]
|
||||
)
|
||||
|
||||
if (singleStudent) {
|
||||
const studyPlanProgrammeCode = selectedStudyPlan ? selectedStudyPlan.programme_code : null
|
||||
const correctStudyRightElement = selectedStudyRight?.studyRightElements.find(
|
||||
element => element.code === studyPlanProgrammeCode
|
||||
)
|
||||
|
||||
const startDates = selectedStudyRight
|
||||
? selectedStudyRight.studyRightElements
|
||||
.filter(e => e.phase === correctStudyRightElement.phase)
|
||||
.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime())
|
||||
.map(e => e.startDate)
|
||||
: (students[0].studyRights ?? students[0].courses ?? []).map(e => e.startDate ?? e.date)
|
||||
|
||||
const firstDate = startDates.length
|
||||
? startDates.map(date => new Date(date)).sort((a, b) => a.getTime() - b.getTime())[0]
|
||||
: new Date()
|
||||
|
||||
const startDate = firstDate.getTime()
|
||||
|
||||
const [, studyRightTargetEnd] = getStudyRightElementTargetDates(
|
||||
selectedStudyRight?.studyRightElements
|
||||
.filter(element => element.phase === correctStudyRightElement.phase)
|
||||
.sort(({ startDate: a }, { startDate: b }) => new Date(a).getTime() - new Date(b).getTime())
|
||||
.at(0),
|
||||
absences
|
||||
)
|
||||
const ending = selectedStudyRight ? new Date(studyRightTargetEnd) : new Date(endDate ?? new Date())
|
||||
const starting = new Date(startDate)
|
||||
const filteredAbsences = selectedStudyRight
|
||||
? absences.filter(({ startDate, endDate }) => startDate >= starting && endDate <= ending)
|
||||
: absences
|
||||
|
||||
seriesData.push(createGoalSeries(starting.getTime(), ending.getTime(), filteredAbsences))
|
||||
}
|
||||
const getStudyRightStart = () => {
|
||||
const studyRightStartFromStudent = new Date(students[0]?.studyrightStart ?? new Date())
|
||||
if (studyRightStartFromStudent.getFullYear() < 2000)
|
||||
return Math.min(...flatten(students.map(({ courses }) => courses.map(({ date }) => new Date(date).getTime()))))
|
||||
return studyRightStartFromStudent.getTime()
|
||||
}
|
||||
|
||||
const getGraphStart = () => {
|
||||
if (startDate) return new Date(startDate).getTime()
|
||||
if (customPopulation)
|
||||
return Math.min(
|
||||
...flatten(
|
||||
students.map(({ courses }) => {
|
||||
return courses.map(({ date }) => new Date(date).getTime())
|
||||
})
|
||||
)
|
||||
)
|
||||
const studyRightStart = getStudyRightStart()
|
||||
if (studyPlanFilterIsActive && cutStudyPlanCredits) return studyRightStart
|
||||
if (studyPlanFilterIsActive) {
|
||||
// math.min may return a infinite value, if beginning of course credits is selected and student's courses are filtered accordingly.
|
||||
const startPoint = Math.min(
|
||||
...flatten(students.map(({ courses }) => courses.map(({ date }) => new Date(date).getTime())))
|
||||
)
|
||||
return startPoint !== Infinity ? startPoint : studyRightStart
|
||||
}
|
||||
return studyRightStart
|
||||
}
|
||||
|
||||
const graduations = singleStudent ? filterGraduations(students[0], selectedStudyRight, getTextIn) : []
|
||||
const transfers = singleStudent ? filterTransfers(students[0], getTextIn) : []
|
||||
const studyRightStartLine =
|
||||
!singleStudent && studyPlanFilterIsActive && students[0]?.studyrightStart
|
||||
? [new Date(students[0].studyrightStart).getTime()]
|
||||
: []
|
||||
|
||||
const options = createGraphOptions({
|
||||
singleStudent,
|
||||
seriesData,
|
||||
graphHeight,
|
||||
startDate: getGraphStart(),
|
||||
endDate,
|
||||
tooltipFormatter: point => singleStudent && singleStudentTooltipFormatter(point, students[0], getTextIn),
|
||||
onPointClicked: point => {
|
||||
if (!singleStudent) {
|
||||
if (point.series.name.length > 10) return
|
||||
window.open(`/students/${point.series.name}`, '_blank')
|
||||
}
|
||||
},
|
||||
graduations,
|
||||
transfers,
|
||||
studyRightStartLine,
|
||||
})
|
||||
|
||||
const makeGraphSizeButton = (height, label) => (
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={() => setGraphHeight(height)}
|
||||
variant={graphHeight === height ? 'contained' : 'outlined'}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ minWidth: '400px', marginBottom: '15px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
flexDirection: 'row-reverse',
|
||||
alignItems: 'center',
|
||||
margin: '1rem 0',
|
||||
}}
|
||||
>
|
||||
{!!infoBoxContent && <InfoBox content={infoBoxContent} />}
|
||||
<div>
|
||||
{!singleStudent && studyPlanFilterIsActive ? (
|
||||
<FormControlLabel
|
||||
control={<Switch />}
|
||||
label="Display credits from study right start"
|
||||
onClick={() => setCutStudyPlanCredits(!cutStudyPlanCredits)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{makeGraphSizeButton(400, 'Small')}
|
||||
{makeGraphSizeButton(700, 'Medium')}
|
||||
{makeGraphSizeButton(1000, 'Large')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ReactHighstock config={options} ref={chartRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import { useEffect } from 'react'
|
||||
import { populationStatisticsToolTips } from '@/common/InfoToolTips'
|
||||
import { PanelView } from '@/components/common/PanelView'
|
||||
import { StyledMessage } from '@/components/common/StyledMessage'
|
||||
import { CreditAccumulationGraphHighCharts } from '@/components/CreditAccumulationGraphHighCharts'
|
||||
import { InfoBox } from '@/components/InfoBox/InfoBoxWithTooltip'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { PopulationCourseStatsFlat } from '@/components/PopulationCourseStats/PopulationCourseStatsFlat'
|
||||
@@ -23,6 +22,7 @@ import { useFilteredAndFormattedStudyProgrammes } from '@/redux/studyProgramme'
|
||||
import { KeyboardBackspaceIcon, LabelIcon } from '@/theme'
|
||||
import { FilteredCourse } from '@/util/coursesOfPopulation'
|
||||
import { FormattedStudent } from '@oodikone/shared/types'
|
||||
import { CreditAccumulationGraph } from '../Charts/CreditAccumulation'
|
||||
import { PageTitle } from '../common/PageTitle'
|
||||
import { StudentAmountLimiter } from '../common/StudentAmountLimiter'
|
||||
import { CustomPopulationProgrammeDist } from './CustomPopulationProgrammeDist'
|
||||
@@ -67,18 +67,10 @@ export const CustomPopulationContent = ({
|
||||
{
|
||||
title: `Credit accumulation (for ${filteredStudents.length} students)`,
|
||||
content: (
|
||||
<CreditAccumulationGraphHighCharts
|
||||
absences={null}
|
||||
customPopulation
|
||||
endDate={null}
|
||||
programmeCodes={null}
|
||||
selectedStudyPlan={null}
|
||||
showBachelorAndMaster={null}
|
||||
singleStudent={false}
|
||||
startDate={null}
|
||||
<CreditAccumulationGraph
|
||||
programmeCodes={[associatedProgramme].filter(Boolean) as string[]}
|
||||
students={filteredStudents}
|
||||
studyPlanFilterIsActive={null} // Atm the filter is not enabled in the view
|
||||
studyRightId={null}
|
||||
studyPlanFilter={false} // Not enabled for custom population yet.
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
courseFilter,
|
||||
creditDateFilter,
|
||||
creditsEarnedFilter,
|
||||
hetuFilter,
|
||||
enrollmentStatusFilter,
|
||||
genderFilter,
|
||||
hopsFilter,
|
||||
@@ -51,6 +52,7 @@ export const CustomPopulationWrapper = ({
|
||||
transferredToProgrammeFilter(),
|
||||
startYearAtUniFilter(),
|
||||
tagsFilter(),
|
||||
hetuFilter(),
|
||||
programmeFilter(),
|
||||
creditDateFilter(),
|
||||
enrollmentStatusFilter({
|
||||
|
||||
@@ -2,8 +2,8 @@ import Button from '@mui/material/Button'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Typography from '@mui/material/Typography'
|
||||
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import { random } from 'lodash-es'
|
||||
import ReactHighchart from 'react-highcharts'
|
||||
|
||||
import toskaLogo from '@/assets/toska.svg'
|
||||
import { Backdrop } from '@/components/common/Backdrop'
|
||||
@@ -12,63 +12,83 @@ import { useTitle } from '@/hooks/title'
|
||||
import { useLogoutMutation } from '@/redux/auth'
|
||||
import { LogoutIcon, RefreshIcon } from '@/theme'
|
||||
|
||||
const names = [
|
||||
'mluukkai',
|
||||
'jakousa',
|
||||
'totutotu',
|
||||
'sasumaki',
|
||||
'ikuisma',
|
||||
'eero3',
|
||||
'mitiaine',
|
||||
'rimi',
|
||||
'esakemp',
|
||||
'woltsu',
|
||||
'cxcorp',
|
||||
'ajhaa',
|
||||
'joonashak',
|
||||
'vaahtokarkki',
|
||||
'otahontas',
|
||||
'saarasat',
|
||||
'popalmu',
|
||||
'ollikehy',
|
||||
] as const
|
||||
|
||||
const dummyData = names.map(name => ({
|
||||
name,
|
||||
data: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].reduce((acc, i) => {
|
||||
acc[i] = (acc[i - 1] || 0) + (random(0, 3) === 0 ? 0 : random(10.0, 100.0))
|
||||
return acc
|
||||
}, [] as number[]),
|
||||
type: 'line' as const,
|
||||
}))
|
||||
const dummyData = [...Array(18).keys()].map(_ => [...Array(new Date().getFullYear() - 2016).keys()].reduce<number[]>((acc, i) => {
|
||||
acc[i] = (acc[i - 1] || 0) + (random(0, 3) === 0 ? 0 : random(10.0, 100.0))
|
||||
return acc
|
||||
}, []))
|
||||
|
||||
export const ErrorBackground = ({ header, content }) => {
|
||||
useTitle(header)
|
||||
const [logout] = useLogoutMutation()
|
||||
const yearLabels = dummyData[0]?.map((_, index) => (2017 + index).toString()) ?? []
|
||||
|
||||
return (
|
||||
<PageLayout maxWidth="lg">
|
||||
<Stack sx={{ alignItems: 'stretch', height: '100vh', justifyContent: 'space-evenly' }}>
|
||||
<ReactHighchart
|
||||
config={{
|
||||
plotOptions: { series: { label: { connectorAllowed: false }, pointStart: 2010 } },
|
||||
series: dummyData,
|
||||
title: { text: 'Students of Computer Science 2018-2020' },
|
||||
yAxis: { title: { text: 'Cumulative credits' } },
|
||||
}}
|
||||
/>
|
||||
<ReactHighchart
|
||||
config={{
|
||||
chart: { type: 'column' },
|
||||
series: dummyData.map(element => ({
|
||||
name: element.name,
|
||||
data: [Math.max(...element.data)],
|
||||
type: 'column',
|
||||
<ReactECharts
|
||||
option={{
|
||||
title: {
|
||||
text: `Students of Computer Science 2017-${new Date().getFullYear()}`,
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
},
|
||||
grid: {
|
||||
left: 40,
|
||||
right: 20,
|
||||
top: 40,
|
||||
bottom: 30,
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: yearLabels,
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: 'Cumulative credits',
|
||||
},
|
||||
series: dummyData.map(data => ({
|
||||
type: 'line',
|
||||
data,
|
||||
})),
|
||||
title: { text: "Your students' future" },
|
||||
xAxis: { categories: ['2018'] },
|
||||
yAxis: { title: { text: 'Cumulative credits' } },
|
||||
}}
|
||||
opts={{ renderer: 'svg' }}
|
||||
style={{ height: 300, width: '100%' }}
|
||||
/>
|
||||
<ReactECharts
|
||||
option={{
|
||||
title: {
|
||||
text: "Your students' future",
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow',
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
left: 40,
|
||||
right: 20,
|
||||
top: 40,
|
||||
bottom: 30,
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: ['2018'],
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: 'Cumulative credits',
|
||||
},
|
||||
series: dummyData.map(data => ({
|
||||
type: 'bar',
|
||||
data: [Math.max(...data)],
|
||||
})),
|
||||
}}
|
||||
opts={{ renderer: 'svg' }}
|
||||
style={{ height: 300, width: '100%' }}
|
||||
/>
|
||||
</Stack>
|
||||
<Backdrop
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Stack from '@mui/material/Stack'
|
||||
|
||||
import { facultyToolTips } from '@/common/InfoToolTips'
|
||||
import { ProgressBarChart } from '@/components/common/ProgressBarChart'
|
||||
import { ProgressBarChart } from '@/components/Charts/ProgressBarChart'
|
||||
import { FacultyProgressTable } from '@/components/Faculties/FacultyProgressTable'
|
||||
import { Section } from '@/components/Section'
|
||||
import { GetAllProgressStatsResponse } from '@/types/api/faculty'
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
/* eslint-disable react/no-this-in-sfc */
|
||||
import Typography from '@mui/material/Typography'
|
||||
|
||||
import HighCharts from 'highcharts' // eslint-disable-line import-x/default
|
||||
import accessibility from 'highcharts/modules/accessibility'
|
||||
import exportData from 'highcharts/modules/export-data'
|
||||
import exporting from 'highcharts/modules/exporting'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { generateGradientColors } from '@/util/color'
|
||||
import { NameWithCode } from '@oodikone/shared/types'
|
||||
|
||||
exporting(ReactHighcharts.Highcharts)
|
||||
exportData(ReactHighcharts.Highcharts)
|
||||
accessibility(ReactHighcharts.Highcharts)
|
||||
|
||||
export const ProgrammeProgressChart = ({
|
||||
data,
|
||||
labels,
|
||||
@@ -27,70 +18,159 @@ export const ProgrammeProgressChart = ({
|
||||
names: string[]
|
||||
}) => {
|
||||
const { getTextIn } = useLanguage()
|
||||
const [legendSelection, setLegendSelection] = useState<Record<string, boolean>>({})
|
||||
const labelsKey = labels.join('|')
|
||||
const seriesNamesKey = names.join('|')
|
||||
|
||||
useEffect(() => {
|
||||
setLegendSelection({})
|
||||
}, [labelsKey, seriesNamesKey])
|
||||
|
||||
const onEvents = useMemo(
|
||||
() => ({
|
||||
legendselectchanged: (params: { selected?: Record<string, boolean> }) => {
|
||||
if (!params.selected) {
|
||||
return
|
||||
}
|
||||
|
||||
setLegendSelection(params.selected)
|
||||
},
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return <Typography variant="caption">No data available for this year</Typography>
|
||||
}
|
||||
|
||||
const getNumericValue = (value: unknown) => {
|
||||
const numericValue = typeof value === 'number' ? value : Number(value)
|
||||
return Number.isFinite(numericValue) ? numericValue : 0
|
||||
}
|
||||
|
||||
const transpose = (matrix: number[][]): number[][] => {
|
||||
return matrix.reduce<number[][]>((prev, next) => next.map((_, i) => (prev[i] || []).concat(next[i])), [])
|
||||
}
|
||||
|
||||
const colors = generateGradientColors(data[0]?.length || 6)
|
||||
const dataTranspose: HighCharts.SeriesBarOptions[] = transpose(data).map((obj, index) => ({
|
||||
const dataTranspose = transpose(data).map((obj, index) => ({
|
||||
name: names[index],
|
||||
data: obj,
|
||||
color: colors[index],
|
||||
type: 'bar',
|
||||
}))
|
||||
|
||||
const config: HighCharts.Options = {
|
||||
chart: {
|
||||
type: 'bar',
|
||||
marginTop: 60,
|
||||
height: 80 + 45 * labels.length,
|
||||
const isSeriesVisible = (name: string) => legendSelection[name] !== false
|
||||
|
||||
const totals = labels.map((_, index) =>
|
||||
dataTranspose.reduce((sum, series) => {
|
||||
if (!isSeriesVisible(series.name)) {
|
||||
return sum
|
||||
}
|
||||
|
||||
return sum + getNumericValue(series.data[index])
|
||||
}, 0)
|
||||
)
|
||||
|
||||
const series = dataTranspose.map((seriesItem, index) => ({
|
||||
name: seriesItem.name,
|
||||
type: 'bar',
|
||||
stack: 'total',
|
||||
itemStyle: {
|
||||
color: colors[index],
|
||||
},
|
||||
series: dataTranspose,
|
||||
xAxis: {
|
||||
categories: labels,
|
||||
},
|
||||
yAxis: {
|
||||
min: 0,
|
||||
reversed: true,
|
||||
title: {
|
||||
text: '',
|
||||
label: {
|
||||
show: true,
|
||||
position: 'inside',
|
||||
formatter: (params: { data?: { percentage?: number } }) => {
|
||||
const percentage = getNumericValue(params.data?.percentage)
|
||||
return percentage > 3 ? `${percentage.toFixed(1)}%` : ''
|
||||
},
|
||||
},
|
||||
labelLayout: {
|
||||
hideOverlap: true,
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'none',
|
||||
},
|
||||
data: labels.map((_, dataIndex) => {
|
||||
const rawValue = getNumericValue(seriesItem.data[dataIndex])
|
||||
const total = totals[dataIndex]
|
||||
const percentage = total ? (rawValue / total) * 100 : 0
|
||||
|
||||
return {
|
||||
value: percentage,
|
||||
rawValue,
|
||||
percentage,
|
||||
}
|
||||
}),
|
||||
}))
|
||||
|
||||
const option = {
|
||||
legend: {
|
||||
borderColor: '#CCC',
|
||||
borderWidth: 1,
|
||||
verticalAlign: 'top',
|
||||
bottom: 0,
|
||||
selected: Object.keys(legendSelection).length ? legendSelection : undefined,
|
||||
},
|
||||
tooltip: {
|
||||
shared: true,
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow',
|
||||
},
|
||||
backgroundColor: 'white',
|
||||
formatter(this: any) {
|
||||
let tooltipString = `<b>${getTextIn(longLabels[this.x])}</b><br /><p>${this.x}</p><br />`
|
||||
this.points.forEach((point: any) => {
|
||||
tooltipString += `<span style="color:${point.color}">●</span> ${point.series.name}: <b>${point.y}</b><br />`
|
||||
formatter: (
|
||||
params: Array<{ name?: string; seriesName?: string; color?: string; data?: { rawValue?: number } }>
|
||||
) => {
|
||||
const tooltipParams = Array.isArray(params) ? params : [params]
|
||||
const label = tooltipParams[0]?.name ?? ''
|
||||
const title = getTextIn(longLabels[label]) ?? ''
|
||||
let tooltipString = `<b>${title}</b><br /><p>${label}</p><br />`
|
||||
|
||||
tooltipParams.forEach(point => {
|
||||
const rawValue = getNumericValue(point.data?.rawValue)
|
||||
tooltipString += `<span style="color:${point.color}">●</span> ${point.seriesName}: <b>${rawValue}</b><br />`
|
||||
})
|
||||
|
||||
return tooltipString
|
||||
},
|
||||
},
|
||||
plotOptions: {
|
||||
series: {
|
||||
stacking: 'percent',
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
format: '{point.percentage:.1f}%',
|
||||
filter: {
|
||||
property: 'percentage',
|
||||
operator: '>',
|
||||
value: 3,
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
saveAsImage: {
|
||||
name: 'Oodikone-progress-of-students',
|
||||
},
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
top: 50,
|
||||
left: 10,
|
||||
right: 20,
|
||||
bottom: 50,
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: 100,
|
||||
axisLabel: {
|
||||
formatter: '{value}%',
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: labels,
|
||||
inverse: true,
|
||||
z: 10,
|
||||
},
|
||||
series,
|
||||
}
|
||||
|
||||
return <ReactHighcharts config={config} />
|
||||
return (
|
||||
<ReactECharts
|
||||
notMerge
|
||||
onEvents={onEvents}
|
||||
option={option}
|
||||
opts={{ renderer: 'svg' }}
|
||||
style={{ height: 80 + 45 * labels.length, width: '100%' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { filterToolTips } from '@/common/InfoToolTips'
|
||||
import { FilterTrayProps } from '../FilterTray'
|
||||
import { FilterRadio } from './common/FilterRadio'
|
||||
import { createFilter } from './createFilter'
|
||||
|
||||
const DEFAULT_STATE = 0
|
||||
|
||||
const HetuFilterCard = ({ onOptionsChange }: FilterTrayProps) => {
|
||||
const modeOptions = [
|
||||
{ key: 'All', text: 'All', value: 0 },
|
||||
{ key: 'Has hetu', text: 'Has hetu', value: 1 },
|
||||
{ key: 'Does not have hetu', text: 'Does not have hetu', value: 2 },
|
||||
]
|
||||
|
||||
return (
|
||||
<FilterRadio
|
||||
defaultValue={DEFAULT_STATE}
|
||||
filterKey={hetuFilter.key}
|
||||
onChange={({ target }) => onOptionsChange({ mode: target.value })}
|
||||
options={modeOptions}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @options
|
||||
* 0 - All
|
||||
* 1 - Has hetu
|
||||
* 2 - No hetu
|
||||
*/
|
||||
export const hetuFilter = createFilter({
|
||||
key: 'hetuFilter',
|
||||
title: 'Personal identity code',
|
||||
|
||||
info: filterToolTips.hetu,
|
||||
|
||||
defaultOptions: {
|
||||
mode: 0,
|
||||
},
|
||||
|
||||
isActive: ({ mode }) => mode !== 0,
|
||||
|
||||
filter(student, { options: { mode } }) {
|
||||
const hetu = student.hasPersonalIdentityCode
|
||||
|
||||
switch (Number(mode)) {
|
||||
case 0:
|
||||
return true
|
||||
case 1:
|
||||
return hetu
|
||||
case 2:
|
||||
return !hetu
|
||||
default:
|
||||
// never reached
|
||||
return true
|
||||
}
|
||||
},
|
||||
|
||||
render: HetuFilterCard,
|
||||
})
|
||||
@@ -18,3 +18,4 @@ export { studyRightTypeFilter } from './studyRightType'
|
||||
export { studyTrackFilter } from './studyTrack'
|
||||
export { tagsFilter } from './tags'
|
||||
export { transferredToProgrammeFilter } from './transferredToProgramme'
|
||||
export { hetuFilter } from './hetu'
|
||||
|
||||
@@ -71,7 +71,8 @@ export const studentNumberFilter = createFilter({
|
||||
filter(student, { options }) {
|
||||
const { allowlist, blocklist } = options
|
||||
|
||||
return allowlist.includes(student.studentNumber) && !blocklist.includes(student.studentNumber)
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
return allowlist.includes(student.studentNumber) || !blocklist.includes(student.studentNumber)
|
||||
},
|
||||
|
||||
render: StudentNumberFilterCard,
|
||||
|
||||
-162
@@ -1,162 +0,0 @@
|
||||
/* eslint-disable react/no-this-in-sfc */
|
||||
import Box from '@mui/material/Box'
|
||||
import useTheme from '@mui/material/styles/useTheme'
|
||||
|
||||
import HighCharts from 'highcharts' // eslint-disable-line import-x/default
|
||||
import accessibility from 'highcharts/modules/accessibility'
|
||||
import exportData from 'highcharts/modules/export-data'
|
||||
import exporting from 'highcharts/modules/exporting'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { GraduationStats, Name, NameWithCode } from '@oodikone/shared/types'
|
||||
|
||||
exporting(ReactHighcharts.Highcharts)
|
||||
exportData(ReactHighcharts.Highcharts)
|
||||
accessibility(ReactHighcharts.Highcharts)
|
||||
|
||||
export const BreakdownBarChart = ({
|
||||
cypress,
|
||||
data,
|
||||
facultyGraph = true,
|
||||
handleClick,
|
||||
mode,
|
||||
names,
|
||||
year = null,
|
||||
yearLabel,
|
||||
}: {
|
||||
cypress: string
|
||||
data: GraduationStats[]
|
||||
facultyGraph?: boolean
|
||||
handleClick: (event, isFacultyGraph: boolean, seriesCategory?: number | string) => void
|
||||
mode: 'faculty' | 'programme' | 'study track'
|
||||
names?: Record<string, Name | NameWithCode> | Record<string, string | Name>
|
||||
year?: number | null
|
||||
yearLabel?: 'Graduation year' | 'Start year'
|
||||
}) => {
|
||||
const { language } = useLanguage()
|
||||
const theme = useTheme()
|
||||
|
||||
const statData: HighCharts.SeriesBarOptions[] = [
|
||||
{ type: 'bar', name: 'On time', color: theme.palette.graduationTimes.onTime, data: [] },
|
||||
{ type: 'bar', name: 'Max. year overtime', color: theme.palette.graduationTimes.yearOver, data: [] },
|
||||
{ type: 'bar', name: 'Overtime', color: theme.palette.graduationTimes.wayOver, data: [] },
|
||||
]
|
||||
|
||||
let categories: string[] = []
|
||||
const codeMap = {}
|
||||
|
||||
for (const item of data) {
|
||||
statData[0].data?.push(item.statistics.onTime)
|
||||
statData[1].data?.push(item.statistics.yearOver)
|
||||
statData[2].data?.push(item.statistics.wayOver)
|
||||
categories = [...categories, item.name]
|
||||
if (!facultyGraph) {
|
||||
codeMap[item.name || item.code!] = item.code
|
||||
}
|
||||
}
|
||||
|
||||
const getHeight = () => {
|
||||
if (data.length === 1) {
|
||||
return 250
|
||||
}
|
||||
const multiplier = data.length > 8 ? 80 : 110
|
||||
return data.length * multiplier + 100
|
||||
}
|
||||
|
||||
const getFacultyOrProgrammeName = (code: string) => {
|
||||
if (!names) {
|
||||
return ''
|
||||
}
|
||||
const name = names[code]
|
||||
if (typeof name === 'string') {
|
||||
return name
|
||||
}
|
||||
return name[language] ?? name.fi
|
||||
}
|
||||
|
||||
const getTooltipText = (id: string, seriesName: string, amount: number) => {
|
||||
if (!facultyGraph) {
|
||||
const code = codeMap[id]
|
||||
return `<b>${getFacultyOrProgrammeName(code)}</b> • ${code}<br /><b>${seriesName}</b>: ${amount}`
|
||||
}
|
||||
return `<b>${seriesName}</b>: ${amount}`
|
||||
}
|
||||
|
||||
const getLabel = () => {
|
||||
if (mode === 'faculty') {
|
||||
return facultyGraph ? 'Graduation year' : 'Faculty'
|
||||
}
|
||||
return facultyGraph ? yearLabel! : `${mode.charAt(0).toUpperCase()}${mode.slice(1)}`
|
||||
}
|
||||
|
||||
const config: HighCharts.Options = {
|
||||
chart: {
|
||||
type: 'bar',
|
||||
height: getHeight(),
|
||||
margin: [70, 0],
|
||||
},
|
||||
title: { text: !facultyGraph ? `Year ${year} by ${yearLabel!.toLowerCase()}` : '' },
|
||||
series: statData.map(series => ({
|
||||
...series,
|
||||
pointWidth: data.length > 8 ? 16 : 20,
|
||||
groupPadding: 0.15,
|
||||
})),
|
||||
tooltip: {
|
||||
backgroundColor: 'white',
|
||||
formatter() {
|
||||
return getTooltipText(this.x as string, this.series.name, this.y!)
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
categories: categories.map(category => category.toString()),
|
||||
title: {
|
||||
text: getLabel(),
|
||||
align: 'high',
|
||||
offset: 0,
|
||||
rotation: 0,
|
||||
y: -10,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
min: 0,
|
||||
title: { text: 'Number of students' },
|
||||
labels: {
|
||||
overflow: 'justify',
|
||||
},
|
||||
allowDecimals: false,
|
||||
showFirstLabel: false,
|
||||
},
|
||||
plotOptions: {
|
||||
bar: {
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
align: 'right',
|
||||
color: '#424949',
|
||||
style: {
|
||||
textOutline: 'none',
|
||||
},
|
||||
formatter() {
|
||||
return this.y !== 0 ? this.y : ''
|
||||
},
|
||||
},
|
||||
},
|
||||
series: {
|
||||
point: {
|
||||
events: {
|
||||
click(event) {
|
||||
handleClick(event, facultyGraph, categories[this.x])
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<Box data-cy={cypress} width={{ sm: '100%', md: '50%' }}>
|
||||
<ReactHighcharts config={config} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
-101
@@ -1,101 +0,0 @@
|
||||
/* eslint-disable react/no-this-in-sfc */
|
||||
import accessibility from 'highcharts/modules/accessibility'
|
||||
import exportData from 'highcharts/modules/export-data'
|
||||
import exporting from 'highcharts/modules/exporting'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
|
||||
import { Section } from '@/components/Section'
|
||||
|
||||
exporting(ReactHighcharts.Highcharts)
|
||||
exportData(ReactHighcharts.Highcharts)
|
||||
accessibility(ReactHighcharts.Highcharts)
|
||||
|
||||
// TODO: Combine with @/components/GraduationTimes/BreakdownDisplay/BreakdownBarChart
|
||||
export const BreakdownBarChart = ({ data, title, byStartYear = false }) => {
|
||||
const statData = [
|
||||
{ name: 'On time', color: '#90A959', data: [] },
|
||||
{ name: 'Max. year overtime', color: '#FEE191', data: [] },
|
||||
{ name: 'Overtime', color: '#FB6962', data: [] },
|
||||
]
|
||||
|
||||
let categories = []
|
||||
|
||||
for (const item of data) {
|
||||
statData[0].data = [...statData[0].data, item.statistics.onTime]
|
||||
statData[1].data = [...statData[1].data, item.statistics.yearOver]
|
||||
statData[2].data = [...statData[2].data, item.statistics.wayOver]
|
||||
categories = [...categories, item.name]
|
||||
}
|
||||
|
||||
const getHeight = () => {
|
||||
const multiplier = data.length > 8 ? 80 : 110
|
||||
return data.length * multiplier + 100
|
||||
}
|
||||
|
||||
const getTooltipText = (seriesName, amount) => {
|
||||
return `<b>Graduated ${seriesName}</b>: ${amount} ${amount === 1 ? 'student' : 'students'}`
|
||||
}
|
||||
|
||||
const config = {
|
||||
chart: {
|
||||
type: 'bar',
|
||||
height: getHeight(),
|
||||
margin: [70, 0],
|
||||
},
|
||||
title: { text: title },
|
||||
series: statData,
|
||||
tooltip: {
|
||||
backgroundColor: 'white',
|
||||
fontSize: '25px',
|
||||
// eslint-disable-next-line
|
||||
formatter: function () {
|
||||
return getTooltipText(this.series.name, this.y)
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
categories,
|
||||
title: {
|
||||
text: byStartYear ? 'Start year' : 'Graduation year',
|
||||
align: 'high',
|
||||
offset: 0,
|
||||
rotation: 0,
|
||||
y: -10,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
min: 0,
|
||||
title: { text: 'Number of students' },
|
||||
labels: {
|
||||
overflow: 'justify',
|
||||
},
|
||||
allowDecimals: false,
|
||||
showFirstLabel: false,
|
||||
},
|
||||
plotOptions: {
|
||||
bar: {
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
align: 'right',
|
||||
color: '#424949',
|
||||
style: {
|
||||
textOutline: 'none',
|
||||
},
|
||||
formatter() {
|
||||
return this.y !== 0 ? this.y : ''
|
||||
},
|
||||
},
|
||||
},
|
||||
series: {
|
||||
pointWidth: data.length > 8 ? 16 : 20,
|
||||
groupPadding: 0.15,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<Section cypress={`${title.split(' ')[0].toLowerCase()}-breakdown-bar-chart`}>
|
||||
<ReactHighcharts config={config} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -2,44 +2,65 @@ import Box from '@mui/material/Box'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Typography from '@mui/material/Typography'
|
||||
|
||||
import { BreakdownBarChart } from '@/components/GraduationTimes/BreakdownDisplay/BreakdownBarChart'
|
||||
import { GraduationBreakdown } from '@/components/Charts/GraduationBreakdown'
|
||||
import { GraduationStats, Name, NameWithCode, ProgrammeMedians } from '@oodikone/shared/types'
|
||||
|
||||
export const BreakdownDisplay = ({
|
||||
data,
|
||||
handleClick,
|
||||
allowExpand,
|
||||
expandKey,
|
||||
level,
|
||||
levelProgrammeData,
|
||||
mode,
|
||||
names,
|
||||
programmeDataVisible,
|
||||
year,
|
||||
yearLabel,
|
||||
}: {
|
||||
data: GraduationStats[]
|
||||
handleClick: (event, isFacultyGraph: boolean, seriesCategory?: number | string) => void
|
||||
handleClick: (seriesCategory: string) => void
|
||||
allowExpand: boolean
|
||||
expandKey: string | null,
|
||||
level: string
|
||||
levelProgrammeData: ProgrammeMedians
|
||||
levelProgrammeData?: ProgrammeMedians
|
||||
mode: 'faculty' | 'programme' | 'study track'
|
||||
names: Record<string, Name | NameWithCode> | Record<string, string | Name> | undefined
|
||||
programmeDataVisible: boolean
|
||||
year: number | null
|
||||
yearLabel: 'Graduation year' | 'Start year'
|
||||
}) => {
|
||||
|
||||
if (!allowExpand) {
|
||||
return (
|
||||
<Box>
|
||||
<GraduationBreakdown
|
||||
cypress={`${level}-breakdown-bar-chart`}
|
||||
data={data}
|
||||
mode={mode}
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography>Click a bar to view that year's {mode} level breakdown</Typography>
|
||||
<Stack direction={{ sm: 'column', md: 'row' }}>
|
||||
<BreakdownBarChart cypress={`${level}-breakdown-bar-chart`} data={data} handleClick={handleClick} mode={mode} />
|
||||
{programmeDataVisible && year && year in levelProgrammeData ? (
|
||||
<BreakdownBarChart
|
||||
<GraduationBreakdown
|
||||
cypress={`${level}-breakdown-bar-chart`}
|
||||
data={data}
|
||||
handleClick={handleClick}
|
||||
mode={mode}
|
||||
yearLabel={yearLabel}
|
||||
/>
|
||||
{programmeDataVisible && expandKey && levelProgrammeData && expandKey in levelProgrammeData ? (
|
||||
<GraduationBreakdown
|
||||
cypress={`${level}BreakdownBarChartFaculty`}
|
||||
data={levelProgrammeData[year].data}
|
||||
facultyGraph={false}
|
||||
data={levelProgrammeData[expandKey].data}
|
||||
handleClick={handleClick}
|
||||
mode={mode}
|
||||
names={names}
|
||||
year={year}
|
||||
expandKey={expandKey}
|
||||
yearLabel={yearLabel}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Stack from '@mui/material/Stack'
|
||||
|
||||
import { GraduationTimes } from '@/components/GraduationTimes'
|
||||
import { GraduationTimes, GraduationTimesProps } from '@/components/GraduationTimes'
|
||||
import { GetAllGraduationStatsResponse } from '@/types/api/university'
|
||||
|
||||
export const FacultyGraduations = ({
|
||||
@@ -24,17 +24,19 @@ export const FacultyGraduations = ({
|
||||
const programmeData = data?.byGradYear.programmes.medians
|
||||
const facultyNames = data?.programmeNames
|
||||
const classSizes = data?.classSizes
|
||||
const commonProps = {
|
||||
|
||||
const commonProps: Omit<GraduationTimesProps, 'data' | 'goal' | 'level' | 'title'> = {
|
||||
allowExpand: true,
|
||||
classSizes,
|
||||
names: facultyNames,
|
||||
goalExceptions,
|
||||
groupBy: 'byGradYear' as const,
|
||||
isError,
|
||||
isLoading,
|
||||
mode: universityMode ? ('faculty' as const) : ('programme' as const),
|
||||
mode: universityMode ? 'faculty' as const : 'programme' as const,
|
||||
showMedian,
|
||||
yearLabel: 'Graduation year' as const,
|
||||
}
|
||||
} as const
|
||||
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
/* eslint-disable react/no-this-in-sfc */
|
||||
import Box from '@mui/material/Box'
|
||||
import useTheme from '@mui/material/styles/useTheme'
|
||||
|
||||
import Highcharts from 'highcharts' // eslint-disable-line import-x/default
|
||||
import accessibility from 'highcharts/modules/accessibility'
|
||||
import exportData from 'highcharts/modules/export-data'
|
||||
import exporting from 'highcharts/modules/exporting'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import {
|
||||
FacultyClassSizes,
|
||||
GraduationStatistics,
|
||||
GraduationStats,
|
||||
Name,
|
||||
NameWithCode,
|
||||
ProgrammeClassSizes,
|
||||
} from '@oodikone/shared/types'
|
||||
|
||||
exporting(ReactHighcharts.Highcharts)
|
||||
exportData(ReactHighcharts.Highcharts)
|
||||
accessibility(ReactHighcharts.Highcharts)
|
||||
|
||||
export const MedianBarChart = ({
|
||||
classSizes,
|
||||
cypress,
|
||||
data,
|
||||
facultyGraph = true,
|
||||
goal,
|
||||
goalExceptions,
|
||||
handleClick,
|
||||
level,
|
||||
mode,
|
||||
names,
|
||||
title,
|
||||
year,
|
||||
yearLabel,
|
||||
}: {
|
||||
classSizes: FacultyClassSizes['programmes'] | ProgrammeClassSizes['studyTracks']
|
||||
cypress: string
|
||||
data: GraduationStats[]
|
||||
facultyGraph?: boolean
|
||||
goal: number
|
||||
goalExceptions?: Record<string, number> | { needed: boolean }
|
||||
handleClick: (event, isFacultyGraph: boolean, seriesCategory?: number) => void
|
||||
level?: string
|
||||
mode: 'faculty' | 'programme' | 'study track'
|
||||
names?: Record<string, Name | NameWithCode>
|
||||
title: string
|
||||
year?: number | null
|
||||
yearLabel: 'Graduation year' | 'Start year'
|
||||
}) => {
|
||||
const { language } = useLanguage()
|
||||
const theme = useTheme()
|
||||
|
||||
let modData: Array<GraduationStats & { code: string; color: string; realGoal?: number }> | null = null
|
||||
if (!facultyGraph && goalExceptions?.needed && level && ['master', 'bcMsCombo'].includes(level)) {
|
||||
// change colors for longer medicine goal times
|
||||
modData = JSON.parse(JSON.stringify(data))
|
||||
for (const data of modData!) {
|
||||
if (Object.keys(goalExceptions).includes(data.code)) {
|
||||
const realGoal = goal + goalExceptions[data.code]
|
||||
if (data.median <= realGoal) {
|
||||
data.color = theme.palette.graduationTimes.onTime
|
||||
} else if (data.median <= realGoal + 12) {
|
||||
data.color = theme.palette.graduationTimes.yearOver
|
||||
} else {
|
||||
data.color = theme.palette.graduationTimes.wayOver
|
||||
}
|
||||
data.realGoal = realGoal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const maxValue = data.reduce((max, { median }) => {
|
||||
return median > max ? median : max
|
||||
}, goal * 2)
|
||||
|
||||
const getClassSize = (category: string) => {
|
||||
if (facultyGraph) {
|
||||
return classSizes[category]
|
||||
}
|
||||
return classSizes[category][year!]
|
||||
}
|
||||
|
||||
const getPercentage = (amount: number, category: string) => {
|
||||
const percent = Math.round((amount / getClassSize(category)) * 100 * 10) / 10
|
||||
return Number.isNaN(percent) ? 0 : percent
|
||||
}
|
||||
|
||||
const getDataLabel = (amount: number, category: string) => {
|
||||
if (yearLabel === 'Start year' && title === 'Bachelor study right') {
|
||||
return `${amount} graduated (${getPercentage(amount, category)} % of class)`
|
||||
}
|
||||
return `${amount} graduated`
|
||||
}
|
||||
|
||||
const getHeight = () => {
|
||||
const multiplier = data.length > 8 ? 35 : 55
|
||||
return data.length * multiplier + 100
|
||||
}
|
||||
|
||||
const getFacultyOrProgrammeName = (code: string) => {
|
||||
if (!names) {
|
||||
return ''
|
||||
}
|
||||
return names[code]?.[language] ?? names[code]?.fi
|
||||
}
|
||||
|
||||
const getTooltipText = (
|
||||
name: string,
|
||||
code: string,
|
||||
amount: number,
|
||||
median: number,
|
||||
statistics: GraduationStatistics,
|
||||
realGoal: number | undefined
|
||||
) => {
|
||||
const sortingText =
|
||||
yearLabel === 'Start year'
|
||||
? `<b>From class of ${facultyGraph ? name : year}, ${amount}/${getClassSize(code)} students have graduated</b>`
|
||||
: `<b>${amount} students graduated in year ${facultyGraph ? name : year}</b>`
|
||||
const timeText = `<br />${sortingText}<br /><b>median study time: ${median} months</b><br />`
|
||||
const statisticsText = `<br />${statistics.onTime} graduated on time<br />${statistics.yearOver} graduated max year overtime<br />${statistics.wayOver} graduated over year late`
|
||||
|
||||
if (!facultyGraph) {
|
||||
const goalText = realGoal ? `<br /><p><b>** Exceptional goal time: ${realGoal} months **</b></p>` : ''
|
||||
return `<b>${getFacultyOrProgrammeName(code)}</b> • ${code}<br />${timeText}${statisticsText}${goalText}`
|
||||
}
|
||||
return `${timeText}${statisticsText}`
|
||||
}
|
||||
|
||||
const getLabel = () => {
|
||||
if (mode === 'faculty') {
|
||||
return facultyGraph ? 'Graduation year' : 'Faculty'
|
||||
}
|
||||
return facultyGraph ? yearLabel : `${mode.charAt(0).toUpperCase()}${mode.slice(1)}`
|
||||
}
|
||||
|
||||
const config: Highcharts.Options = {
|
||||
chart: {
|
||||
type: 'bar',
|
||||
margin: [70, 0],
|
||||
height: getHeight(),
|
||||
},
|
||||
title: { text: !facultyGraph ? `Year ${year} by ${yearLabel.toLowerCase()}` : '' },
|
||||
tooltip: {
|
||||
backgroundColor: 'white',
|
||||
formatter() {
|
||||
const point = this.point as Highcharts.Point & {
|
||||
code?: string
|
||||
amount?: number
|
||||
statistics?: GraduationStatistics
|
||||
realGoal?: number
|
||||
}
|
||||
return getTooltipText(
|
||||
point.name,
|
||||
point.code ?? point.name,
|
||||
point.amount!,
|
||||
this.y!,
|
||||
point.statistics!,
|
||||
point.realGoal
|
||||
)
|
||||
},
|
||||
},
|
||||
plotOptions: {
|
||||
series: {
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
inside: true,
|
||||
overflow: 'allow',
|
||||
},
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: (modData ?? data).map(item => ({
|
||||
...item,
|
||||
y: item.median,
|
||||
name: item.name.toString(),
|
||||
})),
|
||||
dataLabels: {
|
||||
align: 'left',
|
||||
color: '#424949',
|
||||
style: {
|
||||
textOutline: 'none',
|
||||
},
|
||||
formatter() {
|
||||
const point = this.point as Highcharts.Point & {
|
||||
code?: string
|
||||
amount?: number
|
||||
}
|
||||
return getDataLabel(point.amount!, point.code ?? point.name)
|
||||
},
|
||||
},
|
||||
showInLegend: false,
|
||||
zones: [
|
||||
{
|
||||
value: goal + 0.1,
|
||||
color: theme.palette.graduationTimes.onTime,
|
||||
},
|
||||
{
|
||||
value: goal + 12.1,
|
||||
color: theme.palette.graduationTimes.yearOver,
|
||||
},
|
||||
{
|
||||
color: theme.palette.graduationTimes.wayOver,
|
||||
},
|
||||
],
|
||||
point: {
|
||||
events: {
|
||||
click(event) {
|
||||
handleClick(event, facultyGraph)
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
title: {
|
||||
text: getLabel(),
|
||||
align: 'high',
|
||||
offset: 0,
|
||||
rotation: 0,
|
||||
y: -10,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
min: 0,
|
||||
max: maxValue,
|
||||
title: { text: 'Graduation time (months)' },
|
||||
labels: {
|
||||
overflow: 'justify',
|
||||
},
|
||||
allowDecimals: false,
|
||||
showFirstLabel: false,
|
||||
plotLines: [
|
||||
{
|
||||
color: theme.palette.graduationTimes.onTime,
|
||||
width: 2,
|
||||
value: goal,
|
||||
dashStyle: 'ShortDash',
|
||||
},
|
||||
{
|
||||
color: theme.palette.graduationTimes.yearOver,
|
||||
width: 2,
|
||||
value: goal + 12,
|
||||
dashStyle: 'ShortDash',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<Box data-cy={cypress} width={{ sm: '100%', md: '50%' }}>
|
||||
<ReactHighcharts config={config} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import Box from '@mui/material/Box'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Typography from '@mui/material/Typography'
|
||||
|
||||
import { MedianBarChart } from '@/components/GraduationTimes/MedianDisplay/MedianBarChart'
|
||||
import { MedianGraduations } from '@/components/Charts/MedianGraduations'
|
||||
import {
|
||||
FacultyClassSizes,
|
||||
GraduationStats,
|
||||
@@ -12,9 +12,29 @@ import {
|
||||
ProgrammeMedians,
|
||||
} from '@oodikone/shared/types'
|
||||
|
||||
type MedianDisplayProps = {
|
||||
mode: 'faculty' | 'programme' | 'study track'
|
||||
classSizes: FacultyClassSizes | ProgrammeClassSizes | undefined
|
||||
allowExpand: boolean
|
||||
names: Record<string, string | Name> | Record<string, Name | NameWithCode> | undefined
|
||||
data: GraduationStats[]
|
||||
expandKey: string | null
|
||||
goal: number | undefined
|
||||
goalExceptions: Record<string, number> | { needed: boolean }
|
||||
groupBy: 'byGradYear' | 'byStartYear'
|
||||
handleClick: (seriesCategory: string) => void
|
||||
level: string
|
||||
levelProgrammeData: ProgrammeMedians | undefined
|
||||
programmeDataVisible: boolean
|
||||
title: string
|
||||
yearLabel: 'Graduation year' | 'Start year'
|
||||
}
|
||||
|
||||
export const MedianDisplay = ({
|
||||
allowExpand,
|
||||
classSizes,
|
||||
data,
|
||||
expandKey,
|
||||
goal,
|
||||
goalExceptions,
|
||||
groupBy,
|
||||
@@ -25,24 +45,8 @@ export const MedianDisplay = ({
|
||||
names,
|
||||
programmeDataVisible,
|
||||
title,
|
||||
year,
|
||||
yearLabel,
|
||||
}: {
|
||||
classSizes: FacultyClassSizes | ProgrammeClassSizes
|
||||
data: GraduationStats[]
|
||||
goal: number
|
||||
goalExceptions: Record<string, number> | { needed: boolean }
|
||||
groupBy: 'byGradYear' | 'byStartYear'
|
||||
handleClick: (event, isFacultyGraph: boolean, seriesCategory?: number) => void
|
||||
level: string
|
||||
levelProgrammeData: ProgrammeMedians
|
||||
mode: 'faculty' | 'programme' | 'study track'
|
||||
names: Record<string, Name | NameWithCode> | Record<string, string | Name>
|
||||
programmeDataVisible: boolean
|
||||
title: string
|
||||
year: number | null
|
||||
yearLabel: 'Graduation year' | 'Start year'
|
||||
}) => {
|
||||
}: MedianDisplayProps) => {
|
||||
return (
|
||||
<Box>
|
||||
{level === 'bcMsCombo' && groupBy === 'byStartYear' && (
|
||||
@@ -51,43 +55,58 @@ export const MedianDisplay = ({
|
||||
data in Sisu
|
||||
</Typography>
|
||||
)}
|
||||
{goalExceptions.needed && ['master', 'bcMsCombo'].includes(level) ? (
|
||||
{goalExceptions.needed && ['master', 'bcMsCombo'].includes(level) && (
|
||||
<Typography>
|
||||
<b>Different goal times</b> have been taken into account in all numbers and programme level bar coloring, but
|
||||
the faculty level bar color is based on the typical goal time of {goal} months
|
||||
</Typography>
|
||||
) : null}
|
||||
<Typography>Click a bar to view that year's {mode} level breakdown</Typography>
|
||||
<Stack direction={{ sm: 'column', md: 'row' }}>
|
||||
<MedianBarChart
|
||||
classSizes={classSizes?.[level] ?? {}}
|
||||
)}
|
||||
|
||||
{allowExpand ? <Typography>Click a bar to view that year's {mode} level breakdown</Typography> : null}
|
||||
|
||||
{!allowExpand ? (
|
||||
<MedianGraduations
|
||||
cypress={`${level}-median-bar-chart`}
|
||||
classSizes={!!classSizes && 'programme' in classSizes ? classSizes.programme : {}}
|
||||
data={data}
|
||||
goal={goal}
|
||||
fullWidth
|
||||
goal={goal ?? 0}
|
||||
handleClick={handleClick}
|
||||
mode={mode}
|
||||
names={mode === 'faculty' ? (names as Record<string, Name | NameWithCode>) : undefined}
|
||||
title={title}
|
||||
yearLabel={yearLabel}
|
||||
/>
|
||||
{programmeDataVisible && year && year in levelProgrammeData ? (
|
||||
<MedianBarChart
|
||||
classSizes={'programmes' in classSizes ? classSizes.programmes : classSizes.studyTracks}
|
||||
cypress={`${level}-median-bar-chart-faculty`}
|
||||
data={levelProgrammeData[year].data}
|
||||
facultyGraph={false}
|
||||
goal={goal}
|
||||
goalExceptions={goalExceptions}
|
||||
) : (
|
||||
<Stack direction={{ sm: 'column', md: 'row' }}>
|
||||
<MedianGraduations
|
||||
classSizes={!!classSizes ? ('programme' in classSizes ? classSizes.programme : level in classSizes ? classSizes[level] : {}) : {}}
|
||||
cypress={`${level}-median-bar-chart`}
|
||||
data={data}
|
||||
goal={goal ?? 0}
|
||||
handleClick={handleClick}
|
||||
level={level}
|
||||
mode={mode}
|
||||
names={mode === 'faculty' ? (names as Record<string, Name | NameWithCode>) : undefined}
|
||||
title={title}
|
||||
year={year}
|
||||
yearLabel={yearLabel}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Box>
|
||||
{programmeDataVisible && expandKey && levelProgrammeData && expandKey in levelProgrammeData && (
|
||||
<MedianGraduations
|
||||
classSizes={!!classSizes ? ('programmes' in classSizes ? classSizes.programmes : classSizes.studyTracks) : {}}
|
||||
cypress={`${level}-median-bar-chart-faculty`}
|
||||
data={levelProgrammeData[expandKey].data}
|
||||
expandKey={expandKey}
|
||||
goal={goal ?? 0}
|
||||
goalExceptions={goalExceptions}
|
||||
handleClick={handleClick}
|
||||
level={level}
|
||||
mode={mode}
|
||||
names={mode === 'faculty' ? (names as Record<string, Name | NameWithCode>) as any : undefined}
|
||||
title={title}
|
||||
yearLabel={yearLabel}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Box >
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,63 +12,79 @@ import {
|
||||
ProgrammeMedians,
|
||||
} from '@oodikone/shared/types'
|
||||
|
||||
export type GraduationTimesProps = {
|
||||
mode: 'faculty' | 'programme' | 'study track' // "Mode" is one step below the current view in hierarchy e.g. viewing a programme overview -> mode should be study track
|
||||
classSizes?: FacultyClassSizes | ProgrammeClassSizes | undefined
|
||||
data: GraduationStats[] | undefined
|
||||
goal?: number | undefined
|
||||
goalExceptions?: Record<string, number> | { needed: boolean }
|
||||
groupBy?: 'byGradYear' | 'byStartYear'
|
||||
allowExpand?: boolean
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
level?: 'unset' | 'bachelor' | 'master' | 'bcMsCombo' | 'doctor'
|
||||
levelProgrammeData?: ProgrammeMedians
|
||||
names?: Record<string, Name | NameWithCode> | Record<string, string | Name>
|
||||
showMedian: boolean
|
||||
title: string
|
||||
yearLabel: 'Graduation year' | 'Start year'
|
||||
}
|
||||
|
||||
export const GraduationTimes = ({
|
||||
classSizes,
|
||||
data,
|
||||
goal,
|
||||
goalExceptions,
|
||||
goalExceptions = { needed: false },
|
||||
groupBy = 'byStartYear',
|
||||
isError,
|
||||
allowExpand = true,
|
||||
isLoading,
|
||||
level,
|
||||
isError,
|
||||
level = 'unset',
|
||||
levelProgrammeData,
|
||||
mode,
|
||||
names,
|
||||
showMedian,
|
||||
title,
|
||||
yearLabel,
|
||||
}: {
|
||||
classSizes?: FacultyClassSizes | ProgrammeClassSizes
|
||||
data: GraduationStats[] | undefined
|
||||
goal: number | undefined
|
||||
goalExceptions: Record<string, number> | { needed: boolean }
|
||||
groupBy?: 'byGradYear' | 'byStartYear'
|
||||
isError: boolean
|
||||
isLoading: boolean
|
||||
level: string
|
||||
levelProgrammeData?: ProgrammeMedians
|
||||
mode: 'faculty' | 'programme' | 'study track'
|
||||
names?: Record<string, Name | NameWithCode> | Record<string, string | Name>
|
||||
showMedian: boolean
|
||||
title: string
|
||||
yearLabel: 'Graduation year' | 'Start year'
|
||||
}) => {
|
||||
}: GraduationTimesProps) => {
|
||||
const [programmeDataVisible, setProgrammeDataVisible] = useState(false)
|
||||
const [year, setYear] = useState<number | null>(null)
|
||||
const [expandKey, setExpandKey] = useState<string | null>(null)
|
||||
|
||||
const handleClick = (event, isFacultyGraph: boolean, seriesCategory: number | string | null = null) => {
|
||||
if (isFacultyGraph) {
|
||||
setYear(seriesCategory ?? event.point.name)
|
||||
const handleClick = (category: string) => {
|
||||
if (category) {
|
||||
setExpandKey(category)
|
||||
setProgrammeDataVisible(true)
|
||||
} else {
|
||||
setProgrammeDataVisible(false)
|
||||
setYear(null)
|
||||
setExpandKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
const dataIsLoaded = classSizes && data && names && goal && levelProgrammeData
|
||||
|
||||
return (
|
||||
<Section
|
||||
cypress={`${level}-graduation-times`}
|
||||
isError={isError}
|
||||
isLoading={isLoading ? !dataIsLoaded : false}
|
||||
title={title}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{showMedian && dataIsLoaded ? (
|
||||
{!showMedian ? (
|
||||
<BreakdownDisplay
|
||||
allowExpand={allowExpand}
|
||||
data={data!}
|
||||
expandKey={expandKey}
|
||||
handleClick={handleClick}
|
||||
level={level}
|
||||
levelProgrammeData={levelProgrammeData}
|
||||
mode={mode}
|
||||
names={names}
|
||||
programmeDataVisible={programmeDataVisible}
|
||||
yearLabel={yearLabel}
|
||||
/>
|
||||
) : (
|
||||
<MedianDisplay
|
||||
allowExpand={allowExpand}
|
||||
classSizes={classSizes}
|
||||
data={data}
|
||||
data={data!}
|
||||
goal={goal}
|
||||
goalExceptions={goalExceptions}
|
||||
groupBy={groupBy}
|
||||
@@ -76,26 +92,13 @@ export const GraduationTimes = ({
|
||||
level={level}
|
||||
levelProgrammeData={levelProgrammeData}
|
||||
mode={mode}
|
||||
names={names}
|
||||
names={names ?? {}}
|
||||
programmeDataVisible={programmeDataVisible}
|
||||
title={title}
|
||||
year={year}
|
||||
expandKey={expandKey}
|
||||
yearLabel={yearLabel}
|
||||
/>
|
||||
) : null}
|
||||
{!showMedian && dataIsLoaded ? (
|
||||
<BreakdownDisplay
|
||||
data={data}
|
||||
handleClick={handleClick}
|
||||
level={level}
|
||||
levelProgrammeData={levelProgrammeData}
|
||||
mode={mode}
|
||||
names={names}
|
||||
programmeDataVisible={programmeDataVisible}
|
||||
year={year}
|
||||
yearLabel={yearLabel}
|
||||
/>
|
||||
) : null}
|
||||
)}
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -66,6 +66,9 @@ export const OodiTableContainer = <OTData,>({
|
||||
backgroundColor: 'grey.100',
|
||||
}
|
||||
: {},
|
||||
'& tr:hover > td': {
|
||||
backgroundColor: 'grey.200',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{table.getAggregationRowModel().rows.map(row => OodiTableDataRow(row, true))}
|
||||
|
||||
@@ -10,13 +10,14 @@ import { flexRender } from './util'
|
||||
export const OodiTableDataRow = <OTData,>(row: Row<OTData>, aggregate = false) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map(cell => {
|
||||
const { maxSize } = cell.column.columnDef
|
||||
const { size, maxSize } = cell.column.columnDef
|
||||
return (
|
||||
<TableCell
|
||||
className="ot-data-cell"
|
||||
key={cell.id}
|
||||
sx={{
|
||||
...getCommonPinningStyles(cell.column),
|
||||
width: size ?? undefined,
|
||||
maxWidth: maxSize !== Number.MAX_SAFE_INTEGER ? maxSize : '20em',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,19 +1,34 @@
|
||||
import TablePagination from '@mui/material/TablePagination'
|
||||
import type { Table } from '@tanstack/react-table'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export const OodiTablePagination = <TData,>({ table }: { table: Table<TData> }) => {
|
||||
const tableRows = table.getRowCount()
|
||||
const pageRowOptions = [100, 200, 500, 1000, tableRows].filter(count => count <= tableRows)
|
||||
|
||||
const [pagination, setPagination] = useState({
|
||||
pageIndex: 0,
|
||||
pageSize: Math.min(200, tableRows),
|
||||
})
|
||||
|
||||
useEffect(() => table.setPageSize(pagination.pageSize), [pagination.pageSize])
|
||||
useEffect(() => table.setPageIndex(pagination.pageIndex), [pagination.pageIndex])
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
setPagination({
|
||||
pageIndex: 0,
|
||||
pageSize: Math.max(pagination.pageSize, pageRowOptions[0]),
|
||||
}),
|
||||
[table.getCoreRowModel().rows.length]
|
||||
)
|
||||
|
||||
return (
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={table.getFilteredRowModel().rows.length}
|
||||
onPageChange={(_, newPage) => table.setPageIndex(newPage)}
|
||||
onRowsPerPageChange={event => {
|
||||
table.setPageSize(Number(event.target.value))
|
||||
table.setPageIndex(0)
|
||||
}}
|
||||
onPageChange={(_, newPage) => setPagination({ ...pagination, pageIndex: newPage })}
|
||||
onRowsPerPageChange={event => setPagination({ pageIndex: 0, pageSize: Number(event.target.value) })}
|
||||
page={table.getState().pagination.pageIndex}
|
||||
rowsPerPage={table.getState().pagination.pageSize}
|
||||
rowsPerPageOptions={pageRowOptions}
|
||||
|
||||
@@ -93,6 +93,7 @@ export const OodiTableExcelExport = <TData extends object>({
|
||||
}),
|
||||
columnHelper.accessor('sample', {
|
||||
header: 'Sample values',
|
||||
enableSorting: false,
|
||||
cell: cell => {
|
||||
const value = cell.getValue<React.ReactNode>()
|
||||
return (
|
||||
@@ -112,15 +113,14 @@ export const OodiTableExcelExport = <TData extends object>({
|
||||
[data]
|
||||
)
|
||||
|
||||
const prepData = exportData.slice(0, 10)
|
||||
|
||||
const pivotedData = useMemo(
|
||||
() =>
|
||||
exportColumnKeys.map(accessorKey => ({
|
||||
header: accessorKey,
|
||||
sample: prepData
|
||||
sample: data
|
||||
.map(row => row[accessorKey])
|
||||
.filter(value => value !== undefined)
|
||||
.filter(Boolean)
|
||||
.slice(0, 8)
|
||||
.map(churnSampleData),
|
||||
})),
|
||||
[exportColumnKeys, exportData]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ColumnDef, TableOptions } from '@tanstack/react-table'
|
||||
import { useReactTable, getCoreRowModel, getPaginationRowModel, getSortedRowModel } from '@tanstack/react-table'
|
||||
import { ReactNode, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { AggregationRowFeature, VerticalHeaderFeature, ZebrastripesFeature } from './features'
|
||||
import { OodiTableContainer } from './OodiTable'
|
||||
@@ -28,21 +28,14 @@ export const OodiTable = <TData,>({
|
||||
cy,
|
||||
}: {
|
||||
data: TData[]
|
||||
columns: ColumnDef<TData>[]
|
||||
columns: ColumnDef<TData, any>[]
|
||||
isExportView?: true
|
||||
options?: Partial<TableOptions<TData>>
|
||||
toolbarContent?: ReactNode
|
||||
cy?: string
|
||||
}) => {
|
||||
const [pagination, setPagination] = useState({
|
||||
pageIndex: 0,
|
||||
pageSize: Math.min(200, data.length),
|
||||
})
|
||||
|
||||
// should maybe use a deep merging tool or write own
|
||||
const config: Partial<TableOptions<TData>> = {
|
||||
...options,
|
||||
}
|
||||
const { ...config }: Partial<TableOptions<TData>> = options
|
||||
|
||||
const table = useReactTable<TData>({
|
||||
_features: [VerticalHeaderFeature, AggregationRowFeature, ZebrastripesFeature],
|
||||
@@ -51,9 +44,7 @@ export const OodiTable = <TData,>({
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onPaginationChange: setPagination,
|
||||
...config,
|
||||
state: { pagination, ...config.state },
|
||||
})
|
||||
return <OodiTableContainer cy={cy} isExportView={isExportView} table={table} toolbarContent={toolbarContent} />
|
||||
}
|
||||
|
||||
+133
-91
@@ -5,15 +5,11 @@ import FormLabel from '@mui/material/FormLabel'
|
||||
import Paper from '@mui/material/Paper'
|
||||
import RadioMui from '@mui/material/Radio'
|
||||
import RadioGroup from '@mui/material/RadioGroup'
|
||||
import dayjs from 'dayjs'
|
||||
import { Options } from 'highcharts'
|
||||
import accessibility from 'highcharts/modules/accessibility'
|
||||
import exportData from 'highcharts/modules/export-data'
|
||||
import exporting from 'highcharts/modules/exporting'
|
||||
import dayjs, { Dayjs } from 'dayjs'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
|
||||
import { groupBy, range } from 'lodash-es'
|
||||
import { useState } from 'react'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { getCreditCategories, getTargetCreditsForProgramme, TimeDivision } from '@/common'
|
||||
import { Toggle } from '@/components/common/toggle/Toggle'
|
||||
@@ -26,23 +22,15 @@ import { SemestersData, useGetSemestersQuery } from '@/redux/semesters'
|
||||
import { generateGradientColors } from '@/util/color'
|
||||
import { FormattedStudent } from '@oodikone/shared/types'
|
||||
|
||||
exporting(ReactHighcharts.Highcharts)
|
||||
exportData(ReactHighcharts.Highcharts)
|
||||
accessibility(ReactHighcharts.Highcharts)
|
||||
|
||||
/*
|
||||
This file is bit of a legacy mess still and imo not worth the effort refactoring unless replacing legacy react-highcharts
|
||||
(last publish 6 years ago as of 2025 btw) with the official newer version or something else entirely
|
||||
*/
|
||||
const splitStudentCredits = (student, timeSlots, cumulative) => {
|
||||
const splitStudentCredits = (student: FormattedStudent, timeSlots: any[], cumulative: boolean) => {
|
||||
if (!timeSlots.length) return []
|
||||
|
||||
let timeSlotN = 0
|
||||
const results = new Array(timeSlots.length).fill(0)
|
||||
const results: number[] = new Array(timeSlots.length).fill(0)
|
||||
|
||||
student.courses
|
||||
.filter(course => course.passed && !course.isStudyModuleCredit && dayjs(course.date).isAfter(timeSlots[0].start))
|
||||
.sort((a, b) => dayjs(a.date) > dayjs(b.date))
|
||||
.sort((a, b) => Number(dayjs(a.date) > dayjs(b.date)))
|
||||
.forEach(course => {
|
||||
while (timeSlotN < timeSlots.length && dayjs(course.date).isAfter(timeSlots[timeSlotN].end)) timeSlotN++
|
||||
if (timeSlots.length <= timeSlotN) return
|
||||
@@ -58,17 +46,17 @@ const splitStudentCredits = (student, timeSlots, cumulative) => {
|
||||
return results
|
||||
}
|
||||
|
||||
const hasGraduatedBeforeDate = (student, programme, date) => {
|
||||
const hasGraduatedBeforeDate = (student: FormattedStudent, programme: string, date: Dayjs) => {
|
||||
const correctStudyRight = student.studyRights.find(studyRight =>
|
||||
studyRight.studyRightElements.some(el => el.code === programme)
|
||||
)
|
||||
if (!correctStudyRight) return false
|
||||
const studyRightElement = correctStudyRight.studyRightElements.find(el => el.code === programme)
|
||||
return studyRightElement.graduated && date.isAfter(studyRightElement.endDate, 'day')
|
||||
return Boolean(studyRightElement?.graduated && date.isAfter(studyRightElement?.endDate, 'day'))
|
||||
}
|
||||
|
||||
const getChartData = (
|
||||
students: any[],
|
||||
students: FormattedStudent[],
|
||||
timeSlots: any[],
|
||||
programme: string,
|
||||
timeDivision: TimeDivision,
|
||||
@@ -87,43 +75,41 @@ const getChartData = (
|
||||
const colors = generateGradientColors(limits.length - 1)
|
||||
colors.push('#ddd') // Color for graduated (grey)
|
||||
|
||||
const data: { y: number; custom: { students: number[] } }[][] = limits.map(() =>
|
||||
const data: { value: number; students: Array<FormattedStudent['studentNumber']> }[][] = limits.map(() =>
|
||||
timeSlots.map(() => ({
|
||||
y: 0,
|
||||
custom: { students: [] },
|
||||
value: 0,
|
||||
students: [],
|
||||
}))
|
||||
)
|
||||
|
||||
const studentCredits = students.map(student => splitStudentCredits(student, timeSlots, cumulative))
|
||||
|
||||
timeSlots.forEach((slot, timeSlotIndex) => {
|
||||
students
|
||||
.map((student, i) => [student, i])
|
||||
.forEach(([student, studentIndex]) => {
|
||||
const hasGraduated = programme && hasGraduatedBeforeDate(student, programme, dayjs(slot.end))
|
||||
const credits = studentCredits[studentIndex][timeSlotIndex]
|
||||
students.forEach((student, studentIndex) => {
|
||||
const hasGraduated = !!programme && hasGraduatedBeforeDate(student, programme, dayjs(slot.end))
|
||||
const credits = studentCredits[studentIndex][timeSlotIndex]
|
||||
|
||||
const rangeIndex = hasGraduated
|
||||
? limits.indexOf('Graduated')
|
||||
: limits.findIndex(limit => {
|
||||
if (limit === 'Graduated') {
|
||||
return false
|
||||
}
|
||||
const rangeIndex = hasGraduated
|
||||
? limits.indexOf('Graduated')
|
||||
: limits.findIndex(limit => {
|
||||
if (limit === 'Graduated') {
|
||||
return false
|
||||
}
|
||||
|
||||
const [min, max] = limit
|
||||
const [min, max] = limit
|
||||
|
||||
if (min == null) {
|
||||
return credits < max
|
||||
}
|
||||
if (max == null) {
|
||||
return credits >= min
|
||||
}
|
||||
return credits >= min && credits < max
|
||||
})
|
||||
if (min == null) {
|
||||
return credits < max
|
||||
}
|
||||
if (max == null) {
|
||||
return credits >= min
|
||||
}
|
||||
return credits >= min && credits < max
|
||||
})
|
||||
|
||||
data[rangeIndex][timeSlotIndex].y += 1
|
||||
data[rangeIndex][timeSlotIndex].custom.students.push(student.studentNumber)
|
||||
})
|
||||
data[rangeIndex][timeSlotIndex].value += 1
|
||||
data[rangeIndex][timeSlotIndex].students.push(student.studentNumber)
|
||||
})
|
||||
})
|
||||
|
||||
const series = data.map((slots, limitN) => {
|
||||
@@ -170,7 +156,7 @@ export const CreditDistributionDevelopment = ({
|
||||
}: CreditDistributionDevelopmentProps) => {
|
||||
const [cumulative, setCumulative] = useState(true)
|
||||
const [timeDivision, setTimeDivision] = useState<TimeDivision>(TimeDivision.SEMESTER)
|
||||
const [isAscending, setIsAscending] = useState(true)
|
||||
const [isAscending, setIsAscending] = useState(false)
|
||||
|
||||
const { getTextIn } = useLanguage()
|
||||
const { filterDispatch } = useFilters()
|
||||
@@ -220,56 +206,112 @@ export const CreditDistributionDevelopment = ({
|
||||
const labels = timeSlots.map(ts => ts.label)
|
||||
const bcMsTitle = combinedProgramme === 'MH90_001' ? 'Bachelor + Licentiate' : 'Bachelor + Master'
|
||||
const title = combinedProgramme ? bcMsTitle : ''
|
||||
const ascendingSeries = useMemo(() => {
|
||||
const graduatedSeries = series.find(seriesItem => seriesItem.name === 'Graduated')
|
||||
const nonGraduatedSeries = series.filter(seriesItem => seriesItem.name !== 'Graduated')
|
||||
return graduatedSeries ? [...nonGraduatedSeries, graduatedSeries] : nonGraduatedSeries
|
||||
}, [series])
|
||||
|
||||
const config: Options = {
|
||||
series: isAscending ? series : series.toReversed(),
|
||||
title: { text: title },
|
||||
tooltip: {
|
||||
formatter() {
|
||||
// Highcharts formatter only accepts a subset of HTML as a string
|
||||
// eslint-disable-next-line react/no-this-in-sfc
|
||||
return `<div style="text-align: center; width: 100%"><b>${this.x}</b>, ${this.series.name}<br/>${this.y}/${this.total} students (${Math.round(this.percentage)}%)</div>`
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
categories: labels,
|
||||
},
|
||||
yAxis: {
|
||||
allowDecimals: false,
|
||||
min: 0,
|
||||
max: filteredStudents.length,
|
||||
endOnTick: false,
|
||||
reversed: false,
|
||||
title: { text: 'Students' },
|
||||
},
|
||||
chart: {
|
||||
type: 'column',
|
||||
height: '450px',
|
||||
},
|
||||
plotOptions: {
|
||||
column: {
|
||||
stacking: 'normal',
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
const displayedSeries = useMemo(
|
||||
() => (isAscending ? ascendingSeries : ascendingSeries.toReversed()),
|
||||
[isAscending, ascendingSeries]
|
||||
)
|
||||
|
||||
const option = useMemo(
|
||||
() => ({
|
||||
title: title
|
||||
? {
|
||||
text: title,
|
||||
left: 'center',
|
||||
}
|
||||
: undefined,
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: (params: { name?: string; seriesName?: string; value?: unknown; dataIndex?: number }) => {
|
||||
const value =
|
||||
typeof params.value === 'number'
|
||||
? params.value
|
||||
: Number.isFinite(Number(params.value))
|
||||
? Number(params.value)
|
||||
: 0
|
||||
const dataIndex = params.dataIndex ?? 0
|
||||
const total = displayedSeries.reduce(
|
||||
(sum, currentSeries) => sum + (currentSeries.data[dataIndex]?.value ?? 0),
|
||||
0
|
||||
)
|
||||
const percentage = total ? Math.round((value / total) * 100) : 0
|
||||
|
||||
return `<div style="text-align: left; width: 100%"><b>${params.name ?? ''}</b>, ${params.seriesName ?? ''}<br/>${value}/${total} students (${percentage}%)</div>`
|
||||
},
|
||||
},
|
||||
series: {
|
||||
cursor: 'pointer',
|
||||
point: {
|
||||
events: {
|
||||
click(event) {
|
||||
const point = event.point as Highcharts.Point & { custom: { students: number[] } }
|
||||
filterDispatch(studentNumberFilter.actions.addToAllowlist(point.custom.students))
|
||||
},
|
||||
legend: {},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: labels,
|
||||
},
|
||||
grid: {
|
||||
show: false,
|
||||
top: 60,
|
||||
left: '15%',
|
||||
right: '15%',
|
||||
bottom: 90,
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
saveAsImage: {},
|
||||
dataView: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: filteredStudents.length,
|
||||
minInterval: 1,
|
||||
name: 'Students',
|
||||
},
|
||||
series: displayedSeries.map(seriesItem => ({
|
||||
name: seriesItem.name,
|
||||
type: 'bar',
|
||||
stack: 'students',
|
||||
data: seriesItem.data,
|
||||
itemStyle: {
|
||||
color: seriesItem.color,
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
formatter: (params: { value?: unknown }) => {
|
||||
const numericValue =
|
||||
typeof params.value === 'number'
|
||||
? params.value
|
||||
: Number.isFinite(Number(params.value))
|
||||
? Number(params.value)
|
||||
: 0
|
||||
return numericValue ? `${numericValue}` : ''
|
||||
},
|
||||
},
|
||||
})),
|
||||
animation: true,
|
||||
}),
|
||||
[title, labels, filteredStudents.length, displayedSeries]
|
||||
)
|
||||
|
||||
const onEvents = useMemo(
|
||||
() => ({
|
||||
click: (params: { componentType?: string; data?: { students?: Array<FormattedStudent['studentNumber']> } }) => {
|
||||
if (params.componentType !== 'series') return
|
||||
if (!params.data?.students?.length) return
|
||||
|
||||
filterDispatch(studentNumberFilter.actions.addToAllowlist(params.data.students))
|
||||
},
|
||||
}),
|
||||
[filterDispatch]
|
||||
)
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2, my: 2 }} variant="outlined">
|
||||
<Box sx={{ m: 2, width: '100%', display: 'flex', justifyContent: 'center', gap: '5em' }}>
|
||||
<Box sx={{ mt: 2, mx: 2, width: '100%', display: 'flex', justifyContent: 'center', gap: '5em' }}>
|
||||
<ToggleContainer>
|
||||
<FormLabel sx={{ mb: 1 }}>Chart settings</FormLabel>
|
||||
<Toggle
|
||||
@@ -282,7 +324,7 @@ export const CreditDistributionDevelopment = ({
|
||||
firstLabel="Ascending"
|
||||
secondLabel="Descending"
|
||||
setValue={() => setIsAscending(prev => !prev)}
|
||||
value={isAscending}
|
||||
value={!isAscending}
|
||||
/>
|
||||
</ToggleContainer>
|
||||
<FormControl>
|
||||
@@ -294,7 +336,7 @@ export const CreditDistributionDevelopment = ({
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</Box>
|
||||
<ReactHighcharts config={config} />
|
||||
<ReactECharts notMerge onEvents={onEvents} option={option} replaceMerge={['series']} style={{ height: 450 }} />
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,12 +5,11 @@ import FormGroup from '@mui/material/FormGroup'
|
||||
import Paper from '@mui/material/Paper'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Switch from '@mui/material/Switch'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { populationStatisticsToolTips } from '@/common/InfoToolTips'
|
||||
import { Link } from '@/components/common/Link'
|
||||
import { PanelView } from '@/components/common/PanelView'
|
||||
import { CreditAccumulationGraphHighCharts } from '@/components/CreditAccumulationGraphHighCharts'
|
||||
import {
|
||||
hopsFilter as studyPlanFilter,
|
||||
hopsFilter,
|
||||
@@ -28,6 +27,7 @@ import { getFullStudyProgrammeRights } from '@/util/access'
|
||||
|
||||
import { FilteredCourse } from '@/util/coursesOfPopulation'
|
||||
import { FormattedStudent } from '@oodikone/shared/types'
|
||||
import { CreditAccumulationGraph } from '../Charts/CreditAccumulation'
|
||||
import { AdvancedSettings } from './AdvancedSettings'
|
||||
import { AgeStats } from './AgeStats'
|
||||
import { CourseTableModeSelector } from './CourseTableModeSelector'
|
||||
@@ -88,20 +88,16 @@ export const PopulationDetails = ({
|
||||
const panels = [
|
||||
{
|
||||
title: `Credit accumulation (for ${filteredStudents.length} students)`,
|
||||
content: (
|
||||
<CreditAccumulationGraphHighCharts
|
||||
absences={null}
|
||||
endDate={null}
|
||||
infoBoxContent={populationStatisticsToolTips.creditAccumulation}
|
||||
programmeCodes={[programme, combinedProgramme].filter(Boolean)}
|
||||
selectedStudyPlan={null}
|
||||
content: filteredStudents.length ? (
|
||||
// TODO: make wrapper for when no data.
|
||||
<CreditAccumulationGraph
|
||||
programmeCodes={[programme, combinedProgramme].filter(c => c !== undefined)}
|
||||
showBachelorAndMaster={!!showBachelorAndMaster}
|
||||
singleStudent={false}
|
||||
startDate={null}
|
||||
students={filteredStudents}
|
||||
studyPlanFilterIsActive={studyPlanFilterIsActive}
|
||||
studyRightId={null}
|
||||
studyPlanFilter={studyPlanFilterIsActive}
|
||||
/>
|
||||
) : (
|
||||
<Typography>Nothing to show!</Typography>
|
||||
),
|
||||
},
|
||||
query.years.length <= 1
|
||||
|
||||
+39
-28
@@ -153,35 +153,11 @@ export const useGetColumnDefinitions = (modules: Map<string, CourseTabModule>):
|
||||
if (!correctCourse) return null
|
||||
|
||||
const sub = correctCourse.substitutedBy
|
||||
const substitutePrefix = sub ? `Substituting course: ${sub}\n` : ''
|
||||
|
||||
if (correctCourse.grade) {
|
||||
const isTransferred = correctCourse.credittypecode === CreditTypeCode.APPROVED
|
||||
const title =
|
||||
substitutePrefix +
|
||||
(isTransferred
|
||||
? `Grade: ${correctCourse.grade}\nTransferred on: ${formatDate(correctCourse.completionDate, dateFormat)}`
|
||||
: `Grade: ${correctCourse.grade}\nCompleted on: ${formatDate(correctCourse.completionDate, dateFormat)}`)
|
||||
if (correctCourse.enrollmentDate) {
|
||||
const subPrefix = sub ? `Substituted by: ${sub?.map(course => course.course_code)?.join(', ')}\n` : ''
|
||||
return (
|
||||
<div title={title}>
|
||||
{isTransferred ? (
|
||||
<SwapHorizIcon
|
||||
sx={{ color: sub ? theme.palette.ooditable.hops : theme.palette.ooditable.success }}
|
||||
/>
|
||||
) : (
|
||||
<CheckIcon
|
||||
sx={{ color: sub ? theme.palette.ooditable.hops : theme.palette.ooditable.success }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
} else if (correctCourse.enrollmentDate) {
|
||||
return (
|
||||
<div
|
||||
title={
|
||||
substitutePrefix + `Last enrollment ${formatDate(correctCourse.enrollmentDate, dateFormat)}`
|
||||
}
|
||||
>
|
||||
<div title={`${subPrefix}Last enrollment ${formatDate(correctCourse.enrollmentDate, dateFormat)}`}>
|
||||
<RemoveIcon
|
||||
sx={{
|
||||
color: isWithinSixMonths(correctCourse.enrollmentDate)
|
||||
@@ -192,11 +168,46 @@ export const useGetColumnDefinitions = (modules: Map<string, CourseTabModule>):
|
||||
</div>
|
||||
)
|
||||
} else if (correctCourse.inHops) {
|
||||
const subString = sub
|
||||
? `Substituted by: ${sub?.map(course => course.course_code).join(', ')}\n\n`
|
||||
: ''
|
||||
return (
|
||||
<div title={substitutePrefix + 'In primary studyplan'}>
|
||||
<div title={`${subString}In primary studyplan`}>
|
||||
<CropSquareIcon sx={{ color: theme.palette.ooditable.hops }} />
|
||||
</div>
|
||||
)
|
||||
} else if (correctCourse.grade) {
|
||||
// NOTE: Contains only normally-passed-courses because substitutions don't have a grade field
|
||||
const isTransferred = correctCourse.credittypecode === CreditTypeCode.APPROVED
|
||||
const title = isTransferred
|
||||
? `Grade: ${correctCourse.grade}\nTransferred on: ${formatDate(correctCourse.completionDate, dateFormat)}`
|
||||
: `Grade: ${correctCourse.grade}\nCompleted on: ${formatDate(correctCourse.completionDate, dateFormat)}`
|
||||
|
||||
return (
|
||||
<div title={title}>
|
||||
{isTransferred ? (
|
||||
<SwapHorizIcon sx={{ color: theme.palette.ooditable.success }} />
|
||||
) : (
|
||||
<CheckIcon sx={{ color: theme.palette.ooditable.success }} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
} else if (sub) {
|
||||
const subStringPrefix = `Substituted by:\n\n`
|
||||
const subString =
|
||||
subStringPrefix +
|
||||
sub
|
||||
.map(
|
||||
course =>
|
||||
`${course.course_code}\nGrade: ${course.grade}\nCompleted on: ${formatDate(course.date, dateFormat)}`
|
||||
)
|
||||
.join('\n\n')
|
||||
|
||||
return (
|
||||
<div title={subString}>
|
||||
<CheckIcon sx={{ color: theme.palette.ooditable.hops }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
+153
-74
@@ -7,7 +7,7 @@ import { useToggle } from '@/hooks/toggle'
|
||||
import { ExtendedCurriculumDetails } from '@/hooks/useCurriculums'
|
||||
import { FilteredCourse } from '@/util/coursesOfPopulation'
|
||||
import { CreditTypeCode, Name, ProgrammeCourse } from '@oodikone/shared/types'
|
||||
import { FormattedStudent } from '@oodikone/shared/types/studentData'
|
||||
import { FormattedStudent, StudentCourse } from '@oodikone/shared/types/studentData'
|
||||
import { IncludeSubstitutionsToggle } from '../../IncludeSubstitutionsToggle'
|
||||
import { useGetColumnDefinitions } from './columnDefinitions'
|
||||
|
||||
@@ -22,7 +22,7 @@ export type Courses = Record<
|
||||
{
|
||||
grade?: string
|
||||
completionDate?: string
|
||||
substitutedBy?: string
|
||||
substitutedBy?: StudentCourse[]
|
||||
inHops?: boolean
|
||||
enrollmentDate?: Date
|
||||
passed?: boolean
|
||||
@@ -48,11 +48,12 @@ const studentMapper = (
|
||||
student: FormattedStudent,
|
||||
includeSubstitutions: boolean,
|
||||
curriculumCourseCodes: string[],
|
||||
substituteToPrimaryCodes: Record<string, string[]>
|
||||
substitutionsForCourseCode: Record<string, string[][]>
|
||||
) => {
|
||||
const courseMap = {}
|
||||
|
||||
// NB: there can be many attainments/enrollments for each course code
|
||||
// All passed courses that are included in curriculum
|
||||
const passedCourses = student.courses.filter(
|
||||
course => curriculumCourseCodes.includes(course.course_code) && course.passed
|
||||
)
|
||||
@@ -61,74 +62,152 @@ const studentMapper = (
|
||||
studyPlan.included_courses.filter(code => curriculumCourseCodes.includes(code))
|
||||
)
|
||||
|
||||
const passedSubstituteCourses = student.courses.filter(course => !!substituteToPrimaryCodes[course.course_code])
|
||||
const substituteEnrollments = student.enrollments.filter(
|
||||
enrollment => !!substituteToPrimaryCodes[enrollment.course_code]
|
||||
)
|
||||
const substituteHopsItems = student.studyplans.flatMap(studyPlan =>
|
||||
studyPlan.included_courses.filter(code => !!substituteToPrimaryCodes[code])
|
||||
// All passed courses including random AY-codes etc
|
||||
const allPassedCourseCodes = student.courses.filter(course => course.passed).map(course => course.course_code)
|
||||
const substitutionsToCurriculumCourses = curriculumCourseCodes.reduce<Record<string, string[][]>>((acc, code) => {
|
||||
const substitutionsToCurriculumCourse = substitutionsForCourseCode[code]
|
||||
if (substitutionsToCurriculumCourse) {
|
||||
acc[code] = substitutionsToCurriculumCourse
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
// curriculumCourseCode -> passedSubstGroup
|
||||
const passedSubstitutionsToCurriculumCourses = Object.keys(substitutionsToCurriculumCourses).reduce<
|
||||
Record<string, StudentCourse[]>
|
||||
>((acc, code) => {
|
||||
const passedSubstitutionGroups = substitutionsToCurriculumCourses[code].filter(substGroup =>
|
||||
substGroup.every(sgCode => allPassedCourseCodes.includes(sgCode))
|
||||
)
|
||||
// TODO: Implement better logic to select the most optimal substitution_groups, now we select shortest and first group
|
||||
// Also this .find (and at(0)!) should never be undefined because the codes are student's completed courses => they exist under student.courses
|
||||
const passedSubstitutionGroupCourses = passedSubstitutionGroups
|
||||
.map(sg => sg.map(code => student.courses.find(course => course.course_code === code)!))
|
||||
.toSorted((a, b) => b.length - a.length)
|
||||
.at(0)! // We know that this will exist
|
||||
if (passedSubstitutionGroups.length) {
|
||||
acc[code] = passedSubstitutionGroupCourses
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const enrollmentCodes = enrollments.map(e => e.course_code)
|
||||
const enrollmentsWithSubstitutions = Object.keys(substitutionsToCurriculumCourses).reduce<
|
||||
Record<string, FormattedStudent['enrollments']>
|
||||
>((acc, code) => {
|
||||
const enrolledSubstitutionGroups = substitutionsToCurriculumCourses[code].filter(substGroup =>
|
||||
substGroup.every(sgCode => enrollmentCodes.includes(sgCode))
|
||||
)
|
||||
// TODO: Same as above
|
||||
const enrolledSubstitutionGroupCourses = enrolledSubstitutionGroups
|
||||
.map(sg => sg.map(code => student.enrollments.find(course => course.course_code === code)!))
|
||||
.toSorted((a, b) => b.length - a.length)
|
||||
.at(0)! // We know that this will exist
|
||||
if (enrolledSubstitutionGroupCourses?.length) {
|
||||
acc[code] = enrolledSubstitutionGroupCourses
|
||||
}
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const hopsItemsWithSubstitutions = Object.keys(substitutionsToCurriculumCourses).reduce<Record<string, string[]>>(
|
||||
(acc, code) => {
|
||||
// TODO: Same as above
|
||||
const hopsSubstitutionGroups = substitutionsToCurriculumCourses[code]
|
||||
.filter(substGroup => substGroup.every(sgCode => hopsItems.includes(sgCode)))
|
||||
.toSorted((a, b) => b.length - a.length)
|
||||
.at(0)!
|
||||
if (hopsSubstitutionGroups?.length) {
|
||||
acc[code] = hopsSubstitutionGroups
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
const mapCourses = (coursesToAdd: typeof passedCourses, substitutions: boolean) => {
|
||||
const mapSubstitutionCourses = (coursesToAdd: typeof passedSubstitutionsToCurriculumCourses) => {
|
||||
for (const [code, substitutionGroup] of Object.entries(coursesToAdd)) {
|
||||
courseMap[code] ??= {
|
||||
substitutedBy: substitutionGroup,
|
||||
exportValue: `Substitutes ${code}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mapCourses = (coursesToAdd: typeof passedCourses) => {
|
||||
for (const course of coursesToAdd) {
|
||||
const codes = substitutions ? substituteToPrimaryCodes[course.course_code] : [course.course_code]
|
||||
codes.forEach(code => {
|
||||
if (!courseMap[code] || compareCourseGrades(courseMap[code], course)) {
|
||||
courseMap[code] = {
|
||||
grade: course.grade,
|
||||
completionDate: course.date,
|
||||
passed: course.passed,
|
||||
substitutedBy: substitutions ? course.course_code : undefined,
|
||||
credittypecode: course.credittypecode,
|
||||
exportValue: course.grade,
|
||||
}
|
||||
const code = course.course_code
|
||||
|
||||
if (!courseMap[code] || compareCourseGrades(courseMap[code], course)) {
|
||||
courseMap[code] = {
|
||||
grade: course.grade,
|
||||
completionDate: course.date,
|
||||
passed: course.passed,
|
||||
substitutedBy: undefined,
|
||||
credittypecode: course.credittypecode,
|
||||
exportValue: course.grade,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mapEnrollments = (enrollmentsToAdd: typeof enrollments, substitutions: boolean) => {
|
||||
const mapSubstitutionEnrollments = (enrollmentsToAdd: typeof enrollmentsWithSubstitutions) => {
|
||||
for (const [code, substitutionGroup] of Object.entries(enrollmentsToAdd)) {
|
||||
courseMap[code] ??= {
|
||||
substitutedBy: substitutionGroup,
|
||||
enrollmentDate: substitutionGroup.at(0)!.enrollment_date_time,
|
||||
exportValue: 'HOPS',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mapEnrollments = (enrollmentsToAdd: typeof enrollments) => {
|
||||
for (const enrollment of enrollmentsToAdd) {
|
||||
const codes = substitutions ? substituteToPrimaryCodes[enrollment.course_code] : [enrollment.course_code]
|
||||
codes.forEach(code => {
|
||||
if (
|
||||
!courseMap[code] ||
|
||||
(!!courseMap[code].enrollmentDate &&
|
||||
new Date(courseMap[code].enrollmentDate) < new Date(enrollment.enrollment_date_time))
|
||||
)
|
||||
courseMap[code] = {
|
||||
enrollmentDate: enrollment.enrollment_date_time,
|
||||
substitutedBy: substitutions ? enrollment.course_code : undefined,
|
||||
exportValue: 'HOPS',
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const mapHopsSelections = (selectionsToAdd: typeof hopsItems, substitutions: boolean) => {
|
||||
for (const selection of selectionsToAdd) {
|
||||
const codes = substitutions ? substituteToPrimaryCodes[selection] : [selection]
|
||||
codes.forEach(code => {
|
||||
courseMap[code] ??= {
|
||||
inHops: true,
|
||||
substitutedBy: substitutions ? selection : undefined,
|
||||
const code = enrollment.course_code
|
||||
if (
|
||||
!courseMap[code] ||
|
||||
(!!courseMap[code].enrollmentDate &&
|
||||
new Date(courseMap[code].enrollmentDate) < new Date(enrollment.enrollment_date_time))
|
||||
)
|
||||
courseMap[code] = {
|
||||
enrollmentDate: enrollment.enrollment_date_time,
|
||||
substitutedBy: undefined,
|
||||
exportValue: 'HOPS',
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
mapCourses(passedCourses, false)
|
||||
if (includeSubstitutions) mapCourses(passedSubstituteCourses, true)
|
||||
const mapHopsSelections = (selectionsToAdd: typeof hopsItems) => {
|
||||
for (const selection of selectionsToAdd) {
|
||||
const code = selection
|
||||
courseMap[code] ??= {
|
||||
inHops: true,
|
||||
substitutedBy: undefined,
|
||||
exportValue: 'HOPS',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mapSubstitutionHopsSelections = (selectionsToAdd: typeof hopsItemsWithSubstitutions) => {
|
||||
for (const [code, substitutionGroup] of Object.entries(selectionsToAdd)) {
|
||||
courseMap[code] ??= {
|
||||
inHops: true,
|
||||
substitutedBy: substitutionGroup,
|
||||
exporValue: 'HOPS',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mapCourses(passedCourses)
|
||||
if (includeSubstitutions) mapSubstitutionCourses(passedSubstitutionsToCurriculumCourses)
|
||||
|
||||
mapEnrollments(enrollments)
|
||||
if (includeSubstitutions) mapSubstitutionEnrollments(enrollmentsWithSubstitutions)
|
||||
|
||||
mapHopsSelections(hopsItems)
|
||||
if (includeSubstitutions) mapSubstitutionHopsSelections(hopsItemsWithSubstitutions)
|
||||
|
||||
const totalPassed = Object.keys(courseMap).length
|
||||
|
||||
mapEnrollments(enrollments, false)
|
||||
if (includeSubstitutions) mapEnrollments(substituteEnrollments, true)
|
||||
|
||||
mapHopsSelections(hopsItems, false)
|
||||
if (includeSubstitutions) mapHopsSelections(substituteHopsItems, true)
|
||||
|
||||
const totalPlanned = Object.keys(courseMap).length - totalPassed
|
||||
|
||||
return {
|
||||
@@ -163,7 +242,7 @@ const gradeOrdering = ['0', 'Hyl.', 'TT', 'HT', '1', '2', '3', '4', '5', 'Hyv.']
|
||||
/**
|
||||
* @returns true if current course (the latter) has the same or better grade than old course
|
||||
*/
|
||||
const compareCourseGrades = (previous, current) =>
|
||||
const compareCourseGrades = (previous: StudentCourse, current: StudentCourse) =>
|
||||
gradeOrdering.indexOf(previous.grade) <= gradeOrdering.indexOf(current.grade)
|
||||
|
||||
export const CoursesTabContainer = ({ curriculum, students, courses }: CoursesTabContainerProps) => {
|
||||
@@ -182,11 +261,12 @@ export const CoursesTabContainer = ({ curriculum, students, courses }: CoursesTa
|
||||
|
||||
const curriculumCourseCodes = useMemo(() => curriculumCourses.map(course => course.code), [curriculumCourses])
|
||||
|
||||
const substitutionCourseCodes = useMemo(
|
||||
// All substitution_groups entries as codes
|
||||
const substitutionGroupsCourseCodes = useMemo(
|
||||
() =>
|
||||
courses
|
||||
.filter(course => curriculumCourseCodes.includes(course.course.code))
|
||||
.flatMap(course => course.course.substitutions),
|
||||
.flatMap(course => course.course.substitution_groups.map(sbGr => sbGr)),
|
||||
[courses, curriculumCourseCodes]
|
||||
)
|
||||
|
||||
@@ -196,20 +276,19 @@ export const CoursesTabContainer = ({ curriculum, students, courses }: CoursesTa
|
||||
* g.g. course code TKT20015 -> BSCS2002/TKT10001 in curriculum 2020-2023
|
||||
* but most of the time there is only one main code
|
||||
*/
|
||||
const substituteToPrimaryCodes: Record<string, string[]> = useMemo(
|
||||
|
||||
// All substitutionGroups for a given course code => course.substitution_groups
|
||||
const substitutionsForCourseCode: Record<string, string[][]> = useMemo(
|
||||
() =>
|
||||
courses
|
||||
.filter(course => substitutionCourseCodes.includes(course.course.code))
|
||||
.reduce((acc, course) => {
|
||||
const mainCodes = course.course.substitutions.filter(sub => curriculumCourseCodes.includes(sub))
|
||||
// If no mainCode in curriculum, leave blank as nothing would be shown in course tab anyway
|
||||
if (mainCodes.length) {
|
||||
acc[course.course.code] ??= []
|
||||
acc[course.course.code].push(...mainCodes)
|
||||
}
|
||||
return acc
|
||||
}, {}),
|
||||
[curriculumCourseCodes, substitutionCourseCodes, courses]
|
||||
courses.reduce((acc, course) => {
|
||||
const substitutionGroups = course.course.substitution_groups ?? []
|
||||
if (substitutionGroups.length) {
|
||||
acc[course.course.code] ??= []
|
||||
acc[course.course.code].push(...substitutionGroups.filter(group => group.length))
|
||||
}
|
||||
return acc
|
||||
}, {}),
|
||||
[substitutionGroupsCourseCodes, courses]
|
||||
)
|
||||
|
||||
const coursesByParentModule = useMemo(() => {
|
||||
@@ -232,9 +311,9 @@ export const CoursesTabContainer = ({ curriculum, students, courses }: CoursesTa
|
||||
const formattedStudents = useMemo(
|
||||
() =>
|
||||
students.map(student =>
|
||||
studentMapper(student, includeSubstitutions, curriculumCourseCodes, substituteToPrimaryCodes)
|
||||
studentMapper(student, includeSubstitutions, curriculumCourseCodes, substitutionsForCourseCode)
|
||||
),
|
||||
[students, studentMapper]
|
||||
[students, studentMapper, includeSubstitutions]
|
||||
)
|
||||
|
||||
const columns = useGetColumnDefinitions(coursesByParentModule)
|
||||
|
||||
+1
-1
@@ -168,7 +168,7 @@ export const useFormat = ({
|
||||
/* BASE COLUMNS */
|
||||
studentNumber: student.obfuscated ? 'Hidden' : student.studentNumber,
|
||||
creditsTotal: student.credits,
|
||||
creditsHops: student.hopsCredits,
|
||||
creditsHops: programmeDetails?.primaryStudyplan?.completed_credits ?? 0,
|
||||
|
||||
creditsCombinedProg: !!combinedProgramme || showBachelorAndMaster ? getCombinedCredits(studentBlob) : null,
|
||||
creditsSince: getCreditsBetween(
|
||||
|
||||
+6
-15
@@ -1,10 +1,8 @@
|
||||
import dayjs, { extend as dayjsExtend } from 'dayjs'
|
||||
import isBetween from 'dayjs/plugin/isBetween'
|
||||
|
||||
import { populationStatisticsToolTips } from '@/common/InfoToolTips'
|
||||
import { PanelView } from '@/components/common/PanelView'
|
||||
import { ToggleWithTooltip } from '@/components/common/toggle/ToggleWithTooltip'
|
||||
import { CreditAccumulationGraphHighCharts } from '@/components/CreditAccumulationGraphHighCharts'
|
||||
import { creditDateFilter, hopsFilter as studyPlanFilter } from '@/components/FilterView/filters'
|
||||
import { useFilters } from '@/components/FilterView/useFilters'
|
||||
import { AgeStats } from '@/components/PopulationDetails/AgeStats'
|
||||
@@ -19,6 +17,7 @@ import { useCurriculumState } from '../../hooks/useCurriculums'
|
||||
import { useColumns as columnsGeneralTab } from './studentColumns'
|
||||
import { StudyGuidanceGroupPopulationCourses } from './StudyGuidanceGroupPopulationCourses'
|
||||
import { createAcademicYearStartDate } from './utils'
|
||||
import { CreditAccumulationGraph } from '../Charts/CreditAccumulation'
|
||||
|
||||
dayjsExtend(isBetween)
|
||||
|
||||
@@ -73,27 +72,19 @@ export const SingleStudyGuidanceGroupPanels = ({
|
||||
onChange={toggleCreditDateFilter}
|
||||
/>
|
||||
)}
|
||||
<CreditAccumulationGraphHighCharts
|
||||
absences={null}
|
||||
endDate={null}
|
||||
infoBoxContent={populationStatisticsToolTips.creditAccumulation}
|
||||
<CreditAccumulationGraph
|
||||
programmeCodes={group?.tags?.studyProgramme ? [programme, combinedProgramme] : []}
|
||||
selectedStudyPlan={null}
|
||||
showBachelorAndMaster={null}
|
||||
singleStudent={false}
|
||||
startDate={null}
|
||||
students={filteredStudents}
|
||||
studyPlanFilterIsActive={studyPlanFilterIsActive}
|
||||
studyRightId={null}
|
||||
studyPlanFilter={studyPlanFilterIsActive}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
(programme || group?.tags?.studyProgramme) && groupYear
|
||||
? {
|
||||
title: 'Credit statistics',
|
||||
content: <CreditStatistics filteredStudents={filteredStudents} query={query} />,
|
||||
}
|
||||
title: 'Credit statistics',
|
||||
content: <CreditStatistics filteredStudents={filteredStudents} query={query} />,
|
||||
}
|
||||
: null,
|
||||
{
|
||||
title: 'Age distribution',
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import Chip from '@mui/material/Chip'
|
||||
import { SwapHorizIcon } from '@/theme'
|
||||
|
||||
export const GroupChip = ({ group }: { group: string[] }) => (
|
||||
<Chip
|
||||
icon={<SwapHorizIcon color="primary" fontSize="small" />}
|
||||
label={group.join(', ')}
|
||||
sx={{ my: 0.25, width: 'fit-content' }}
|
||||
/>
|
||||
)
|
||||
@@ -28,7 +28,7 @@ export const ExpandableRow = ({
|
||||
return (
|
||||
<TableRow>
|
||||
{yearArray?.map((value, index) => (
|
||||
<TableCell align="right" key={getKey(value, index)}>
|
||||
<TableCell align="right" key={getKey(value, index)} sx={{ px: 0.5, py: 0.75 }}>
|
||||
{index === 0 ? (
|
||||
<Box alignItems="center" display="flex" justifyContent="left">
|
||||
<IconButton data-cy={`${cypress}${yearIndex}`} onClick={toggleVisibility} size="small">
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import accessibility from 'highcharts/modules/accessibility'
|
||||
import exportData from 'highcharts/modules/export-data'
|
||||
import exporting from 'highcharts/modules/exporting'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
|
||||
import { Section } from '@/components/Section'
|
||||
import { theme } from '@/theme'
|
||||
import { GraphStat } from '@/types/graphStat'
|
||||
|
||||
exporting(ReactHighcharts.Highcharts)
|
||||
exportData(ReactHighcharts.Highcharts)
|
||||
accessibility(ReactHighcharts.Highcharts)
|
||||
|
||||
export const LineGraph = ({
|
||||
cypress,
|
||||
exportFileName,
|
||||
graphStats,
|
||||
years,
|
||||
}: {
|
||||
cypress: string
|
||||
exportFileName: string
|
||||
graphStats: GraphStat[]
|
||||
years: number[]
|
||||
}) => {
|
||||
if (!graphStats) {
|
||||
return null
|
||||
}
|
||||
|
||||
const dataWithColors = graphStats.map((series, index) => ({
|
||||
...series,
|
||||
color: theme.palette.graphColors[index],
|
||||
type: 'line' as const,
|
||||
}))
|
||||
|
||||
const config = {
|
||||
series: dataWithColors,
|
||||
exporting: {
|
||||
filename: exportFileName,
|
||||
},
|
||||
chart: {
|
||||
height: '450px',
|
||||
},
|
||||
xAxis: {
|
||||
categories: years.map(String),
|
||||
},
|
||||
yAxis: {
|
||||
allowDecimals: false,
|
||||
min: 0,
|
||||
reversed: false,
|
||||
title: {
|
||||
text: '',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<Section cypress={`${cypress}-line-graph`}>
|
||||
<ReactHighcharts config={config} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
/* eslint-disable react/no-this-in-sfc */
|
||||
import Highcharts from 'highcharts' // eslint-disable-line import-x/default
|
||||
import accessibility from 'highcharts/modules/accessibility'
|
||||
import exportData from 'highcharts/modules/export-data'
|
||||
import exporting from 'highcharts/modules/exporting'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
|
||||
import { Section } from '@/components/Section'
|
||||
import type { GraduationStatistics } from '@oodikone/shared/types'
|
||||
import { ChartGraduationTimes } from '@oodikone/shared/types/graduations'
|
||||
|
||||
exporting(ReactHighcharts.Highcharts)
|
||||
exportData(ReactHighcharts.Highcharts)
|
||||
accessibility(ReactHighcharts.Highcharts)
|
||||
|
||||
export const MedianTimeBarChart = ({
|
||||
byStartYear,
|
||||
data,
|
||||
goal,
|
||||
title,
|
||||
}: {
|
||||
byStartYear: boolean
|
||||
data: ChartGraduationTimes['medians'] | undefined
|
||||
goal: ChartGraduationTimes['goal']
|
||||
title: string
|
||||
}) => {
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
|
||||
const maxValue = data.reduce((max: number, { y }) => {
|
||||
return y > max ? y : max
|
||||
}, goal * 2)
|
||||
|
||||
const getPercentage = (amount: number, classSize: number) => {
|
||||
const percent = Math.round((amount / classSize) * 100 * 10) / 10
|
||||
return Number.isNaN(percent) ? 0 : percent
|
||||
}
|
||||
|
||||
const getDataLabel = (amount: number, classSize: number) => {
|
||||
if (byStartYear && title !== 'Bachelor + master study right') {
|
||||
return `${amount} graduated (${getPercentage(amount, classSize)} % of class)`
|
||||
}
|
||||
return `${amount} graduated`
|
||||
}
|
||||
|
||||
const getHeight = () => {
|
||||
const multiplier = data.length > 8 ? 35 : 55
|
||||
return data.length * multiplier + 100
|
||||
}
|
||||
|
||||
const getTooltipText = (
|
||||
amount: number,
|
||||
y: number,
|
||||
year: string,
|
||||
statistics: GraduationStatistics,
|
||||
classSize: number
|
||||
) => {
|
||||
const sortingText =
|
||||
byStartYear && title !== 'Bachelor + master study right'
|
||||
? `<b>From class of ${year}, ${amount}/${classSize} students have graduated</b>`
|
||||
: `<b>${amount} students graduated in year ${year}</b>`
|
||||
|
||||
const timeText = `<br />${sortingText}<br /><b>median study time: ${y} months</b><br />`
|
||||
|
||||
const statisticsText = `<br />${statistics.onTime} graduated on time<br />${statistics.yearOver} graduated max year overtime<br />${statistics.wayOver} graduated over year late`
|
||||
|
||||
return `${timeText}${statisticsText}`
|
||||
}
|
||||
|
||||
const config: Highcharts.Options = {
|
||||
chart: {
|
||||
type: 'bar',
|
||||
width: 700,
|
||||
margin: [70, 0],
|
||||
height: getHeight(),
|
||||
},
|
||||
title: { text: title },
|
||||
tooltip: {
|
||||
backgroundColor: 'white',
|
||||
formatter() {
|
||||
const point = this.point as Highcharts.Point & {
|
||||
amount: number
|
||||
name: string
|
||||
statistics: GraduationStatistics
|
||||
classSize: number
|
||||
}
|
||||
return getTooltipText(point.amount, this.y!, point.name, point.statistics, point.classSize)
|
||||
},
|
||||
},
|
||||
plotOptions: {
|
||||
series: {
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
inside: true,
|
||||
overflow: 'allow' as const,
|
||||
},
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data,
|
||||
dataLabels: {
|
||||
align: 'left',
|
||||
color: '#424949',
|
||||
style: {
|
||||
textOutline: 'none',
|
||||
},
|
||||
formatter() {
|
||||
const point = this.point as Highcharts.Point & { amount: number; classSize: number }
|
||||
return getDataLabel(point.amount, point.classSize)
|
||||
},
|
||||
},
|
||||
showInLegend: false,
|
||||
zones: [
|
||||
{
|
||||
value: goal + 0.1,
|
||||
color: '#90A959',
|
||||
},
|
||||
{
|
||||
value: goal + 12.1,
|
||||
color: '#FEE191',
|
||||
},
|
||||
{
|
||||
color: '#FB6962',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
title: {
|
||||
text: byStartYear ? 'Start year' : 'Graduation year',
|
||||
align: 'high',
|
||||
offset: 0,
|
||||
rotation: 0,
|
||||
y: -10,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
min: 0,
|
||||
max: maxValue,
|
||||
title: { text: 'Graduation time (months)' },
|
||||
labels: {
|
||||
overflow: 'justify',
|
||||
},
|
||||
allowDecimals: false,
|
||||
showFirstLabel: false,
|
||||
plotLines: [
|
||||
{
|
||||
color: '#90A959',
|
||||
width: 2,
|
||||
value: goal,
|
||||
dashStyle: 'ShortDash',
|
||||
},
|
||||
{
|
||||
color: '#FEE191',
|
||||
width: 2,
|
||||
value: goal + 12,
|
||||
dashStyle: 'ShortDash',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<Section cypress={`${title.split(' ')[0].toLowerCase()}-median-time-bar-chart`}>
|
||||
<ReactHighcharts config={config} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { OptionsStackingValue, SeriesColumnOptions } from 'highcharts'
|
||||
import accessibility from 'highcharts/modules/accessibility'
|
||||
import exportData from 'highcharts/modules/export-data'
|
||||
import exporting from 'highcharts/modules/exporting'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
|
||||
import { Section } from '@/components/Section'
|
||||
import { generateGradientColors } from '@/util/color'
|
||||
|
||||
exporting(ReactHighcharts.Highcharts)
|
||||
exportData(ReactHighcharts.Highcharts)
|
||||
accessibility(ReactHighcharts.Highcharts)
|
||||
|
||||
export const ProgressBarChart = ({
|
||||
cypress,
|
||||
data,
|
||||
}: {
|
||||
cypress: string
|
||||
data: {
|
||||
id: string
|
||||
stats: {
|
||||
data: number[]
|
||||
name: string
|
||||
}[]
|
||||
years: string[]
|
||||
}
|
||||
}) => {
|
||||
if (!data.stats) {
|
||||
return null
|
||||
}
|
||||
|
||||
const colors = generateGradientColors(Object.keys(data.stats).length - 1)
|
||||
colors.push('#ddd') // For graduated students
|
||||
const dataWithColors: SeriesColumnOptions[] = Object.values(data.stats).map((series, index) => ({
|
||||
...series,
|
||||
color: colors[index],
|
||||
type: 'column',
|
||||
}))
|
||||
|
||||
const config = {
|
||||
series: dataWithColors,
|
||||
xAxis: {
|
||||
categories: data?.years,
|
||||
},
|
||||
chart: {
|
||||
type: 'column',
|
||||
height: '450px',
|
||||
},
|
||||
plotOptions: {
|
||||
column: {
|
||||
stacking: 'percent' as OptionsStackingValue,
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
format: '{point.percentage:.1f}%',
|
||||
},
|
||||
},
|
||||
},
|
||||
exporting: {
|
||||
filename: `oodikone_progress_of_students_in_${data?.id}_by_starting_year`,
|
||||
},
|
||||
yAxis: {
|
||||
allowDecimals: false,
|
||||
min: 0,
|
||||
reversed: false,
|
||||
title: {
|
||||
text: '',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<Section cypress={`${cypress}-progress-bar-chart`}>
|
||||
<ReactHighcharts config={config} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
/* eslint-disable react/no-this-in-sfc */
|
||||
import HighCharts from 'highcharts' // eslint-disable-line import-x/default
|
||||
import accessibility from 'highcharts/modules/accessibility'
|
||||
import exportData from 'highcharts/modules/export-data'
|
||||
import exporting from 'highcharts/modules/exporting'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
|
||||
import { Section } from '@/components/Section'
|
||||
import { theme } from '@/theme'
|
||||
|
||||
exporting(ReactHighcharts.Highcharts)
|
||||
exportData(ReactHighcharts.Highcharts)
|
||||
accessibility(ReactHighcharts.Highcharts)
|
||||
|
||||
export const StackedBarChart = ({
|
||||
cypress,
|
||||
data,
|
||||
labels,
|
||||
exportFileName = 'chart',
|
||||
}: {
|
||||
cypress: string
|
||||
data?: { data: number[]; name: string }[]
|
||||
labels?: string[]
|
||||
exportFileName: string
|
||||
}) => {
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
|
||||
const dataWithColors = data?.map((series, index) => ({
|
||||
...series,
|
||||
color: theme.palette.graphColors[index],
|
||||
type: 'column' as const,
|
||||
}))
|
||||
|
||||
const config: HighCharts.Options = {
|
||||
series: dataWithColors,
|
||||
xAxis: {
|
||||
categories: labels,
|
||||
allowDecimals: false,
|
||||
},
|
||||
chart: {
|
||||
type: 'column',
|
||||
height: '450px',
|
||||
},
|
||||
exporting: {
|
||||
filename: exportFileName,
|
||||
},
|
||||
plotOptions: {
|
||||
column: {
|
||||
stacking: 'normal',
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
formatter() {
|
||||
if (Number.isInteger(this.y)) return `${this.y}`
|
||||
return `${this.y?.toFixed(1)}`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
pointFormat: '<b>{series.name}: {point.percentage:.1f} %</b>',
|
||||
},
|
||||
yAxis: {
|
||||
allowDecimals: false,
|
||||
min: 0,
|
||||
reversed: false,
|
||||
title: {
|
||||
text: '',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<Section cypress={`${cypress}-stacked-bar-chart`}>
|
||||
<ReactHighcharts config={config} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,27 @@
|
||||
import Alert from '@mui/material/Alert'
|
||||
import AlertTitle from '@mui/material/AlertTitle'
|
||||
import type { SxProps } from '@mui/material/styles'
|
||||
|
||||
export const StyledMessage = ({ children, sx: propSx }: { children: React.ReactNode; sx?: SxProps }) => (
|
||||
<Alert icon={false} severity="info" sx={{ margin: 'auto', maxWidth: '800px', ...propSx }} variant="outlined">
|
||||
export const StyledMessage = ({
|
||||
children,
|
||||
variant = 'outlined',
|
||||
sx: propSx,
|
||||
showIcon,
|
||||
title,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
variant?: 'outlined' | 'standard' | 'filled'
|
||||
sx?: SxProps
|
||||
showIcon?: boolean
|
||||
title?: string
|
||||
}) => (
|
||||
<Alert
|
||||
icon={showIcon ? null : false} // Null keeps icon enabled (default behaviour)
|
||||
severity="info"
|
||||
sx={{ margin: 'auto', maxWidth: '800px', ...propSx }}
|
||||
variant={variant}
|
||||
>
|
||||
{title?.length ? <AlertTitle>{title}</AlertTitle> : null}
|
||||
{children}
|
||||
</Alert>
|
||||
)
|
||||
|
||||
@@ -6,13 +6,34 @@ import Table from '@mui/material/Table'
|
||||
* To be paired with StyledCell
|
||||
*/
|
||||
export const StyledTable = styled(Table, {
|
||||
shouldForwardProp: prop => prop !== 'showCellBorders' && prop !== 'zebraStriped',
|
||||
})<{ showCellBorders?: true; zebraStriped?: true }>(({ theme, showCellBorders, zebraStriped }) => {
|
||||
shouldForwardProp: prop =>
|
||||
prop !== 'showCellBorders' && prop !== 'zebraStriped' && prop !== 'slimHeader' && prop !== 'slimBody',
|
||||
})<{ showCellBorders?: true; zebraStriped?: true; slimHeader?: true; slimBody?: true }>(({
|
||||
theme,
|
||||
showCellBorders,
|
||||
zebraStriped,
|
||||
slimHeader,
|
||||
slimBody,
|
||||
}) => {
|
||||
return {
|
||||
'&': {
|
||||
border: `1px solid ${theme.palette.grey[300]}`,
|
||||
},
|
||||
|
||||
'& .MuiTableHead-root .MuiTableCell-root': slimHeader
|
||||
? {
|
||||
paddingTop: '0.5em',
|
||||
paddingBottom: '0.5em',
|
||||
}
|
||||
: {},
|
||||
|
||||
'& .MuiTableBody-root .MuiTableCell-root': slimBody
|
||||
? {
|
||||
paddingTop: '0.5em',
|
||||
paddingBottom: '0.5em',
|
||||
}
|
||||
: {},
|
||||
|
||||
'& .MuiTableHead-root': {
|
||||
backgroundColor: theme.palette.grey[100],
|
||||
border: `1px solid ${theme.palette.grey[300]}`,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import Chip from '@mui/material/Chip'
|
||||
|
||||
import { StarIcon, SwapHorizIcon } from '@/theme'
|
||||
import { GroupChip } from '@/components/common/EquivalenceGroupChip'
|
||||
import { StarIcon } from '@/theme'
|
||||
|
||||
export const PrimaryCourseLabel = ({ code, name }: { code: string; name: string }) => (
|
||||
<Chip color={'primary'} icon={<StarIcon fontSize="small" />} label={`${code} • ${name}`} />
|
||||
)
|
||||
|
||||
export const SecondaryCourseLabel = ({ code, name }: { code: string; name: string }) => (
|
||||
<Chip icon={<SwapHorizIcon fontSize="small" />} label={`${code} • ${name}`} />
|
||||
<GroupChip group={[`${code} • ${name}`]} />
|
||||
)
|
||||
|
||||
+94
-18
@@ -1,18 +1,30 @@
|
||||
import useTheme from '@mui/material/styles/useTheme'
|
||||
|
||||
import accessibility from 'highcharts/modules/accessibility'
|
||||
import exportData from 'highcharts/modules/export-data'
|
||||
import exporting from 'highcharts/modules/exporting'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
|
||||
import { TotalsDisclaimer } from '@/components/common/TotalsDisclaimer'
|
||||
import { ProgrammeStats, ViewMode } from '@/types/courseStat'
|
||||
import { getGradeSpread, getSeriesType, getThesisGradeSpread } from '../util'
|
||||
import { absoluteToRelative, getDataObject, getGraphOptions, getMaxValueOfSeries } from './util'
|
||||
import { absoluteToRelative, getDataObject, getMaxValueOfSeries } from './util'
|
||||
|
||||
exporting(ReactHighcharts.Highcharts)
|
||||
exportData(ReactHighcharts.Highcharts)
|
||||
accessibility(ReactHighcharts.Highcharts)
|
||||
const formatNumber = (value: unknown) => {
|
||||
const numericValue = typeof value === 'number' ? value : Number(value)
|
||||
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (Number.isInteger(numericValue)) {
|
||||
return `${numericValue}`
|
||||
}
|
||||
|
||||
return `${numericValue.toFixed(2)}`
|
||||
}
|
||||
|
||||
const getNumericValue = (value: unknown) => {
|
||||
const numericValue = typeof value === 'number' ? value : Number(value)
|
||||
return Number.isFinite(numericValue) ? numericValue : 0
|
||||
}
|
||||
|
||||
const calculateSumAll = (newSeries: Record<string, number[]>) => {
|
||||
return Object.values(newSeries)[0].map((_, index) =>
|
||||
@@ -100,19 +112,83 @@ export const GradeDistributionChart = ({
|
||||
const seriesType = getSeriesType(grades)
|
||||
const maxGradeValue = isRelative ? 100 : getMaxValueOfSeries(series.absolute)
|
||||
|
||||
const graphOptions = getGraphOptions(
|
||||
colors[seriesType],
|
||||
colors[seriesType],
|
||||
isRelative,
|
||||
maxGradeValue,
|
||||
statYears,
|
||||
`Grades for group ${data.name}`,
|
||||
viewMode
|
||||
)
|
||||
const chartSeries = isRelative ? series.relative : series.absolute
|
||||
const chartColors = colors[seriesType]
|
||||
const yAxisTitle = isRelative ? `Share of ${viewMode.toLowerCase()}` : `Number of ${viewMode.toLowerCase()}`
|
||||
|
||||
const option = {
|
||||
color: chartColors,
|
||||
title: {
|
||||
text: `Grades for group ${data.name}`,
|
||||
left: 'center',
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
dataView: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: (params: { seriesName?: string; value?: unknown; dataIndex?: number; name?: string }) => {
|
||||
const value = getNumericValue(params.value)
|
||||
const category = params.name ?? ''
|
||||
const categoryLine = category ? `<b>${category}</b><br/>` : ''
|
||||
|
||||
if (isRelative) {
|
||||
const dataIndex = params.dataIndex ?? 0
|
||||
const total = chartSeries.reduce((sum, seriesItem) => sum + getNumericValue(seriesItem.data[dataIndex]), 0)
|
||||
const percentage = total ? (value / total) * 100 : 0
|
||||
return `${categoryLine}<b>${params.seriesName ?? ''}</b>: ${formatNumber(percentage)}%`
|
||||
}
|
||||
|
||||
return `${categoryLine}<b>${params.seriesName ?? ''}</b>: ${formatNumber(value)}`
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
bottom: 0,
|
||||
},
|
||||
grid: {
|
||||
top: 60,
|
||||
left: 10,
|
||||
right: 10,
|
||||
bottom: 60,
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: statYears,
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: isRelative ? 100 : maxGradeValue,
|
||||
minInterval: 1,
|
||||
name: yAxisTitle,
|
||||
axisLabel: {
|
||||
margin: 50,
|
||||
formatter: isRelative ? '{value}%' : '{value}',
|
||||
},
|
||||
},
|
||||
series: chartSeries.map((seriesItem, index) => ({
|
||||
name: seriesItem.name,
|
||||
type: 'bar',
|
||||
stack: seriesItem.stack,
|
||||
data: seriesItem.data,
|
||||
itemStyle: {
|
||||
borderRadius: 1,
|
||||
color: chartColors[index],
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
},
|
||||
})),
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ReactHighcharts config={{ ...graphOptions, series: isRelative ? series.relative : series.absolute }} />
|
||||
<ReactECharts notMerge option={option} opts={{ renderer: 'svg' }} style={{ height: 450 }} />
|
||||
<TotalsDisclaimer shownAsZero userHasAccessToAllStats={userHasAccessToAllStats} />
|
||||
</div>
|
||||
)
|
||||
|
||||
+93
-19
@@ -1,17 +1,29 @@
|
||||
import useTheme from '@mui/material/styles/useTheme'
|
||||
|
||||
import accessibility from 'highcharts/modules/accessibility'
|
||||
import exportData from 'highcharts/modules/export-data'
|
||||
import exporting from 'highcharts/modules/exporting'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
|
||||
import { TotalsDisclaimer } from '@/components/common/TotalsDisclaimer'
|
||||
import { FormattedStats, ProgrammeStats, ViewMode } from '@/types/courseStat'
|
||||
import { absoluteToRelative, getDataObject, getGraphOptions, getMaxValueOfSeries } from './util'
|
||||
import { absoluteToRelative, getDataObject } from './util'
|
||||
|
||||
exporting(ReactHighcharts.Highcharts)
|
||||
exportData(ReactHighcharts.Highcharts)
|
||||
accessibility(ReactHighcharts.Highcharts)
|
||||
const formatNumber = (value: unknown) => {
|
||||
const numericValue = typeof value === 'number' ? value : Number(value)
|
||||
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (Number.isInteger(numericValue)) {
|
||||
return `${numericValue}`
|
||||
}
|
||||
|
||||
return `${numericValue.toFixed(2)}`
|
||||
}
|
||||
|
||||
const getNumericValue = (value: unknown) => {
|
||||
const numericValue = typeof value === 'number' ? value : Number(value)
|
||||
return Number.isFinite(numericValue) ? numericValue : 0
|
||||
}
|
||||
|
||||
const getPassRateSeries = (stats: FormattedStats[], viewMode: ViewMode) => {
|
||||
const all: number[] = []
|
||||
@@ -73,21 +85,83 @@ export const PassRateChart = ({
|
||||
const statYears = stats.map(year => year.name)
|
||||
|
||||
const series = getPassRateSeries(stats, viewMode)
|
||||
const maxPassRateVal = isRelative ? 100 : getMaxValueOfSeries(series.absolute)
|
||||
const chartSeries = isRelative ? series.relative : series.absolute
|
||||
const chartColors = isRelative ? colorsRelative : colors
|
||||
const yAxisTitle = isRelative ? `Share of ${viewMode.toLowerCase()}` : `Number of ${viewMode.toLowerCase()}`
|
||||
|
||||
const graphOptions = getGraphOptions(
|
||||
colors,
|
||||
colorsRelative,
|
||||
isRelative,
|
||||
maxPassRateVal,
|
||||
statYears,
|
||||
`Pass rate for group ${data.name}`,
|
||||
viewMode
|
||||
)
|
||||
const option = {
|
||||
color: chartColors,
|
||||
title: {
|
||||
text: `Pass rate for group ${data.name}`,
|
||||
left: 'center',
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
dataView: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: (params: { seriesName?: string; value?: unknown; dataIndex?: number; name?: string }) => {
|
||||
const value = getNumericValue(params.value)
|
||||
const category = params.name ?? ''
|
||||
const categoryLine = category ? `<b>${category}</b><br/>` : ''
|
||||
|
||||
if (isRelative) {
|
||||
const dataIndex = params.dataIndex ?? 0
|
||||
const total = chartSeries.reduce((sum, seriesItem) => sum + getNumericValue(seriesItem.data[dataIndex]), 0)
|
||||
const percentage = total ? (value / total) * 100 : 0
|
||||
return `${categoryLine}<b>${params.seriesName ?? ''}</b>: ${formatNumber(percentage)}%`
|
||||
}
|
||||
|
||||
return `${categoryLine}<b>${params.seriesName ?? ''}</b>: ${formatNumber(value)}`
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
bottom: 0,
|
||||
},
|
||||
grid: {
|
||||
top: 60,
|
||||
left: 10,
|
||||
right: 10,
|
||||
bottom: 60,
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: statYears,
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: isRelative ? 100 : undefined,
|
||||
minInterval: 1,
|
||||
name: yAxisTitle,
|
||||
axisLabel: {
|
||||
margin: 50,
|
||||
formatter: isRelative ? '{value}%' : '{value}',
|
||||
},
|
||||
},
|
||||
series: chartSeries.map((seriesItem, index) => ({
|
||||
name: seriesItem.name,
|
||||
type: 'bar',
|
||||
stack: seriesItem.stack,
|
||||
data: seriesItem.data,
|
||||
itemStyle: {
|
||||
borderRadius: 0,
|
||||
color: chartColors[index],
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
},
|
||||
})),
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ReactHighcharts config={{ ...graphOptions, series: isRelative ? series.relative : series.absolute }} />
|
||||
<ReactECharts notMerge option={option} opts={{ renderer: 'svg' }} style={{ height: 450 }} />
|
||||
<TotalsDisclaimer shownAsZero userHasAccessToAllStats={userHasAccessToAllStats} />
|
||||
</div>
|
||||
)
|
||||
|
||||
+86
-94
@@ -1,18 +1,30 @@
|
||||
import { MaterialReactTable, MRT_ColumnDef, useMaterialReactTable } from 'material-react-table'
|
||||
import { createColumnHelper, TableOptions } from '@tanstack/react-table'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { ExportToExcelDialog } from '@/components/common/MRTExcelExport'
|
||||
import { TotalsDisclaimer } from '@/components/common/TotalsDisclaimer'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { OodiTable } from '@/components/OodiTable'
|
||||
import { OodiTableExcelExport } from '@/components/OodiTable/excelExport'
|
||||
import { CourseSearchState } from '@/pages/CourseStatistics'
|
||||
import { FormattedStats } from '@/types/courseStat'
|
||||
import { getDefaultMRTOptions } from '@/util/getDefaultMRTOptions'
|
||||
import { FormattedStats, ProgrammeStats } from '@/types/courseStat'
|
||||
|
||||
import { queryParamsToString } from '@/util/queryparams'
|
||||
import { getGradeSpread, getThesisGradeSpread, isThesisGrades } from '../util'
|
||||
import { ObfuscatedCell } from './ObfuscatedCell'
|
||||
import { TimeCell } from './TimeCell'
|
||||
import { commonOptions, formatPercentage, getGradeColumns, resolveGrades } from './util'
|
||||
import { formatPercentage, getGradeColumns, resolveGrades } from './util'
|
||||
|
||||
const getTableData = (stats: FormattedStats[], useThesisGrades: boolean) => {
|
||||
type TableData = {
|
||||
name: string
|
||||
code: number
|
||||
totalAttempts: number
|
||||
passed: number
|
||||
failed: number
|
||||
passRate: string
|
||||
enrollments?: number
|
||||
rowObfuscated?: boolean
|
||||
grades: Record<string, number>
|
||||
}
|
||||
|
||||
const getTableData = (stats: FormattedStats[], useThesisGrades: boolean): TableData[] => {
|
||||
return stats.map(stat => {
|
||||
const {
|
||||
name,
|
||||
@@ -42,7 +54,7 @@ const getTableData = (stats: FormattedStats[], useThesisGrades: boolean) => {
|
||||
}
|
||||
|
||||
export const AttemptsTable = ({
|
||||
data: { name, stats },
|
||||
data: { stats },
|
||||
separate,
|
||||
showGrades,
|
||||
userHasAccessToAllStats,
|
||||
@@ -50,7 +62,7 @@ export const AttemptsTable = ({
|
||||
openOrRegular,
|
||||
alternatives,
|
||||
}: {
|
||||
data: { name: string; stats: FormattedStats[] }
|
||||
data: ProgrammeStats
|
||||
separate: boolean
|
||||
showGrades: boolean
|
||||
userHasAccessToAllStats: boolean
|
||||
@@ -58,14 +70,10 @@ export const AttemptsTable = ({
|
||||
openOrRegular: CourseSearchState
|
||||
alternatives: string[]
|
||||
}) => {
|
||||
const { language } = useLanguage()
|
||||
|
||||
const [exportModalOpen, setExportModalOpen] = useState(false)
|
||||
const [exportData, setExportData] = useState<Record<string, unknown>[]>([])
|
||||
const [columnVisibility, setColumnVisibility] = useState<Record<string, boolean>>({})
|
||||
|
||||
const showPopulation = useCallback(
|
||||
(yearCode: string) => {
|
||||
(yearCode: number) => {
|
||||
const queryObject = {
|
||||
from: yearCode,
|
||||
to: yearCode,
|
||||
@@ -81,51 +89,12 @@ export const AttemptsTable = ({
|
||||
|
||||
const useThesisGrades = isThesisGrades(stats[0].attempts.grades)
|
||||
const data = useMemo(() => getTableData(stats, useThesisGrades), [stats, useThesisGrades])
|
||||
|
||||
const columns = useMemo<MRT_ColumnDef<any>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Time',
|
||||
Cell: ({ cell, row }) => (
|
||||
<TimeCell
|
||||
href={showPopulation(row.original.code)}
|
||||
isEmptyRow={row.original.totalAttempts === 0}
|
||||
name={cell.getValue<string>()}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
/>
|
||||
),
|
||||
sortingFn: (rowA, rowB) => rowB.original.code - rowA.original.code,
|
||||
},
|
||||
{
|
||||
accessorKey: 'totalAttempts',
|
||||
header: 'Total attempts',
|
||||
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>()),
|
||||
},
|
||||
{
|
||||
accessorKey: 'passed',
|
||||
header: 'Passed',
|
||||
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>() || 0),
|
||||
},
|
||||
{
|
||||
accessorKey: 'failed',
|
||||
header: 'Failed',
|
||||
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>() || 0),
|
||||
},
|
||||
{
|
||||
accessorKey: 'passRate',
|
||||
header: 'Pass rate',
|
||||
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<string>()),
|
||||
},
|
||||
{
|
||||
accessorKey: 'enrollments',
|
||||
header: 'Enrollments',
|
||||
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>()),
|
||||
},
|
||||
...getGradeColumns(resolveGrades(stats)),
|
||||
],
|
||||
[showPopulation, stats, userHasAccessToAllStats]
|
||||
)
|
||||
const excelData = useMemo(() => {
|
||||
return data.map(({ grades, ...rest }) => ({
|
||||
...rest,
|
||||
...Object.fromEntries(Object.entries(grades).map(([key, val]) => [key, val])),
|
||||
}))
|
||||
}, [data])
|
||||
|
||||
useEffect(() => {
|
||||
setColumnVisibility(prev => {
|
||||
@@ -142,51 +111,74 @@ export const AttemptsTable = ({
|
||||
updatedVisibility.passRate = !showGrades
|
||||
updatedVisibility.enrollments = !showGrades
|
||||
|
||||
return { ...updatedVisibility }
|
||||
return {
|
||||
...updatedVisibility,
|
||||
name: true,
|
||||
totalAttempts: true,
|
||||
}
|
||||
})
|
||||
}, [showGrades, stats])
|
||||
|
||||
const defaultOptions = getDefaultMRTOptions(setExportData, setExportModalOpen, language)
|
||||
const columnHelper = createColumnHelper<TableData>()
|
||||
const ooditable_columns = useMemo(
|
||||
() => [
|
||||
columnHelper.accessor('name', {
|
||||
header: 'Time',
|
||||
cell: ctx => (
|
||||
<TimeCell
|
||||
href={showPopulation(ctx.row.original.code)}
|
||||
isEmptyRow={ctx.row.original.totalAttempts === 0}
|
||||
name={ctx.getValue()}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
/>
|
||||
),
|
||||
sortingFn: (rowA, rowB) => rowB.original.code - rowA.original.code,
|
||||
}),
|
||||
columnHelper.accessor('totalAttempts', {
|
||||
header: 'Total attempts',
|
||||
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue()),
|
||||
}),
|
||||
columnHelper.accessor('passed', {
|
||||
header: 'Passed',
|
||||
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue() || 0),
|
||||
}),
|
||||
columnHelper.accessor('failed', {
|
||||
header: 'Failed',
|
||||
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue() || 0),
|
||||
}),
|
||||
columnHelper.accessor('passRate', {
|
||||
header: 'Pass rate',
|
||||
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue()),
|
||||
}),
|
||||
columnHelper.accessor('enrollments', {
|
||||
header: 'Enrollments',
|
||||
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue()),
|
||||
}),
|
||||
...getGradeColumns<TableData>(resolveGrades(stats)),
|
||||
],
|
||||
[showPopulation, stats, userHasAccessToAllStats]
|
||||
)
|
||||
|
||||
const table = useMaterialReactTable({
|
||||
...defaultOptions,
|
||||
...commonOptions,
|
||||
columns,
|
||||
data,
|
||||
const ooditable_table: Partial<TableOptions<TableData>> = {
|
||||
enableSortingRemoval: false,
|
||||
initialState: {
|
||||
...defaultOptions.initialState,
|
||||
...commonOptions.initialState,
|
||||
},
|
||||
state: {
|
||||
columnVisibility,
|
||||
columnOrder: [
|
||||
'name',
|
||||
'totalAttempts',
|
||||
'passed',
|
||||
'failed',
|
||||
...resolveGrades(stats).map(({ key }) => `grades.${key}`),
|
||||
'passRate',
|
||||
'enrollments',
|
||||
],
|
||||
sorting: [{ id: 'name', desc: false }],
|
||||
},
|
||||
state: { columnVisibility },
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
muiTableBodyCellProps: {
|
||||
...defaultOptions?.muiTableHeadCellProps,
|
||||
...commonOptions?.muiTableBodyCellProps,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ExportToExcelDialog
|
||||
exportColumns={columns}
|
||||
exportData={exportData}
|
||||
featureName={`attempt_statistics_${name}`}
|
||||
onClose={() => setExportModalOpen(false)}
|
||||
open={exportModalOpen}
|
||||
<>
|
||||
<OodiTable
|
||||
columns={ooditable_columns}
|
||||
data={data}
|
||||
options={ooditable_table}
|
||||
toolbarContent={
|
||||
<OodiTableExcelExport data={excelData} exportColumnKeys={ooditable_table.state?.columnOrder ?? []} />
|
||||
}
|
||||
/>
|
||||
<MaterialReactTable table={table} />
|
||||
<TotalsDisclaimer userHasAccessToAllStats={userHasAccessToAllStats} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+109
-117
@@ -1,18 +1,30 @@
|
||||
import { MaterialReactTable, MRT_ColumnDef, useMaterialReactTable } from 'material-react-table'
|
||||
import { createColumnHelper, TableOptions } from '@tanstack/react-table'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { ExportToExcelDialog } from '@/components/common/MRTExcelExport'
|
||||
import { TableHeaderWithTooltip } from '@/components/common/TableHeaderWithTooltip'
|
||||
import { TotalsDisclaimer } from '@/components/common/TotalsDisclaimer'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { OodiTable } from '@/components/OodiTable'
|
||||
import { OodiTableExcelExport } from '@/components/OodiTable/excelExport'
|
||||
import { CourseSearchState } from '@/pages/CourseStatistics'
|
||||
import { FormattedStats } from '@/types/courseStat'
|
||||
import { getDefaultMRTOptions } from '@/util/getDefaultMRTOptions'
|
||||
import { FormattedStats, ProgrammeStats } from '@/types/courseStat'
|
||||
import { queryParamsToString } from '@/util/queryparams'
|
||||
import { ObfuscatedCell } from './ObfuscatedCell'
|
||||
import { TimeCell } from './TimeCell'
|
||||
import { commonOptions, formatPercentage, getGradeColumns, resolveGrades } from './util'
|
||||
import { formatPercentage, getGradeColumns, resolveGrades } from './util'
|
||||
|
||||
const getTableData = (stats: FormattedStats[]) => {
|
||||
type TableData = {
|
||||
name: string
|
||||
code: number
|
||||
totalStudents: number
|
||||
passed: number
|
||||
failed: number
|
||||
enrolledNoGrade?: number
|
||||
passRate: string
|
||||
failRate: string
|
||||
grades: Record<string, number>
|
||||
rowObfuscated?: boolean
|
||||
}
|
||||
|
||||
const getTableData = (stats: FormattedStats[]): TableData[] => {
|
||||
return stats.map(stat => ({
|
||||
name: stat.name,
|
||||
code: stat.code,
|
||||
@@ -23,11 +35,12 @@ const getTableData = (stats: FormattedStats[]) => {
|
||||
passRate: formatPercentage(stat.students.passRate * 100),
|
||||
failRate: formatPercentage(stat.students.failRate * 100),
|
||||
grades: stat.students.grades,
|
||||
rowObfuscated: stat.rowObfuscated,
|
||||
}))
|
||||
}
|
||||
|
||||
export const StudentsTable = ({
|
||||
data: { name, stats },
|
||||
data: { stats },
|
||||
separate,
|
||||
showGrades,
|
||||
userHasAccessToAllStats,
|
||||
@@ -35,7 +48,7 @@ export const StudentsTable = ({
|
||||
openOrRegular,
|
||||
alternatives,
|
||||
}: {
|
||||
data: { name: string; stats: FormattedStats[] }
|
||||
data: ProgrammeStats
|
||||
separate: boolean
|
||||
showGrades: boolean
|
||||
userHasAccessToAllStats: boolean
|
||||
@@ -43,14 +56,10 @@ export const StudentsTable = ({
|
||||
openOrRegular: CourseSearchState
|
||||
alternatives: string[]
|
||||
}) => {
|
||||
const { language } = useLanguage()
|
||||
|
||||
const [exportModalOpen, setExportModalOpen] = useState(false)
|
||||
const [exportData, setExportData] = useState<Record<string, unknown>[]>([])
|
||||
const [columnVisibility, setColumnVisibility] = useState<Record<string, boolean>>({})
|
||||
|
||||
const showPopulation = useCallback(
|
||||
(yearCode: string) => {
|
||||
(yearCode: number) => {
|
||||
const queryObject = {
|
||||
from: yearCode,
|
||||
to: yearCode,
|
||||
@@ -65,128 +74,111 @@ export const StudentsTable = ({
|
||||
)
|
||||
|
||||
const data = useMemo(() => getTableData(stats), [stats])
|
||||
|
||||
const columns = useMemo<MRT_ColumnDef<any>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Time',
|
||||
Cell: ({ cell, row }) => (
|
||||
<TimeCell
|
||||
href={showPopulation(row.original.code)}
|
||||
isEmptyRow={row.original.totalStudents === 0}
|
||||
name={cell.getValue<string>()}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
/>
|
||||
),
|
||||
sortingFn: (rowA, rowB) => rowB.original.code - rowA.original.code,
|
||||
},
|
||||
{
|
||||
accessorKey: 'totalStudents',
|
||||
header: 'Total students',
|
||||
Header: (
|
||||
<TableHeaderWithTooltip
|
||||
header="Total students"
|
||||
tooltipText="Total count of students, including enrolled students with no grade"
|
||||
/>
|
||||
),
|
||||
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>()),
|
||||
},
|
||||
{
|
||||
accessorKey: 'passed',
|
||||
header: 'Passed',
|
||||
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>() || 0),
|
||||
},
|
||||
{
|
||||
accessorKey: 'failed',
|
||||
header: 'Failed',
|
||||
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>() || 0),
|
||||
},
|
||||
...getGradeColumns(resolveGrades(stats)),
|
||||
{
|
||||
accessorKey: 'enrolledNoGrade',
|
||||
header: 'Enrolled, no grade',
|
||||
Header: (
|
||||
<TableHeaderWithTooltip
|
||||
header="Enrolled, no grade"
|
||||
tooltipText="Total count of students with a valid enrollment and no passing or failing grade"
|
||||
/>
|
||||
),
|
||||
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>()),
|
||||
},
|
||||
{
|
||||
accessorKey: 'passRate',
|
||||
header: 'Pass rate',
|
||||
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>()),
|
||||
},
|
||||
{
|
||||
accessorKey: 'failRate',
|
||||
header: 'Fail rate',
|
||||
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : cell.getValue<number>()),
|
||||
},
|
||||
],
|
||||
[showPopulation, stats, userHasAccessToAllStats]
|
||||
)
|
||||
const excelData = useMemo(() => {
|
||||
return data.map(({ grades, ...rest }) => ({
|
||||
...rest,
|
||||
...Object.fromEntries(Object.entries(grades).map(([key, val]) => [key, val])),
|
||||
}))
|
||||
}, [data])
|
||||
|
||||
useEffect(() => {
|
||||
setColumnVisibility(prev => {
|
||||
const updatedVisibility: Record<string, boolean> = { ...prev }
|
||||
|
||||
const gradeColumns = resolveGrades(stats).map(({ key }) => `grades.${key}`)
|
||||
|
||||
gradeColumns.forEach(key => {
|
||||
updatedVisibility[key] = showGrades
|
||||
})
|
||||
resolveGrades(stats)
|
||||
.map(({ key }) => `grades.${key}`)
|
||||
.forEach(key => {
|
||||
updatedVisibility[key] = showGrades
|
||||
})
|
||||
|
||||
updatedVisibility.passed = !showGrades
|
||||
updatedVisibility.failed = !showGrades
|
||||
|
||||
return { ...updatedVisibility }
|
||||
return {
|
||||
...updatedVisibility,
|
||||
name: true,
|
||||
totalStudents: true,
|
||||
enrolledNoGrade: true,
|
||||
passRate: true,
|
||||
failRate: true,
|
||||
}
|
||||
})
|
||||
}, [showGrades, stats])
|
||||
|
||||
const defaultOptions = getDefaultMRTOptions(setExportData, setExportModalOpen, language)
|
||||
const columnHelper = createColumnHelper<TableData>()
|
||||
const ooditable_columns = useMemo(
|
||||
() => [
|
||||
columnHelper.accessor('name', {
|
||||
header: 'Time',
|
||||
cell: ctx => (
|
||||
<TimeCell
|
||||
href={showPopulation(ctx.row.original.code)}
|
||||
isEmptyRow={ctx.row.original.totalStudents === 0}
|
||||
name={ctx.getValue()}
|
||||
userHasAccessToAllStats={userHasAccessToAllStats}
|
||||
/>
|
||||
),
|
||||
sortingFn: (rowA, rowB) => rowB.original.code - rowA.original.code,
|
||||
}),
|
||||
columnHelper.accessor('totalStudents', {
|
||||
header: _ => (
|
||||
<TableHeaderWithTooltip
|
||||
header="Total students"
|
||||
tooltipText="Total count of students, including enrolled students with no grade"
|
||||
/>
|
||||
),
|
||||
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue() || 0),
|
||||
}),
|
||||
columnHelper.accessor('passed', {
|
||||
header: 'Passed',
|
||||
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue() || 0),
|
||||
}),
|
||||
columnHelper.accessor('failed', {
|
||||
header: 'Failed',
|
||||
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue() || 0),
|
||||
}),
|
||||
...getGradeColumns<TableData>(resolveGrades(stats)),
|
||||
columnHelper.accessor('enrolledNoGrade', {
|
||||
header: _ => (
|
||||
<TableHeaderWithTooltip
|
||||
header="Enrolled, no grade"
|
||||
tooltipText="Total count of students with a valid enrollment and no passing or failing grade"
|
||||
/>
|
||||
),
|
||||
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue()),
|
||||
}),
|
||||
columnHelper.accessor('passRate', {
|
||||
header: 'Pass rate',
|
||||
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue()),
|
||||
}),
|
||||
columnHelper.accessor('failRate', {
|
||||
header: 'Fail rate',
|
||||
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : ctx.getValue()),
|
||||
}),
|
||||
],
|
||||
[showPopulation, stats, userHasAccessToAllStats]
|
||||
)
|
||||
|
||||
const table = useMaterialReactTable({
|
||||
...defaultOptions,
|
||||
...commonOptions,
|
||||
columns,
|
||||
data,
|
||||
const ooditable_table: Partial<TableOptions<TableData>> = {
|
||||
enableSortingRemoval: false,
|
||||
initialState: {
|
||||
...defaultOptions.initialState,
|
||||
...commonOptions.initialState,
|
||||
},
|
||||
state: {
|
||||
columnVisibility,
|
||||
columnOrder: [
|
||||
'name',
|
||||
'totalStudents',
|
||||
'passed',
|
||||
'failed',
|
||||
...resolveGrades(stats).map(({ key }) => `grades.${key}`),
|
||||
'enrolledNoGrade',
|
||||
'passRate',
|
||||
'failRate',
|
||||
],
|
||||
sorting: [{ id: 'name', desc: false }],
|
||||
},
|
||||
state: { columnVisibility },
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
muiTableBodyCellProps: {
|
||||
...defaultOptions?.muiTableHeadCellProps,
|
||||
...commonOptions?.muiTableBodyCellProps,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ExportToExcelDialog
|
||||
exportColumns={columns}
|
||||
exportData={exportData}
|
||||
featureName={`student_statistics_${name}`}
|
||||
onClose={() => setExportModalOpen(false)}
|
||||
open={exportModalOpen}
|
||||
<>
|
||||
<OodiTable
|
||||
columns={ooditable_columns}
|
||||
data={data}
|
||||
options={ooditable_table}
|
||||
toolbarContent={
|
||||
<OodiTableExcelExport data={excelData} exportColumnKeys={ooditable_table.state?.columnOrder ?? []} />
|
||||
}
|
||||
/>
|
||||
<MaterialReactTable table={table} />
|
||||
<TotalsDisclaimer userHasAccessToAllStats={userHasAccessToAllStats} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+6
-20
@@ -1,3 +1,4 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { flatten } from 'lodash-es'
|
||||
|
||||
import { FAILED_GRADES, NUMERIC_GRADES, OTHER_PASSED_GRADES } from '@/constants/grades'
|
||||
@@ -8,14 +9,15 @@ export const formatPercentage = (rate: number) => {
|
||||
return Number.isNaN(rate) ? '–' : `${rate.toFixed(2)} %`
|
||||
}
|
||||
|
||||
export const getGradeColumns = (grades: { key: string; title: string }[]) => {
|
||||
return grades.map(({ key, title }) => ({
|
||||
export const getGradeColumns = <T extends { grades; rowObfuscated?: boolean }>(
|
||||
grades: { key: string; title: string }[]
|
||||
): ColumnDef<T>[] =>
|
||||
grades.map(({ key, title }) => ({
|
||||
id: `grades.${key}`,
|
||||
accessorFn: row => row.grades[key],
|
||||
header: title,
|
||||
Cell: ({ cell, row }) => (row.original.rowObfuscated ? <ObfuscatedCell /> : (cell.getValue() ?? 0)),
|
||||
cell: ctx => (ctx.row.original.rowObfuscated ? <ObfuscatedCell /> : (ctx.getValue() ?? 0)),
|
||||
}))
|
||||
}
|
||||
|
||||
const gradesOrder = {
|
||||
0: 0,
|
||||
@@ -73,19 +75,3 @@ export const resolveGrades = (stats: FormattedStats[]) => {
|
||||
const grades = [...new Set(allGrades)]
|
||||
return getSortedGrades(grades)
|
||||
}
|
||||
|
||||
export const commonOptions = {
|
||||
defaultColumn: { size: 0 },
|
||||
enableColumnFilters: false,
|
||||
enableColumnOrdering: false,
|
||||
enableDensityToggle: false,
|
||||
enableGlobalFilter: false,
|
||||
enableHiding: false,
|
||||
initialState: {
|
||||
sorting: [{ id: 'name', desc: false }],
|
||||
showColumnFilters: false,
|
||||
},
|
||||
muiTableBodyCellProps: {
|
||||
align: 'right' as const,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import Table from '@mui/material/Table'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import TableBody from '@mui/material/TableBody'
|
||||
import TableCell from '@mui/material/TableCell'
|
||||
import TableContainer from '@mui/material/TableContainer'
|
||||
import TableHead from '@mui/material/TableHead'
|
||||
import TableRow from '@mui/material/TableRow'
|
||||
import Typography from '@mui/material/Typography'
|
||||
|
||||
import { memo } from 'react'
|
||||
|
||||
import { GroupChip } from '@/components/common/EquivalenceGroupChip'
|
||||
import { StyledTable } from '@/components/common/StyledTable'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { DeleteIcon } from '@/theme'
|
||||
import { SearchResultCourse } from '@/types/api/courses'
|
||||
import { getActiveYears } from '../util'
|
||||
|
||||
const CourseTable = ({
|
||||
export const CourseTable = ({
|
||||
combineSubstitutions,
|
||||
courses,
|
||||
hidden,
|
||||
onSelectCourse,
|
||||
title,
|
||||
}: {
|
||||
combineSubstitutions: boolean
|
||||
courses: any[]
|
||||
hidden: boolean
|
||||
onSelectCourse: (course: any) => void
|
||||
@@ -29,62 +31,67 @@ const CourseTable = ({
|
||||
|
||||
const getEmptyListRow = () => (
|
||||
<TableRow>
|
||||
<TableCell align="center" colSpan={2}>
|
||||
<TableCell align="center" colSpan={3}>
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
|
||||
const toCourseRow = course => (
|
||||
<TableRow
|
||||
data-cy={`course-${course.code}`}
|
||||
hover
|
||||
key={course.id}
|
||||
onClick={() => (course.min_attainment_date ? onSelectCourse(course) : null)}
|
||||
style={{ cursor: course.min_attainment_date ? 'pointer' : 'default' }}
|
||||
>
|
||||
<TableCell>
|
||||
<Typography variant="subtitle1">{getTextIn(course.name)}</Typography>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
{getActiveYears(course)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{[course.code, ...course.substitutions].join(', ')}</TableCell>
|
||||
{title === 'Selected courses' && (
|
||||
<TableCell align="right">
|
||||
<IconButton>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
const toCourseRow = (course: SearchResultCourse) => {
|
||||
return (
|
||||
<TableRow
|
||||
data-cy={`course-${course.code}`}
|
||||
hover
|
||||
key={course.id}
|
||||
onClick={() => (course.min_attainment_date ? onSelectCourse(course) : null)}
|
||||
style={{ cursor: course.min_attainment_date ? 'pointer' : 'default' }}
|
||||
>
|
||||
<TableCell>
|
||||
<Typography variant="subtitle1">{getTextIn(course.name)}</Typography>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
{getActiveYears(course)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
)
|
||||
<TableCell>
|
||||
<Typography fontSize="0.9rem">{course.code}</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{combineSubstitutions ? (
|
||||
<Stack>
|
||||
{course.substitution_groups?.map(group => <GroupChip group={group} key={group.join(':')} />) ?? (
|
||||
<Typography fontSize="0.9rem">Equivalent groups not available!</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
course.code
|
||||
)}
|
||||
</TableCell>
|
||||
{title === 'Selected courses' && (
|
||||
<TableCell align="right">
|
||||
<IconButton>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
if (hidden) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{title}</TableCell>
|
||||
<TableCell>Code(s)</TableCell>
|
||||
{title === 'Selected courses' && <TableCell />}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>{noContent ? getEmptyListRow() : courses.map(toCourseRow)}</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<StyledTable>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{title}</TableCell>
|
||||
<TableCell>Main course code</TableCell>
|
||||
<TableCell>Equivalent groups</TableCell>
|
||||
{title === 'Selected courses' && <TableCell />}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>{noContent ? getEmptyListRow() : courses.map(toCourseRow)}</TableBody>
|
||||
</StyledTable>
|
||||
)
|
||||
}
|
||||
|
||||
const areEqual = (prevProps, nextProps) => {
|
||||
if (prevProps.courses.length !== nextProps.courses.length) {
|
||||
return false
|
||||
}
|
||||
return prevProps.courses.every(c1 => nextProps.courses.some(c2 => c1.code === c2.code))
|
||||
}
|
||||
|
||||
export const MemoizedCourseTable = memo(CourseTable, areEqual)
|
||||
|
||||
@@ -21,9 +21,10 @@ import { useSearchHistory } from '@/hooks/searchHistory'
|
||||
import { useToggle } from '@/hooks/toggle'
|
||||
import { useGetCourseSearchResultQuery } from '@/redux/courseSearch'
|
||||
import { AddIcon, SearchIcon } from '@/theme'
|
||||
import { SearchResultCourse } from '@/types/api/courses'
|
||||
import { SearchHistoryItem } from '@/types/searchHistory'
|
||||
import { queryParamsToString } from '@/util/queryparams'
|
||||
import { MemoizedCourseTable as CourseTable } from './CourseTable'
|
||||
import { CourseTable } from './CourseTable'
|
||||
import { FetchStatisticsButton } from './FetchStatisticsButton'
|
||||
import { MultipleCoursesAlert } from './MultipleCoursesAlert'
|
||||
|
||||
@@ -37,7 +38,7 @@ export const SearchForm = () => {
|
||||
const [courseCode, setCourseCode] = useState('')
|
||||
const [debouncedCourseName, setDebouncedCourseName] = useDebouncedState('')
|
||||
const [debouncedCourseCode, setDebouncedCourseCode] = useDebouncedState('')
|
||||
const [selectedCourses, setSelectedCourses] = useState({})
|
||||
const [selectedCourses, setSelectedCourses] = useState<Record<string, SearchResultCourse>>({})
|
||||
|
||||
const isInputValid = validateInputLength(courseName, 5) || validateInputLength(courseCode, 2)
|
||||
const isDebouncedInputValid =
|
||||
@@ -49,7 +50,6 @@ export const SearchForm = () => {
|
||||
{
|
||||
name: debouncedCourseName,
|
||||
code: debouncedCourseCode,
|
||||
combineSubstitutions,
|
||||
includeSpecial: includeSpecialCourses,
|
||||
},
|
||||
{ skip: !isDebouncedInputValid }
|
||||
@@ -66,7 +66,7 @@ export const SearchForm = () => {
|
||||
setDebouncedCourseCode(event.target.value)
|
||||
}
|
||||
|
||||
const onSelectCourse = course => {
|
||||
const onSelectCourse = (course: SearchResultCourse) => {
|
||||
const isSelected = !!selectedCourses[course.code]
|
||||
|
||||
if (isSelected) {
|
||||
@@ -149,7 +149,7 @@ export const SearchForm = () => {
|
||||
const noSelectedCourses = !selected.length
|
||||
|
||||
const addAllCourses = () => {
|
||||
const newSelectedCourses = courses.reduce((newSelected, course) => {
|
||||
const newSelectedCourses = courses.reduce<Record<string, SearchResultCourse>>((newSelected, course) => {
|
||||
newSelected[course.code] = { ...course }
|
||||
return newSelected
|
||||
}, {})
|
||||
@@ -245,6 +245,7 @@ export const SearchForm = () => {
|
||||
{selected.length > 0 && selectMultipleCourses ? (
|
||||
<Stack gap={2}>
|
||||
<CourseTable
|
||||
combineSubstitutions={combineSubstitutions}
|
||||
courses={selected}
|
||||
hidden={noSelectedCourses}
|
||||
onSelectCourse={onSelectCourse}
|
||||
@@ -259,6 +260,7 @@ export const SearchForm = () => {
|
||||
</Stack>
|
||||
) : null}
|
||||
<CourseTable
|
||||
combineSubstitutions={combineSubstitutions}
|
||||
courses={courses}
|
||||
hidden={false}
|
||||
onSelectCourse={onSelectCourse}
|
||||
|
||||
+172
-96
@@ -1,19 +1,9 @@
|
||||
/* eslint-disable react/no-this-in-sfc */
|
||||
/* eslint-disable-next-line import-x/default */
|
||||
import HighCharts from 'highcharts'
|
||||
import accessibility from 'highcharts/modules/accessibility'
|
||||
import exportData from 'highcharts/modules/export-data'
|
||||
import exporting from 'highcharts/modules/exporting'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { theme } from '@/theme'
|
||||
import { NameWithCode } from '@oodikone/shared/types'
|
||||
|
||||
exporting(ReactHighcharts.Highcharts)
|
||||
exportData(ReactHighcharts.Highcharts)
|
||||
accessibility(ReactHighcharts.Highcharts)
|
||||
|
||||
export const CollapsedStackedBar = ({
|
||||
data,
|
||||
differenceData,
|
||||
@@ -30,20 +20,18 @@ export const CollapsedStackedBar = ({
|
||||
plotLinePlaces: string[][]
|
||||
}) => {
|
||||
const { getTextIn } = useLanguage()
|
||||
|
||||
const transpose = (matrix: number[][]) => {
|
||||
return matrix.reduce((prev, next) => next.map((_item, i) => (prev[i] || []).concat(next[i])), [] as number[][])
|
||||
}
|
||||
if (names[0] === 'Started studying') {
|
||||
names[0] += ' (new in faculty)'
|
||||
}
|
||||
const dataTranspose = transpose(data)
|
||||
.map((obj, index) => ({
|
||||
name: names[index],
|
||||
data: obj,
|
||||
color: theme.palette.graphColors[index],
|
||||
type: 'bar' as const,
|
||||
}))
|
||||
.reverse()
|
||||
const seriesNames = names.map((name, index) =>
|
||||
index === 0 && name === 'Started studying' ? 'Started studying (new in faculty)' : name
|
||||
)
|
||||
const dataTranspose = transpose(data).map((obj, index) => ({
|
||||
name: seriesNames[index],
|
||||
data: obj,
|
||||
color: theme.palette.graphColors[index],
|
||||
}))
|
||||
|
||||
const differenceArray: Record<string, Record<string, number>> = Object.keys(differenceData).reduce(
|
||||
(programmes, programme) => ({
|
||||
@@ -51,7 +39,7 @@ export const CollapsedStackedBar = ({
|
||||
[programme]: differenceData[programme].reduce(
|
||||
(results, value, currentIndex) => ({
|
||||
...results,
|
||||
[names[currentIndex]]: value,
|
||||
[seriesNames[currentIndex]]: value,
|
||||
}),
|
||||
{}
|
||||
),
|
||||
@@ -70,19 +58,8 @@ export const CollapsedStackedBar = ({
|
||||
? plotLinePlaces.map(value => ({
|
||||
color: '#90A959',
|
||||
width: 1,
|
||||
value: Number(value[0]) - 0.5,
|
||||
dashStyle: 'Solid' as const,
|
||||
label: {
|
||||
text: value[1],
|
||||
style: {
|
||||
color: 'black',
|
||||
fontWeight: 'bold',
|
||||
position: 'absolute',
|
||||
},
|
||||
align: 'right' as const,
|
||||
x: 0,
|
||||
y: 5,
|
||||
},
|
||||
value: Number(value[0]),
|
||||
label: value[1],
|
||||
}))
|
||||
: []
|
||||
|
||||
@@ -101,82 +78,181 @@ export const CollapsedStackedBar = ({
|
||||
return '#7B9FCF'
|
||||
}
|
||||
|
||||
const config: HighCharts.Options = {
|
||||
chart: {
|
||||
type: 'bar',
|
||||
marginTop: 60,
|
||||
height: getFlexHeight(labels.length),
|
||||
const formatNumber = (value: unknown) => {
|
||||
const numericValue = typeof value === 'number' ? value : Number(value)
|
||||
|
||||
if (!Number.isFinite(numericValue)) return ''
|
||||
if (Number.isInteger(numericValue)) return `${numericValue}`
|
||||
return `${numericValue.toFixed(1)}`
|
||||
}
|
||||
|
||||
const getNumericValue = (value: unknown) => {
|
||||
const numericValue = typeof value === 'number' ? value : Number(value)
|
||||
return Number.isFinite(numericValue) ? numericValue : 0
|
||||
}
|
||||
|
||||
const totals = labels.map((_, index) =>
|
||||
dataTranspose.reduce((sum, series) => sum + getNumericValue(series.data[index]), 0)
|
||||
)
|
||||
|
||||
// Prevents clutter
|
||||
const labelCutoff = Math.round(Math.max(...totals) * 0.05)
|
||||
|
||||
const totalMarkPoints = labels.map((label, index) => ({
|
||||
coord: [totals[index], label],
|
||||
value: totals[index],
|
||||
}))
|
||||
|
||||
const series = dataTranspose.map((graphSeries, index) => ({
|
||||
name: graphSeries.name,
|
||||
type: 'bar',
|
||||
stack: 'total',
|
||||
data: graphSeries.data,
|
||||
itemStyle: {
|
||||
color: graphSeries.color,
|
||||
},
|
||||
series: dataTranspose,
|
||||
xAxis: {
|
||||
categories: labels,
|
||||
title: {
|
||||
text: '',
|
||||
label: {
|
||||
show: true,
|
||||
position: 'insideLeft',
|
||||
formatter: (params: { value?: unknown }) => {
|
||||
const value = getNumericValue(params.value)
|
||||
if (value <= labelCutoff) return ''
|
||||
return formatNumber(value)
|
||||
},
|
||||
plotLines: chartPlotLinePlaces,
|
||||
},
|
||||
yAxis: {
|
||||
min: 0,
|
||||
title: {
|
||||
text: '',
|
||||
},
|
||||
stackLabels: {
|
||||
enabled: true,
|
||||
},
|
||||
labelLayout: {
|
||||
hideOverlap: true,
|
||||
},
|
||||
markPoint:
|
||||
index === dataTranspose.length - 1
|
||||
? {
|
||||
showSymbol: false,
|
||||
symbolSize: 0,
|
||||
label: {
|
||||
show: true,
|
||||
position: 'right',
|
||||
distance: 10,
|
||||
formatter: (params: { value?: unknown }) => `(${formatNumber(params.value)})`,
|
||||
},
|
||||
data: totalMarkPoints,
|
||||
}
|
||||
: undefined,
|
||||
}))
|
||||
|
||||
const plotLineSeries = chartPlotLinePlaces.length
|
||||
? [
|
||||
{
|
||||
name: 'Plot lines',
|
||||
type: 'line',
|
||||
data: [],
|
||||
silent: true,
|
||||
tooltip: { show: false },
|
||||
markLine: {
|
||||
silent: true,
|
||||
symbol: 'none',
|
||||
lineStyle: {
|
||||
color: '#000',
|
||||
width: 1,
|
||||
},
|
||||
data: chartPlotLinePlaces.map(place => ({
|
||||
yAxis: place.value,
|
||||
lineStyle: {
|
||||
width: 0,
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
formatter: place.label,
|
||||
position: 'insideEnd',
|
||||
color: '#000',
|
||||
fontWeight: 'bold',
|
||||
distance: 5,
|
||||
},
|
||||
})),
|
||||
},
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
const option = {
|
||||
grid: {
|
||||
top: 20,
|
||||
left: 10,
|
||||
right: 10,
|
||||
bottom: 75,
|
||||
show: true,
|
||||
},
|
||||
legend: {
|
||||
layout: 'horizontal',
|
||||
align: 'left',
|
||||
x: 20,
|
||||
verticalAlign: 'top',
|
||||
y: -10,
|
||||
floating: true,
|
||||
backgroundColor: 'white',
|
||||
borderColor: '#CCC',
|
||||
borderWidth: 1,
|
||||
shadow: false,
|
||||
borderWidth: 0,
|
||||
data: series.map(item => item.name),
|
||||
},
|
||||
tooltip: {
|
||||
shared: true,
|
||||
backgroundColor: 'white',
|
||||
formatter() {
|
||||
let tooltipString = `<b>${getTextIn(longLabels[this.x!])}</b><br /><p>${this.x} - ${
|
||||
longLabels[this.x!]?.code
|
||||
}</p><br />`
|
||||
const diffArray = differenceArray[this.x!]
|
||||
this.points?.forEach(point => {
|
||||
tooltipString += `<span style="color:${point.color as string}">●</span> <b>${point.series.name}: ${point.y}</b>
|
||||
(<span style="color:${getColor(diffArray[point.series.name])};font-weight:bold">${getCorrectSign(
|
||||
diffArray[point.series.name]
|
||||
)}</span>)<br />`
|
||||
confine: true,
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow',
|
||||
},
|
||||
backgroundColor: '#fff',
|
||||
borderColor: '#CCC',
|
||||
borderWidth: 1,
|
||||
formatter: (rawParams: unknown) => {
|
||||
const params = Array.isArray(rawParams) ? rawParams : [rawParams]
|
||||
const barParams = params.filter(
|
||||
param =>
|
||||
(param as { seriesType?: string })?.seriesType === 'bar' &&
|
||||
(param as { componentType?: string })?.componentType === 'series'
|
||||
) as Array<{ dataIndex?: number; seriesName?: string; value?: unknown; color?: string }>
|
||||
|
||||
const firstPoint = barParams[0]
|
||||
if (!firstPoint) return ''
|
||||
|
||||
const dataIndex = firstPoint.dataIndex ?? 0
|
||||
const label = labels[dataIndex] ?? ''
|
||||
const labelInfo = longLabels[label]
|
||||
let tooltipString = `<b>${getTextIn(labelInfo) ?? ''}</b><p>${label} - ${labelInfo?.code ?? ''}</p>`
|
||||
const diffArray = differenceArray[label] ?? {}
|
||||
|
||||
barParams.forEach(point => {
|
||||
const value = getNumericValue(point.value)
|
||||
const seriesName = point.seriesName ?? ''
|
||||
const diffValue = diffArray[seriesName]
|
||||
tooltipString += `<span style="color:${point.color ?? '#000'}">●</span> <b>${seriesName}: ${formatNumber(
|
||||
value
|
||||
)}</b>`
|
||||
if (typeof diffValue === 'number') {
|
||||
tooltipString += ` (<span style="color:${getColor(diffValue)};font-weight:bold">${getCorrectSign(
|
||||
diffValue
|
||||
)}</span>)`
|
||||
}
|
||||
tooltipString += '<br />'
|
||||
})
|
||||
tooltipString += `<b>Total: ${this.points?.reduce((prev, current) => prev + current.y!, 0)}</b>`
|
||||
const total = barParams.reduce((sum, current) => sum + getNumericValue(current.value), 0)
|
||||
|
||||
tooltipString += `<b>Total: ${formatNumber(total)}</b>`
|
||||
return tooltipString
|
||||
},
|
||||
},
|
||||
plotOptions: {
|
||||
series: {
|
||||
stacking: 'normal',
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
align: 'left',
|
||||
formatter() {
|
||||
if (Number.isInteger(this.y)) return `${this.y}`
|
||||
return `${this.y?.toFixed(1)}`
|
||||
},
|
||||
filter: {
|
||||
property: 'y',
|
||||
operator: '>',
|
||||
value: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
axisLine: { show: true },
|
||||
axisTick: { show: false },
|
||||
min: 0,
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: labels,
|
||||
inverse: true,
|
||||
axisLine: {
|
||||
show: true,
|
||||
},
|
||||
z: 10,
|
||||
},
|
||||
series: [...series, ...plotLineSeries],
|
||||
}
|
||||
|
||||
if (!dataTranspose) {
|
||||
return <>No data provided</>
|
||||
}
|
||||
|
||||
return <ReactHighcharts config={config} />
|
||||
return <ReactECharts option={option} opts={{ renderer: 'svg' }} style={{ height: getFlexHeight(labels.length) }} />
|
||||
}
|
||||
|
||||
+1
-1
@@ -123,7 +123,7 @@ export const InteractiveDataTable = ({
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{titles.map((title, index) => (
|
||||
<TableCell key={title}>
|
||||
<TableCell key={title} sx={{ px: 0, py: 0.5 }}>
|
||||
<Box alignItems="center" display="flex" justifyContent={index === 0 ? 'left' : 'right'}>
|
||||
{index === 0 && (
|
||||
<Box sx={{ marginRight: 1 }}>
|
||||
|
||||
@@ -4,8 +4,8 @@ import Stack from '@mui/material/Stack'
|
||||
import { useState, type Dispatch, type SetStateAction } from 'react'
|
||||
|
||||
import { facultyToolTips } from '@/common/InfoToolTips'
|
||||
import { LineGraph } from '@/components/common/LineGraph'
|
||||
import { StackedBarChart } from '@/components/common/StackedBarChart'
|
||||
import { LineGraph } from '@/components/Charts/LineGraph'
|
||||
import { StackedBarChart } from '@/components/Charts/StackedBarChart'
|
||||
import { Toggle } from '@/components/common/toggle/Toggle'
|
||||
import { ToggleContainer } from '@/components/common/toggle/ToggleContainer'
|
||||
import { Section } from '@/components/Section'
|
||||
@@ -286,7 +286,7 @@ export const BasicInformationTab = ({
|
||||
<Stack gap={2}>
|
||||
<LineGraph
|
||||
cypress="graduated-of-the-faculty"
|
||||
exportFileName={`oodikon"graduated-of-the-faculty"_${faculty.code}_${getTimestamp()}`}
|
||||
exportFileName={`oodikone_graduated-of-the-faculty"_${faculty.code}_${getTimestamp()}`}
|
||||
graphStats={basics.data.graduationInfo.graphStats}
|
||||
years={basics.data.years}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { utils, writeFile } from 'xlsx'
|
||||
import { facultyToolTips } from '@/common/InfoToolTips'
|
||||
import { Toggle } from '@/components/common/toggle/Toggle'
|
||||
import { ToggleContainer } from '@/components/common/toggle/ToggleContainer'
|
||||
import { GraduationTimes } from '@/components/GraduationTimes'
|
||||
import { GraduationTimes, GraduationTimesProps } from '@/components/GraduationTimes'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { Section } from '@/components/Section'
|
||||
import { useGetFacultyGraduationTimesQuery } from '@/redux/facultyStats'
|
||||
@@ -38,18 +38,20 @@ export const GraduationTimesTab = ({
|
||||
const programmeNames = graduationStats?.data?.programmeNames
|
||||
const classSizes = graduationStats?.data?.classSizes
|
||||
const isError = graduationStats.isError || (graduationStats.isSuccess && !graduationStats.data)
|
||||
const isLoading = graduationStats.isLoading || graduationStats.isFetching
|
||||
const commonProps = {
|
||||
const isLoading = graduationStats.isFetching
|
||||
|
||||
const commonProps: Omit<GraduationTimesProps, 'data' | 'goal' | 'level' | 'title'> = {
|
||||
allowExpand: true,
|
||||
classSizes,
|
||||
goalExceptions,
|
||||
groupBy,
|
||||
isError,
|
||||
isLoading,
|
||||
mode: 'programme' as const,
|
||||
mode: 'programme',
|
||||
names: programmeNames,
|
||||
showMedian,
|
||||
yearLabel,
|
||||
}
|
||||
} as const
|
||||
|
||||
const exportToExcel = (data?: GetFacultyGraduationTimesResponse, programmeNames?: Record<string, Name>) => {
|
||||
if (!data || !programmeNames) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Stack from '@mui/material/Stack'
|
||||
|
||||
import { facultyToolTips } from '@/common/InfoToolTips'
|
||||
import { ProgressBarChart } from '@/components/common/ProgressBarChart'
|
||||
import { ProgressBarChart } from '@/components/Charts/ProgressBarChart'
|
||||
import { FacultyProgressTable } from '@/components/Faculties/FacultyProgressTable'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { Section } from '@/components/Section'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user