mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-13 03:13:40 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d66fea3d7b | ||
|
|
dcfac28fa6 | ||
|
|
09b743fb9b | ||
|
|
36159c7305 | ||
|
|
1c84948f3c | ||
|
|
631b652553 | ||
|
|
41d027e054 | ||
|
|
caa7739f37 | ||
|
|
be91975591 | ||
|
|
f62984143a | ||
|
|
1645f568f8 | ||
|
|
15dac7c158 | ||
|
|
edc6182b20 | ||
|
|
183a4cd782 | ||
|
|
c3f776c9ea | ||
|
|
5f96b20936 | ||
|
|
50e37c8ef6 | ||
|
|
8287a4a37f | ||
|
|
1e46a27712 | ||
|
|
fe72648a77 | ||
|
|
7c6fb10521 | ||
|
|
f394a1515f | ||
|
|
f0cf3a12a5 | ||
|
|
9e53a739f6 | ||
|
|
74e3e59cd8 | ||
|
|
8a14efa403 | ||
|
|
a77c70150c | ||
|
|
89dd5f1322 | ||
|
|
75d4c78adb | ||
|
|
25bdf0cf8f | ||
|
|
ca16291e37 | ||
|
|
1ced256f43 | ||
|
|
a00fb4b78e | ||
|
|
874c8c6d00 | ||
|
|
fef7faa8e7 | ||
|
|
fa21354b8b | ||
|
|
ce2a035111 | ||
|
|
1a48b7a028 |
+10
-9
@@ -14,16 +14,17 @@ The quick guide should be enough for most purposes: updating schema, data, or th
|
||||
./manage_test_dbs.sh release all # dump, upload and push images for every database
|
||||
```
|
||||
|
||||
Actions are `download`, `dump`, `upload`, `push`, `release` (dump + upload + push) and `rebuild` (download + push). Each one is a thin call to a script in this folder, which can also be used on its own:
|
||||
Actions are `download`, `dump`, `upload` (upload + prune), `push`, `prune`, `release` (dump + upload + prune + push) and `rebuild` (download + push). Each one is a thin call to a script in this folder, which can also be used on its own:
|
||||
|
||||
| Script | What it does |
|
||||
| -------------------------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| `dump_local_db.sh` | `pg_dump`s the databases from the running containers into `.databasedumps/` |
|
||||
| `download_dump_from_s3.sh` | Downloads the newest dump of each database from s3 (`-b <branch>` to pick dumps from a single branch) |
|
||||
| `upload_dump_to_s3.sh` | Uploads the dumps in `.databasedumps/test` to s3 as `<database>-<git_branch>-<datetime>.sql` |
|
||||
| `build_and_push_image.sh` | Builds database images from the current dumps and pushes them to the Toska registry |
|
||||
| `create_s3_bucket.sh` | Creates the s3 bucket if it doesn't exist yet, run automatically before an upload |
|
||||
| `lib/common.sh` | Shared configuration (dump directories, s3 bucket, registry, database names) |
|
||||
| Script | What it does |
|
||||
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||
| `dump_local_db.sh` | `pg_dump`s the databases from the running containers into `.databasedumps/` |
|
||||
| `download_dump_from_s3.sh` | Downloads the newest dump of each database from s3 (`-b <branch>` to pick dumps from a single branch) |
|
||||
| `upload_dump_to_s3.sh` | Uploads the dumps in `.databasedumps/test` to s3 as `<database>-<git_branch>-<datetime>.sql` |
|
||||
| `prune_s3_dumps.sh` | Deletes dumps older than 30 days from s3, always keeping the newest `master` dump of each database (`-d <days>`, `-n`) |
|
||||
| `build_and_push_image.sh` | Builds database images from the current dumps and pushes them to the Toska registry |
|
||||
| `create_s3_bucket.sh` | Creates the s3 bucket if it doesn't exist yet, run automatically before an upload |
|
||||
| `lib/common.sh` | Shared configuration (dump directories, s3 bucket, registry, database names) |
|
||||
|
||||
Dumps end up in `.databasedumps/test/<database>.sql`, which is where `db.Dockerfile` reads them from when an image is built. The s3 commands read their credentials from the `ACCESS_KEY` and `SECRET_KEY` environment variables (e.g. in CI). If neither is set, they fall back to `~/.s3cfg`, which can be found on the toska/dokumentaatio repo.
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ S3_HOST="s3.datacloud.helsinki.fi"
|
||||
S3_BUCKET="s3://oodikone"
|
||||
# Dumps live under a prefix of their own inside the bucket
|
||||
S3_TARGET="$S3_BUCKET/test-dbs"
|
||||
# Dumps older than this are pruned from s3, except for the newest dump of each database
|
||||
S3_DUMP_RETENTION_DAYS=30
|
||||
|
||||
REGISTRY="registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi"
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=lib/common.sh
|
||||
source "$SCRIPT_DIR"/lib/common.sh
|
||||
|
||||
ACTIONS=("download" "dump" "upload" "push" "release" "rebuild")
|
||||
ACTIONS=("download" "dump" "upload" "push" "prune" "release" "rebuild")
|
||||
|
||||
usage() {
|
||||
cat >&2 <<EOF
|
||||
@@ -28,10 +28,11 @@ Actions:
|
||||
download Download the newest dumps from s3
|
||||
dump Dump the databases from the running docker containers
|
||||
|
||||
upload Upload the current dumps to s3
|
||||
upload Upload the current dumps to s3, then prune old dumps
|
||||
push Build images from the current dumps and push them to the registry
|
||||
prune Delete dumps older than $S3_DUMP_RETENTION_DAYS days from s3, keeping the newest one
|
||||
|
||||
release Dump from the running containers, then upload and push (shorthand)
|
||||
release Dump from the running containers, then upload, prune and push (shorthand)
|
||||
rebuild Download the dumps from s3, then push (shorthand)
|
||||
|
||||
Databases: ${DATABASES[*]} all
|
||||
@@ -52,6 +53,10 @@ upload_dump() {
|
||||
"$SCRIPT_DIR"/upload_dump_to_s3.sh "$1"
|
||||
}
|
||||
|
||||
prune_dumps() {
|
||||
"$SCRIPT_DIR"/prune_s3_dumps.sh "$1"
|
||||
}
|
||||
|
||||
push_image() {
|
||||
"$SCRIPT_DIR"/build_and_push_image.sh "$1"
|
||||
}
|
||||
@@ -63,11 +68,16 @@ run_action() {
|
||||
case "$action" in
|
||||
download) download_dump "$database" ;;
|
||||
dump) dump_database "$database" ;;
|
||||
upload) upload_dump "$database" ;;
|
||||
upload)
|
||||
upload_dump "$database"
|
||||
prune_dumps "$database"
|
||||
;;
|
||||
push) push_image "$database" ;;
|
||||
prune) prune_dumps "$database" ;;
|
||||
release)
|
||||
dump_database "$database"
|
||||
upload_dump "$database"
|
||||
prune_dumps "$database"
|
||||
push_image "$database"
|
||||
;;
|
||||
rebuild)
|
||||
|
||||
Executable
+113
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Delete old database dumps from s3. Dumps older than the retention period are
|
||||
# removed, but the newest master dump of each database is always kept, so that a
|
||||
# database which hasn't changed in a while can still be downloaded. Dumps from
|
||||
# other branches aren't kept, so that old branches don't accumulate dumps.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source-path=SCRIPTDIR
|
||||
# shellcheck source=lib/common.sh
|
||||
source "$SCRIPT_DIR"/lib/common.sh
|
||||
|
||||
# The newest dump of each database from this branch is never pruned
|
||||
MASTER_BRANCH="master"
|
||||
|
||||
usage() {
|
||||
cat >&2 <<EOF
|
||||
Usage: $(basename "$0") [-n|--dry-run] [-d|--days <days>] <database>... | all
|
||||
|
||||
Deletes the dumps in $S3_TARGET older than the given number of days
|
||||
(default $S3_DUMP_RETENTION_DAYS). The newest $MASTER_BRANCH dump of each database is
|
||||
always kept, or the newest dump of any branch if the database has no $MASTER_BRANCH dumps.
|
||||
|
||||
Databases: ${DATABASES[*]}
|
||||
|
||||
Options:
|
||||
-n, --dry-run Print the dumps that would be deleted instead of deleting them
|
||||
-d, --days <days> Delete dumps older than this many days
|
||||
-h, --help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
dry_run=false
|
||||
days="$S3_DUMP_RETENTION_DAYS"
|
||||
arguments=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-n | --dry-run)
|
||||
dry_run=true
|
||||
shift
|
||||
;;
|
||||
-d | --days)
|
||||
days="${2-}"
|
||||
[[ "$days" =~ ^[0-9]+$ ]] || {
|
||||
usage
|
||||
die "Expected a number of days for $1, got: $days"
|
||||
}
|
||||
shift 2
|
||||
;;
|
||||
-h | --help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
-*)
|
||||
usage
|
||||
die "Unknown option: $1"
|
||||
;;
|
||||
*)
|
||||
arguments+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ${#arguments[@]} -eq 0 ]]; then
|
||||
usage
|
||||
die "No database given"
|
||||
fi
|
||||
|
||||
prune_database() {
|
||||
local database="$1"
|
||||
local deleted=0
|
||||
|
||||
# s3cmd ls prints "<date> <time> <size> <object>", so sorting by the first two
|
||||
# columns in reverse puts the newest dump first
|
||||
local dumps
|
||||
dumps="$(s3 ls "$S3_TARGET/$database-" | sort -rk1,2)"
|
||||
|
||||
# Find the newest master dump or in the case of it missing, the newest dump in general
|
||||
local kept
|
||||
kept="$(awk -v pattern="/$database-$MASTER_BRANCH-[0-9]+-[0-9]+[.]sql$" '$4 ~ pattern { print $4; exit }' <<< "$dumps")"
|
||||
[[ -n "$kept" ]] || kept="$(awk 'NR == 1 { print $4 }' <<< "$dumps")"
|
||||
|
||||
# Remove any dumps older than the dump retention date (or -d)
|
||||
# except for master for which at least one dump is saved
|
||||
while read -r date time _ dump; do
|
||||
[[ -n "$dump" && "$dump" != "$kept" && "$date $time" < "$cutoff" ]] || continue
|
||||
if [[ "$dry_run" == true ]]; then
|
||||
infomsg "Dry run, would delete $dump"
|
||||
else
|
||||
s3 del "$dump"
|
||||
fi
|
||||
deleted=$((deleted + 1))
|
||||
done <<< "$dumps"
|
||||
|
||||
if [[ "$dry_run" == true ]]; then
|
||||
successmsg "Would have pruned $deleted dump(s) of $database older than $days days"
|
||||
else
|
||||
successmsg "Pruned $deleted dump(s) of $database older than $days days"
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_databases "${arguments[@]}"
|
||||
require_s3_config
|
||||
|
||||
cutoff="$(date -u -d "-$days days" '+%Y-%m-%d %H:%M')"
|
||||
|
||||
for database in "${selected_databases[@]}"; do
|
||||
prune_database "$database"
|
||||
done
|
||||
@@ -650,10 +650,14 @@ test.describe('Degree programme overview', () => {
|
||||
})
|
||||
|
||||
test.describe('Curriculum section', () => {
|
||||
test('is shown', async ({ page }) => {
|
||||
await expect(page.getByTestId('curriculum-section')).toBeVisible()
|
||||
await expect(page.getByText('Select curriculum to edit:')).toBeVisible()
|
||||
await expect(page.getByTestId('curriculum-picker').getByText('2023–2026')).toBeVisible()
|
||||
test('is shown in both settings tabs', async ({ page }) => {
|
||||
for (const tab of ['criteria', 'visibility']) {
|
||||
await page.getByTestId(`${tab}-tab`).click()
|
||||
const panel = page.getByTestId(`${tab}-panel`)
|
||||
await expect(panel.getByTestId('curriculum-section')).toBeVisible()
|
||||
await expect(panel.getByText('Select curriculum to edit:')).toBeVisible()
|
||||
await expect(panel.getByTestId('curriculum-picker').getByText('2023–2026')).toBeVisible()
|
||||
}
|
||||
})
|
||||
test.skip('can be changed', () => {
|
||||
return
|
||||
@@ -664,7 +668,7 @@ test.describe('Degree programme overview', () => {
|
||||
test('info box', async ({ page }) => {
|
||||
await expect(page.getByTestId('credit-criteria-section')).toBeVisible()
|
||||
await page.getByTestId('credit-criteria-info-box-button').click()
|
||||
await expect(page.getByTestId('credit-criteria-info-box-content').getByText('Tässä asetettu')).toBeVisible()
|
||||
await expect(page.getByTestId('credit-criteria-info-box-content').getByText('Tässä asetetut')).toBeVisible()
|
||||
})
|
||||
|
||||
test.skip('changing the credit criteria works', () => {
|
||||
@@ -675,7 +679,9 @@ test.describe('Degree programme overview', () => {
|
||||
})
|
||||
|
||||
test('Degree course table', async ({ page }) => {
|
||||
await expect(page.getByTestId('degree-course-table')).toBeVisible()
|
||||
await expect(page.getByTestId('criteria-panel').getByTestId('degree-course-table')).toBeVisible()
|
||||
await page.getByTestId('visibility-tab').click()
|
||||
await expect(page.getByTestId('visibility-panel').getByTestId('degree-course-table')).toBeVisible()
|
||||
// TODO: Test clicking the arrow buttons
|
||||
// TODO: Test toggling visibility for MODULES and COURSES
|
||||
// TODO: Test changing criterion labels
|
||||
|
||||
@@ -28,16 +28,28 @@ Run without a command to open the interactive menu.
|
||||
|
||||
Available commands:
|
||||
|
||||
up, u Start Oodikone with real data
|
||||
rs Restart Oodikone with real data (down, then up)
|
||||
test, t Start Oodikone with test/anon data
|
||||
down, d Stop Oodikone
|
||||
logs, l Follow oodikone service logs
|
||||
logs:updater, lu Follow sis-updater service logs
|
||||
importer, i Open a psql/pgcli shell to the importer database
|
||||
updater Open a psql/pgcli shell to the updater (sis) database
|
||||
install Add the 'ok' shell function to your shell rc file
|
||||
-h, --help Print this help and exit
|
||||
up, u, r, real Start Oodikone with real data
|
||||
rs, rl Restart Oodikone with real data (down, then up)
|
||||
anon, a Start Oodikone with anonymous data
|
||||
down, d Stop Oodikone
|
||||
|
||||
logs, l Follow oodikone service logs
|
||||
logs:updater, lu Follow sis-updater service logs
|
||||
lint, li Lint the project or the path given as parameter
|
||||
format, fmt, f Format the project or the path given as parameter
|
||||
|
||||
test:backend, tb Run backend unit tests
|
||||
test:frontend, tf Run frontend unit tests
|
||||
test:updater, tu Run updater (worker) unit tests
|
||||
test:services, ts Run backend service tests
|
||||
test:api, ta Run backend api tests
|
||||
test:component, tc Run frontend component tests tests
|
||||
test:e2e, te Run all e2e tests (add --ui to run in headed mode)
|
||||
|
||||
importer, i Open a psql/pgcli shell to the importer database
|
||||
updater Open a psql/pgcli shell to the updater (sis) database
|
||||
install Add the 'ok' shell function to your shell rc file
|
||||
-h, --help Print this help and exit
|
||||
EOF
|
||||
exit
|
||||
}
|
||||
@@ -191,7 +203,7 @@ main_loop() {
|
||||
# === Direct (non-interactive) commands ===
|
||||
run_up() {
|
||||
infomsg "Starting Oodikone with real data..."
|
||||
npm run both:real
|
||||
npm run both:real "$@"
|
||||
}
|
||||
|
||||
run_restart() {
|
||||
@@ -200,24 +212,53 @@ run_restart() {
|
||||
npm run both:real
|
||||
}
|
||||
|
||||
run_test() {
|
||||
infomsg "Starting Oodikone with test data..."
|
||||
npm run both
|
||||
run_anon() {
|
||||
infomsg "Starting Oodikone with anonymous data..."
|
||||
npm run both "$@"
|
||||
}
|
||||
|
||||
run_down() {
|
||||
infomsg "Stopping Oodikone..."
|
||||
npm run docker:down
|
||||
npm run docker:down "$@"
|
||||
}
|
||||
|
||||
run_logs() {
|
||||
npm run logs
|
||||
npm run logs "$@"
|
||||
}
|
||||
|
||||
run_lint() {
|
||||
npm run lint -- "$@"
|
||||
}
|
||||
|
||||
run_format() {
|
||||
npm run fmt -- "$@"
|
||||
}
|
||||
|
||||
run_unit_tests() {
|
||||
npm run test:unit --prefix "$@"
|
||||
}
|
||||
|
||||
run_component_tests() {
|
||||
npm run test:component --prefix services/frontend "$@"
|
||||
}
|
||||
|
||||
run_service_tests() {
|
||||
npm run test:services --prefix services/backend "$@"
|
||||
}
|
||||
|
||||
run_api_tests() {
|
||||
npm run test:api --prefix services/backend "$@"
|
||||
}
|
||||
|
||||
run_e2e_tests() {
|
||||
npm run playwright -- "$@"
|
||||
}
|
||||
|
||||
run_logs_updater() {
|
||||
docker compose --file docker-compose.yml logs sis-updater-worker sis-updater-scheduler -f
|
||||
}
|
||||
|
||||
|
||||
run_importer_db() {
|
||||
local pgclient
|
||||
pgclient="$(get_pgclient)"
|
||||
@@ -271,17 +312,17 @@ cmd_install() {
|
||||
setup_colors
|
||||
|
||||
case "${1-}" in
|
||||
up|u)
|
||||
up|u|r|real)
|
||||
shift
|
||||
run_up "$@"
|
||||
;;
|
||||
rs)
|
||||
rs|rl)
|
||||
shift
|
||||
run_restart "$@"
|
||||
;;
|
||||
test|t)
|
||||
anon|a)
|
||||
shift
|
||||
run_test "$@"
|
||||
run_anon "$@"
|
||||
;;
|
||||
down|d)
|
||||
shift
|
||||
@@ -291,6 +332,42 @@ case "${1-}" in
|
||||
shift
|
||||
run_logs "$@"
|
||||
;;
|
||||
lint|li)
|
||||
shift
|
||||
run_lint "$@"
|
||||
;;
|
||||
format|fmt|f)
|
||||
shift
|
||||
run_format "$@"
|
||||
;;
|
||||
test:backend|tb)
|
||||
shift
|
||||
run_unit_tests services/backend "$@"
|
||||
;;
|
||||
test:frontend|tf)
|
||||
shift
|
||||
run_unit_tests services/frontend "$@"
|
||||
;;
|
||||
test:updater|tu)
|
||||
shift
|
||||
run_unit_tests updater/sis-updater-worker "$@"
|
||||
;;
|
||||
test:services|ts)
|
||||
shift
|
||||
run_service_tests "$@"
|
||||
;;
|
||||
test:api|ta)
|
||||
shift
|
||||
run_api_tests "$@"
|
||||
;;
|
||||
test:component|tc)
|
||||
shift
|
||||
run_component_tests "$@"
|
||||
;;
|
||||
test:e2e|te)
|
||||
shift
|
||||
run_e2e_tests "$@"
|
||||
;;
|
||||
logs:updater|lu)
|
||||
shift
|
||||
run_logs_updater "$@"
|
||||
|
||||
@@ -114,3 +114,4 @@ cypress/
|
||||
**/*Dockerfile
|
||||
**/build
|
||||
**/dist
|
||||
**/.npm
|
||||
|
||||
@@ -4,8 +4,6 @@ import { baseUrl, backendPort } from './config'
|
||||
import { initializeDatabaseConnection, dbConnections } from './database/connection'
|
||||
import { startCron } from './events'
|
||||
import routes from './routes'
|
||||
import { backfillExcludedCourseGroupIds } from './services/excludedCourses'
|
||||
import { backfillProgressCriteriaGroupIds } from './services/studyProgramme/studyProgrammeCriteria'
|
||||
import logger from './util/logger'
|
||||
import './worker/worker'
|
||||
|
||||
@@ -15,8 +13,6 @@ initializeDatabaseConnection()
|
||||
|
||||
dbConnections.on('connect', () => {
|
||||
logger.info('Connected to sis db successfully')
|
||||
backfillExcludedCourseGroupIds().catch(error => logger.error(error))
|
||||
backfillProgressCriteriaGroupIds().catch(error => logger.error(error))
|
||||
})
|
||||
|
||||
dbConnections.on('error', () => {
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
/* eslint-disable no-console */
|
||||
const { ARRAY, STRING } = require('sequelize')
|
||||
|
||||
const YEARS = ['one', 'two', 'three', 'four', 'five', 'six']
|
||||
const UNIQUE_CONSTRAINT = 'excluded_courses_programme_version_group_key'
|
||||
|
||||
// Migration step 2 of 2
|
||||
// Rows the backfill could not map to a course group id are removed, as they no longer reference a course.
|
||||
// NOTE: down only restores the schema, the dropped course codes cannot be restored.
|
||||
module.exports = {
|
||||
up: async ({ context: queryInterface }) => {
|
||||
await queryInterface.sequelize.transaction(async transaction => {
|
||||
const [, unmapped] = await queryInterface.sequelize.query(
|
||||
'DELETE FROM excluded_courses WHERE course_group_id IS NULL',
|
||||
{ transaction }
|
||||
)
|
||||
console.log(`Deleted ${unmapped.rowCount} excluded courses without a course group id`)
|
||||
|
||||
const [, duplicates] = await queryInterface.sequelize.query(
|
||||
`DELETE FROM excluded_courses a
|
||||
USING excluded_courses b
|
||||
WHERE a.id > b.id
|
||||
AND a.programme_code = b.programme_code
|
||||
AND a.curriculum_version IS NOT DISTINCT FROM b.curriculum_version
|
||||
AND a.course_group_id = b.course_group_id`,
|
||||
{ transaction }
|
||||
)
|
||||
console.log(`Deleted ${duplicates.rowCount} duplicate excluded courses`)
|
||||
|
||||
await queryInterface.removeColumn('excluded_courses', 'course_code', { transaction })
|
||||
await queryInterface.changeColumn(
|
||||
'excluded_courses',
|
||||
'course_group_id',
|
||||
{ type: STRING, allowNull: false },
|
||||
{ transaction }
|
||||
)
|
||||
await queryInterface.addConstraint('excluded_courses', {
|
||||
fields: ['programme_code', 'curriculum_version', 'course_group_id'],
|
||||
type: 'unique',
|
||||
name: UNIQUE_CONSTRAINT,
|
||||
transaction,
|
||||
})
|
||||
|
||||
for (const year of YEARS) {
|
||||
const [[{ count }]] = await queryInterface.sequelize.query(
|
||||
`SELECT COUNT(*) AS count FROM progress_criteria
|
||||
WHERE course_group_ids_year_${year} IS NULL AND cardinality(courses_year_${year}) > 0`,
|
||||
{ transaction }
|
||||
)
|
||||
console.log(`Emptied year ${year} of ${count} progress criteria without course group ids`)
|
||||
|
||||
await queryInterface.sequelize.query(
|
||||
`UPDATE progress_criteria SET course_group_ids_year_${year} = '{}' WHERE course_group_ids_year_${year} IS NULL`,
|
||||
{ transaction }
|
||||
)
|
||||
await queryInterface.removeColumn('progress_criteria', `courses_year_${year}`, { transaction })
|
||||
await queryInterface.changeColumn(
|
||||
'progress_criteria',
|
||||
`course_group_ids_year_${year}`,
|
||||
{ type: ARRAY(STRING), allowNull: false, defaultValue: [] },
|
||||
{ transaction }
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
down: async ({ context: queryInterface }) => {
|
||||
await queryInterface.sequelize.transaction(async transaction => {
|
||||
await queryInterface.removeConstraint('excluded_courses', UNIQUE_CONSTRAINT, { transaction })
|
||||
await queryInterface.changeColumn(
|
||||
'excluded_courses',
|
||||
'course_group_id',
|
||||
{ type: STRING, allowNull: true },
|
||||
{ transaction }
|
||||
)
|
||||
await queryInterface.addColumn('excluded_courses', 'course_code', { type: STRING }, { transaction })
|
||||
|
||||
for (const year of YEARS) {
|
||||
await queryInterface.changeColumn(
|
||||
'progress_criteria',
|
||||
`course_group_ids_year_${year}`,
|
||||
{ type: ARRAY(STRING), allowNull: true, defaultValue: null },
|
||||
{ transaction }
|
||||
)
|
||||
await queryInterface.addColumn(
|
||||
'progress_criteria',
|
||||
`courses_year_${year}`,
|
||||
{ type: ARRAY(STRING) },
|
||||
{ transaction }
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -16,9 +16,6 @@ export class ExcludedCourseModel extends Model implements ExcludedCourse {
|
||||
@Column(DataType.STRING)
|
||||
declare programme_code: ExcludedCourse['programme_code']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
declare course_code: ExcludedCourse['course_code']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
declare course_group_id: ExcludedCourse['course_group_id']
|
||||
|
||||
|
||||
@@ -16,24 +16,6 @@ export class ProgressCriteriaModel extends Model implements ProgressCriteria {
|
||||
@Column(DataType.STRING)
|
||||
declare curriculumVersion: ProgressCriteria['curriculumVersion']
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
declare coursesYearOne: ProgressCriteria['coursesYearOne']
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
declare coursesYearTwo: ProgressCriteria['coursesYearTwo']
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
declare coursesYearThree: ProgressCriteria['coursesYearThree']
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
declare coursesYearFour: ProgressCriteria['coursesYearFour']
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
declare coursesYearFive: ProgressCriteria['coursesYearFive']
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
declare coursesYearSix: ProgressCriteria['coursesYearSix']
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
declare courseGroupIdsYearOne: ProgressCriteria['courseGroupIdsYearOne']
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Op } from 'sequelize'
|
||||
|
||||
import { CourseModel } from '../models'
|
||||
import { ExcludedCourseModel } from '../models/kone'
|
||||
import logger from '../util/logger'
|
||||
|
||||
export const addExcludedCourses = async (
|
||||
courseGroupIds: string[],
|
||||
@@ -44,42 +42,3 @@ export const removeExcludedCourses = async (
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills course_group_id for rows that only have the legacy course_code. Safe to run repeatedly.
|
||||
* Rows whose code cannot be found in the course table are left untouched and reported.
|
||||
*/
|
||||
export const backfillExcludedCourseGroupIds = async () => {
|
||||
const legacyRows = await ExcludedCourseModel.findAll({
|
||||
attributes: ['course_code'],
|
||||
where: { course_group_id: null, course_code: { [Op.ne]: null } },
|
||||
group: ['course_code'],
|
||||
raw: true,
|
||||
})
|
||||
if (legacyRows.length === 0) return
|
||||
|
||||
const courseCodes = legacyRows.map(({ course_code }) => course_code!)
|
||||
const courses = await CourseModel.findAll({
|
||||
attributes: ['code', 'groupId'],
|
||||
where: { code: { [Op.in]: courseCodes } },
|
||||
raw: true,
|
||||
})
|
||||
const groupIdByCode = new Map(courses.map(({ code, groupId }) => [code, groupId]))
|
||||
|
||||
let updated = 0
|
||||
for (const courseCode of courseCodes) {
|
||||
const groupId = groupIdByCode.get(courseCode)
|
||||
if (!groupId) continue
|
||||
const [count] = await ExcludedCourseModel.update(
|
||||
{ course_group_id: groupId },
|
||||
{ where: { course_code: courseCode, course_group_id: null } }
|
||||
)
|
||||
updated += count
|
||||
}
|
||||
|
||||
const unmapped = courseCodes.filter(code => !groupIdByCode.has(code))
|
||||
logger.info(`Backfilled course_group_id for ${updated} excluded courses`)
|
||||
if (unmapped.length > 0) {
|
||||
logger.warn(`Could not find a course group id for excluded course codes: ${unmapped.join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Op } from 'sequelize'
|
||||
import { col, Op } from 'sequelize'
|
||||
|
||||
import { SISStudyRight, SISStudyRightElement, Student, Studyplan } from '@oodikone/shared/models'
|
||||
import { Tag, TagStudent } from '@oodikone/shared/models/kone'
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
StudyplanModel,
|
||||
SISStudyRightModel,
|
||||
SISStudyRightElementModel,
|
||||
CourseModel,
|
||||
} from '../../models'
|
||||
import { TagModel, TagStudentModel } from '../../models/kone'
|
||||
import { StudentCredit, StudentEnrollment } from '@oodikone/shared/types/studentData'
|
||||
@@ -190,11 +191,7 @@ export const getStudents = (studentNumbers: string[]): Promise<Array<StudentData
|
||||
},
|
||||
}).then(data => data.map(x => x.get({ plain: true })))
|
||||
|
||||
export const getCredits = async (
|
||||
studentNumbers: string[],
|
||||
startDate: string,
|
||||
endDate: string
|
||||
): Promise<StudentCredit[]> =>
|
||||
export const getCredits = async (studentNumbers: string[], startDate: string, endDate: string) =>
|
||||
CreditModel.findAll({
|
||||
raw: true,
|
||||
attributes: [
|
||||
@@ -208,12 +205,19 @@ export const getCredits = async (
|
||||
'course_code',
|
||||
'language',
|
||||
'studyright_id',
|
||||
[col('course.group_id'), 'courseGroupId'],
|
||||
],
|
||||
include: {
|
||||
model: CourseModel,
|
||||
attributes: [],
|
||||
as: 'course',
|
||||
required: true,
|
||||
},
|
||||
where: {
|
||||
student_studentnumber: { [Op.in]: studentNumbers },
|
||||
attainment_date: { [Op.between]: [startDate, endDate] },
|
||||
},
|
||||
})
|
||||
}) as unknown as Promise<StudentCredit[]>
|
||||
|
||||
export const getStudyRightElementsForStudyRight = async (studentNumbers: string[], code: string) =>
|
||||
SISStudyRightElementModel.findAll({
|
||||
|
||||
@@ -142,9 +142,7 @@ const labelProgrammes = (modules: ModuleWithChildren[], excludedCourses: Exclude
|
||||
label: `${module.parent_code}\n${module.parent_name?.fi}`,
|
||||
orderNumber: module.module_order,
|
||||
}
|
||||
const foundCourse = excludedCourses.find(course =>
|
||||
course.course_group_id ? course.course_group_id === module.group_id : course.course_code === module.code
|
||||
)
|
||||
const foundCourse = excludedCourses.find(course => course.course_group_id === module.group_id)
|
||||
const visible = { visibility: !foundCourse, id: foundCourse?.id ?? null }
|
||||
|
||||
return { ...module, label, visible }
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
export const CRITERIA_YEARS = ['One', 'Two', 'Three', 'Four', 'Five', 'Six'] as const
|
||||
|
||||
export type CriteriaYear = (typeof CRITERIA_YEARS)[number]
|
||||
|
||||
/**
|
||||
* Converts course codes to course group ids, keeping the order and dropping duplicates.
|
||||
* Codes that are not in the mapping are returned in `unmapped`.
|
||||
*/
|
||||
export const codesToGroupIds = (codes: readonly string[], groupIdByCode: ReadonlyMap<string, string>) => {
|
||||
const groupIds = new Set<string>()
|
||||
const unmapped: string[] = []
|
||||
for (const code of codes) {
|
||||
const groupId = groupIdByCode.get(code)
|
||||
if (groupId) {
|
||||
groupIds.add(groupId)
|
||||
} else {
|
||||
unmapped.push(code)
|
||||
}
|
||||
}
|
||||
return { groupIds: [...groupIds], unmapped }
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { ProgressCriteria } from '@oodikone/shared/types'
|
||||
import { CourseModel } from '../../models'
|
||||
import { ProgressCriteriaModel } from '../../models/kone'
|
||||
import logger from '../../util/logger'
|
||||
import { CRITERIA_YEARS, codesToGroupIds } from './criteriaCourseGroupIds'
|
||||
|
||||
type CriteriaWithoutCurriculumVersion = Omit<ProgressCriteriaModel, 'curriculumVersion'>
|
||||
|
||||
@@ -14,16 +13,6 @@ const getCriteriaByStudyProgramme = async (code: string): Promise<CriteriaWithou
|
||||
where: { code },
|
||||
})
|
||||
|
||||
const getGroupIdByCode = async (codes: string[]) => {
|
||||
if (codes.length === 0) return new Map<string, string>()
|
||||
const courses: Array<Pick<CourseModel, 'code' | 'groupId'>> = await CourseModel.findAll({
|
||||
attributes: ['code', 'groupId'],
|
||||
where: { code: { [Op.in]: codes } },
|
||||
raw: true,
|
||||
})
|
||||
return new Map(courses.map(({ code, groupId }) => [code, groupId]))
|
||||
}
|
||||
|
||||
/** Construct course_group_id => substitutionGroups object */
|
||||
const getSubstitutions = async (courseGroupIds: string[]) => {
|
||||
const courses: Array<Pick<CourseModel, 'groupId' | 'substitutionGroups' | 'isPrimary'>> = await CourseModel.findAll({
|
||||
@@ -49,28 +38,13 @@ const getSubstitutions = async (courseGroupIds: string[]) => {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: Remove the fallback to coursesYear* (course codes) once all rows have courseGroupIdsYear*
|
||||
* populated and the old columns are dropped.
|
||||
*/
|
||||
const formatCriteria = async (criteria: CriteriaWithoutCurriculumVersion | null) => {
|
||||
const legacyCodes = CRITERIA_YEARS.flatMap(year =>
|
||||
criteria && !criteria[`courseGroupIdsYear${year}`] ? (criteria[`coursesYear${year}`] ?? []) : []
|
||||
)
|
||||
const groupIdByCode = await getGroupIdByCode(legacyCodes)
|
||||
|
||||
const getYear = (year: (typeof CRITERIA_YEARS)[number]) => {
|
||||
const courseGroupIds = criteria?.[`courseGroupIdsYear${year}`]
|
||||
if (courseGroupIds) return courseGroupIds
|
||||
return codesToGroupIds(criteria?.[`coursesYear${year}`] ?? [], groupIdByCode).groupIds
|
||||
}
|
||||
|
||||
const yearOne = getYear('One')
|
||||
const yearTwo = getYear('Two')
|
||||
const yearThree = getYear('Three')
|
||||
const yearFour = getYear('Four')
|
||||
const yearFive = getYear('Five')
|
||||
const yearSix = getYear('Six')
|
||||
const yearOne = criteria?.courseGroupIdsYearOne ?? []
|
||||
const yearTwo = criteria?.courseGroupIdsYearTwo ?? []
|
||||
const yearThree = criteria?.courseGroupIdsYearThree ?? []
|
||||
const yearFour = criteria?.courseGroupIdsYearFour ?? []
|
||||
const yearFive = criteria?.courseGroupIdsYearFive ?? []
|
||||
const yearSix = criteria?.courseGroupIdsYearSix ?? []
|
||||
const courseGroupIds = [...yearOne, ...yearTwo, ...yearThree, ...yearFour, ...yearFive, ...yearSix]
|
||||
|
||||
const formattedCriteria: ProgressCriteria = {
|
||||
@@ -197,39 +171,3 @@ export const getCriteria = async (studyProgramme: string) => {
|
||||
|
||||
return await formatCriteria(studyProgrammeCriteria)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills courseGroupIdsYear* for rows that only have the legacy coursesYear* (course codes). Safe to run repeatedly.
|
||||
* A year whose codes cannot all be found in the course table is left untouched and reported.
|
||||
*/
|
||||
export const backfillProgressCriteriaGroupIds = async () => {
|
||||
const rows = await ProgressCriteriaModel.findAll()
|
||||
const rowsToBackfill = rows.filter(row =>
|
||||
CRITERIA_YEARS.some(year => !row[`courseGroupIdsYear${year}`] && row[`coursesYear${year}`])
|
||||
)
|
||||
if (rowsToBackfill.length === 0) return
|
||||
|
||||
const allCodes = rowsToBackfill.flatMap(row => CRITERIA_YEARS.flatMap(year => row[`coursesYear${year}`] ?? []))
|
||||
const groupIdByCode = await getGroupIdByCode([...new Set(allCodes)])
|
||||
|
||||
let updatedYears = 0
|
||||
for (const row of rowsToBackfill) {
|
||||
const updates: Partial<Record<`courseGroupIdsYear${(typeof CRITERIA_YEARS)[number]}`, string[]>> = {}
|
||||
for (const year of CRITERIA_YEARS) {
|
||||
const codes = row[`coursesYear${year}`]
|
||||
if (row[`courseGroupIdsYear${year}`] || !codes) continue
|
||||
|
||||
const { groupIds, unmapped } = codesToGroupIds(codes, groupIdByCode)
|
||||
if (unmapped.length > 0) {
|
||||
logger.warn(
|
||||
`Could not find a course group id for progress criteria codes of ${row.code} (${year}): ${unmapped.join(', ')}`
|
||||
)
|
||||
continue
|
||||
}
|
||||
updates[`courseGroupIdsYear${year}`] = groupIds
|
||||
updatedYears++
|
||||
}
|
||||
if (Object.keys(updates).length > 0) await row.update(updates)
|
||||
}
|
||||
logger.info(`Backfilled course group ids for ${updatedYears} progress criteria years`)
|
||||
}
|
||||
|
||||
@@ -139,17 +139,19 @@ export const getYearlyMonthlyCreditsObj = () => {
|
||||
return yearlyMonthlyCredits
|
||||
}
|
||||
|
||||
const getMonthlyCreditsObj = (academicStartYear?: number) => {
|
||||
const getMonthlyCreditsObj = (academicStartYear = 2017) => {
|
||||
const today = now()
|
||||
const time = getStartDate(true, academicStartYear)
|
||||
|
||||
/* Compare whole months instead of dates, so the result does not depend on the time of day.
|
||||
Academic year starts in August (month index 7). */
|
||||
const firstMonth = academicStartYear * 12 + 7
|
||||
const lastMonth = today.getFullYear() * 12 + today.getMonth()
|
||||
|
||||
const monthlyCredits: Record<string, number[]> = {}
|
||||
today.setDate(1)
|
||||
|
||||
/* NB: JS months start at 0. We need them to start at 1. */
|
||||
while (time < today) {
|
||||
monthlyCredits[`${time.getFullYear()}-${time.getMonth() + 1}`] = []
|
||||
time.setMonth(time.getMonth() + 1)
|
||||
for (let month = firstMonth; month <= lastMonth; month++) {
|
||||
monthlyCredits[`${Math.floor(month / 12)}-${(month % 12) + 1}`] = []
|
||||
}
|
||||
|
||||
return monthlyCredits
|
||||
@@ -191,8 +193,7 @@ export const computePercentiles = (
|
||||
return values
|
||||
}
|
||||
|
||||
// In the object programmes should be {bachelorCode: masterCode}
|
||||
export const combinedStudyProgrammes = { KH90_001: 'MH90_001' } as const
|
||||
export { combinedStudyProgrammes } from '@oodikone/shared/util/studyProgramme'
|
||||
|
||||
// There are 9 course_unit_types
|
||||
// 1. urn:code:course-unit-type:regular
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, it, assert, vi, beforeAll } from 'vitest'
|
||||
import { getPercentage, getYearsArray, getYearsObject } from '@/services/studyProgramme/studyProgrammeHelpers'
|
||||
import {
|
||||
getMonthlyCredits,
|
||||
getPercentage,
|
||||
getYearlyMonthlyCreditsObj,
|
||||
getYearsArray,
|
||||
getYearsObject,
|
||||
} from '@/services/studyProgramme/studyProgrammeHelpers'
|
||||
import { clockStorage } from '@/util/clock'
|
||||
|
||||
void describe('Get years object', () => {
|
||||
it('should return nothing with empty years', () => {
|
||||
@@ -95,3 +102,36 @@ void describe('Get percentage', () => {
|
||||
assert.strictEqual(getPercentage(-5, 10), '-50.0 %')
|
||||
})
|
||||
})
|
||||
|
||||
void describe('Get monthly credits', () => {
|
||||
const atTime = <T>(date: string, fn: () => T) => clockStorage.run({ now: new Date(date) }, fn)
|
||||
|
||||
it('should include the current month regardless of the time of day', () => {
|
||||
const lastKey = (date: string) => Object.keys(atTime(date, () => getYearlyMonthlyCreditsObj())[2024]).at(-1)
|
||||
|
||||
assert.strictEqual(lastKey('2026-10-07T00:05:00'), '2026-10')
|
||||
assert.strictEqual(lastKey('2026-10-07T23:40:00'), '2026-10')
|
||||
})
|
||||
|
||||
it('should not throw when computation continues past midnight', () => {
|
||||
const monthlyCreditsByStartingYear = atTime('2026-10-06T23:40:00', () => getYearlyMonthlyCreditsObj())
|
||||
|
||||
assert.doesNotThrow(() =>
|
||||
atTime('2026-10-07T00:05:00', () =>
|
||||
getMonthlyCredits([], new Date('2024-08-01'), 2024, monthlyCreditsByStartingYear[2024])
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('should accumulate credits cumulatively by month', () => {
|
||||
const monthlyCredits = atTime('2024-10-15T12:00:00', () => getYearlyMonthlyCreditsObj())[2024]
|
||||
const credits = [
|
||||
{ attainment_date: new Date('2024-08-20'), credits: 5 },
|
||||
{ attainment_date: new Date('2024-10-01'), credits: 3 },
|
||||
] as Parameters<typeof getMonthlyCredits>[0]
|
||||
|
||||
atTime('2024-10-15T12:00:00', () => getMonthlyCredits(credits, new Date('2024-08-01'), 2024, monthlyCredits))
|
||||
|
||||
assert.deepStrictEqual(monthlyCredits, { '2024-8': [5], '2024-9': [5], '2024-10': [8] })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
FROM registry.access.redhat.com/ubi10/nodejs-24-minimal
|
||||
|
||||
ENV TZ="Europe/Helsinki"
|
||||
# Default install location is node_modules/.vite which is not writable
|
||||
# by user 1001 when binding the local files to the container
|
||||
ENV VITE_CACHE_DIR=/tmp/vite
|
||||
|
||||
WORKDIR /opt/app-root/shared
|
||||
COPY ./shared .
|
||||
|
||||
@@ -191,28 +191,32 @@ However this is optional and you can create a new tag without selecting year.
|
||||
|
||||
degreeCoursesTab: {
|
||||
creditCriteria: {
|
||||
en: `The criteria set here will affect the Class Statistics → Students tab → Progress tab view.
|
||||
en: `The targets set here will affect the Class Statistics → Students tab → Progress tab view.
|
||||
|
||||
Credit criteria is computed as follows: for the first academic year, the credits are counted
|
||||
if they are completed during the first 12 months. For the second year, completions
|
||||
within the first 24 months. Third year, the first 36 months, and so on.
|
||||
`,
|
||||
fi: `Tässä asetettu "criteria" vaikuttaa Class Statistics → Students tab → Progress tab näkymään.
|
||||
fi: `Tässä asetetut tavoitteet vaikuttavat Class Statistics → Students tab → Progress tab näkymään.
|
||||
|
||||
Laskenta toimii seuraavasti: ensimmäisen akateemisen vuoden opintopisteet lasketaan, jos ne ovat suoritettu ensimmäisen 12 kuukauden ajalla. Toisen vuoden opintopisteet, jos ne ovat suoritettu ensimmäisen 24 kuukauden aikana. Kolmas, ensimmäiset 36kk, ja niin edelleen.
|
||||
`,
|
||||
},
|
||||
programmeCriteriaAndVisibility: {
|
||||
en: `Click the arrow next to the module to view courses belonging to the module in the curriculum. Setting a criterion label for a course will affect which academic year the course is placed under in Class Statistics → Students → Progress tab.
|
||||
requiredCourses: {
|
||||
en: `Click a module to see its courses in the selected curriculum.
|
||||
|
||||
Courses' visibilities can also be toggled here; hiding a course here will additionally hide it in the Courses of Class view and Students → Courses tab.
|
||||
Tick a year to make the course a required course for that academic year in Class Statistics → Students → Progress tab. A course can be required for several years. The numbers on a module row show how many of its courses are required for each year.`,
|
||||
fi: `Klikkaa opintokokonaisuutta nähdäksesi sen kurssit valitussa opetussuunnitelmassa.
|
||||
|
||||
Note: If hiding a module, all courses belonging to the module will also be hidden.`,
|
||||
fi: `Klikkaa nuolta opintokokonaisuuden nimen vieressä näyttääksesi opetussuunitelmassa opintokokonaisuuden alle sijoitetut kurssit. Kursseille voi asettaa "Criterion label":eita, jotka vaikuttavat minkä akateemisen vuoden alla kurssi näytetään Class Statistics → Students → Progress tab näkymässä.
|
||||
Valitse vuosi, jolloin kurssi on tarkoitus suorittaa. Kurssi näytetään kyseisen akateemisen vuoden alla Class Statistics → Students → Progress tab -näkymässä. Kurssi voi olla pakollinen useammalle vuodelle. Opintokokonaisuuden rivillä olevat luvut kertovat, kuinka moni sen kursseista on pakollinen kunakin vuonna. Huom. kurssi voi kuulua useaan opintokokonaisuuteen.`,
|
||||
},
|
||||
courseVisibility: {
|
||||
en: `Click a module to see its courses in the selected curriculum.
|
||||
|
||||
Kurssien näkyvyyttä voidaan myös muuttaa tällä sivulla. Kurssin piilottaminen piilottaa sen myös Courses of Class ja Students → Courses tab näkymissä.
|
||||
Hiding a course also hides it in the Courses of Class view and Students → Courses tab for all users. Hiding a module hides all of its courses.`,
|
||||
fi: `Klikkaa opintokokonaisuutta nähdäksesi sen kurssit valitussa opetussuunnitelmassa.
|
||||
|
||||
Huom: Opintokokonaisuuden piilottaminen piilottaa myös kaikki sen alla olevat kurssit.`,
|
||||
Kurssin piilottaminen piilottaa sen kaikilta käyttäjiltä myös Courses of Class ja Students → Courses tab -näkymissä. Opintokokonaisuuden piilottaminen piilottaa kaikki sen kurssit.`,
|
||||
},
|
||||
},
|
||||
// Not used
|
||||
|
||||
@@ -20,10 +20,10 @@ export const CourseMatrixExport = ({
|
||||
const { getTextIn } = useLanguage()
|
||||
|
||||
const getXlsx = () => {
|
||||
const courseInfoById = new Map(
|
||||
courses.map(({ course }) => [course.id, { code: course.code, name: getTextIn(course.name) ?? '' }])
|
||||
const courseInfoByGroupId = new Map(
|
||||
courses.map(({ course }) => [course.groupId, { code: course.code, name: getTextIn(course.name) ?? '' }])
|
||||
)
|
||||
const { completedCoursesRows, courseCounterRows } = calculateExcelData(students, courseInfoById)
|
||||
const { completedCoursesRows, courseCounterRows } = calculateExcelData(students, courseInfoByGroupId)
|
||||
|
||||
// Columns: Student number, Name, student's attainments in the format: "course name (course code)"
|
||||
const completedCoursesSheet = utils.aoa_to_sheet([['Student number', 'Name'], ...completedCoursesRows])
|
||||
|
||||
@@ -63,7 +63,7 @@ export const CustomPopulationContent = ({
|
||||
|
||||
const panels = [
|
||||
{
|
||||
title: `Credit accumulation (for ${filteredStudents.length} students)`,
|
||||
title: `Credit accumulation (for ${filteredStudents.length} student${filteredStudents.length > 1 ? 's' : ''})`,
|
||||
content: (
|
||||
<CreditAccumulationGraph
|
||||
programmeCodes={[associatedProgramme].filter(Boolean) as string[]}
|
||||
|
||||
@@ -21,39 +21,39 @@ export type CourseMatrixAggregate = {
|
||||
|
||||
/**
|
||||
* Passed completions of the student that are included in one of the student's study plans (HOPS).
|
||||
* Returns the latest completion per course id, sorted by course id.
|
||||
* Returns the latest completion per course group, sorted by course group id.
|
||||
*/
|
||||
export const getHopsCourses = (student: FormattedStudent): StudentCourse[] => {
|
||||
const hopsCourseIds = new Set(student.studyplans?.flatMap(plan => plan.included_courses ?? []) ?? [])
|
||||
const passedCoursesInHops = student.courses.filter(course => course.passed && hopsCourseIds.has(course.course_id))
|
||||
|
||||
const latestById = new Map<string, StudentCourse>()
|
||||
const latestByGroupId = new Map<string, StudentCourse>()
|
||||
for (const course of passedCoursesInHops) {
|
||||
if (!course.course_id) continue
|
||||
if (!course.courseGroupId) continue
|
||||
|
||||
const existing = latestById.get(course.course_id)
|
||||
const existing = latestByGroupId.get(course.courseGroupId)
|
||||
if (!existing || new Date(course.date).getTime() > new Date(existing.date).getTime()) {
|
||||
latestById.set(course.course_id, course)
|
||||
latestByGroupId.set(course.courseGroupId, course)
|
||||
}
|
||||
}
|
||||
|
||||
return [...latestById.values()].sort((a, b) => a.course_id.localeCompare(b.course_id))
|
||||
return [...latestByGroupId.values()].sort((a, b) => a.courseGroupId.localeCompare(b.courseGroupId))
|
||||
}
|
||||
|
||||
export const calculateExcelData = (
|
||||
students: FormattedStudent[],
|
||||
courseInfoById: Map<string, { code: string; name: string }>
|
||||
courseInfoByGroupId: Map<string, { code: string; name: string }>
|
||||
) => {
|
||||
// Keyed by course_id internally so different courses are never accidentally merged;
|
||||
// code/name are only for display.
|
||||
// Keyed by course groupId internally so different courses are never accidentally merged,
|
||||
// while different versions of the same course are; code/name are only for display.
|
||||
const counters = new Map<string, { code: string; name: string; credits: number; students: Set<string> }>()
|
||||
|
||||
const rows = students.map(student => {
|
||||
const courses = getHopsCourses(student).map(course => {
|
||||
const id = course.course_id
|
||||
const { code, name } = courseInfoById.get(id) ?? { code: id, name: '' }
|
||||
const counter = counters.get(id) ?? { code, name, credits: 0, students: new Set() }
|
||||
counters.set(id, counter)
|
||||
const groupId = course.courseGroupId
|
||||
const { code, name } = courseInfoByGroupId.get(groupId) ?? { code: course.course_code, name: '' }
|
||||
const counter = counters.get(groupId) ?? { code, name, credits: 0, students: new Set() }
|
||||
counters.set(groupId, counter)
|
||||
counter.credits += course.credits ?? 0
|
||||
counter.students.add(student.studentNumber)
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@ import { FilterViewContext } from '@/components/FilterView/context'
|
||||
import type { FilterContext, FilterOptions, GenericFilter } from '@/components/FilterView/filters/createFilter'
|
||||
import { FilterTray } from '@/components/FilterView/FilterTray'
|
||||
import { useFilterStorage } from '@/components/FilterView/useFilterStorage'
|
||||
import type { ExpandedCourseStats } from '@/redux/populations/util'
|
||||
import type { FilteredCourse } from '@/util/coursesOfPopulation'
|
||||
import { filterCourses } from '@/util/coursesOfPopulation'
|
||||
import type { FormattedStudent as Student } from '@oodikone/shared/types/studentData'
|
||||
import type { PopulationCourseStats } from '@oodikone/shared/routes/populations'
|
||||
|
||||
export const FilterView = <Options extends FilterOptions>({
|
||||
children,
|
||||
@@ -20,7 +20,7 @@ export const FilterView = <Options extends FilterOptions>({
|
||||
initialOptions,
|
||||
}: {
|
||||
students: Student[]
|
||||
coursestatistics: ExpandedCourseStats | undefined
|
||||
coursestatistics: PopulationCourseStats | undefined
|
||||
children: (filteredStudents: Student[], filteredCourses: FilteredCourse[]) => ReactNode
|
||||
filters: GenericFilter<Options>[]
|
||||
initialOptions: Options
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { StyledMessage } from '@/components/common/StyledMessage'
|
||||
import { getHiddenCourseCodes, getHiddenModuleCodes } from '@/components/HiddenCourseAlert/util'
|
||||
import { useCurriculum } from '@/hooks/useCurriculum'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
|
||||
/**
|
||||
* Displays if course visibility is modified in the degree programme settings
|
||||
*/
|
||||
export const HiddenCourseAlert = ({
|
||||
ooditable,
|
||||
modules,
|
||||
}: {
|
||||
/** aligns to the right edge in toolbar */
|
||||
ooditable?: true
|
||||
/** show only modules whose courses are all hidden */
|
||||
modules?: true
|
||||
}) => {
|
||||
const { curriculum } = useCurriculum()
|
||||
|
||||
const curriculumCourses = curriculum
|
||||
? [...curriculum.defaultProgrammeCourses, ...curriculum.secondProgrammeCourses]
|
||||
: []
|
||||
|
||||
const hiddenCodes = modules ? getHiddenModuleCodes(curriculumCourses) : getHiddenCourseCodes(curriculumCourses)
|
||||
|
||||
const count = hiddenCodes.length
|
||||
|
||||
if (!count) return null
|
||||
|
||||
const [singular, plural] = modules ? ['module', 'modules'] : ['course', 'courses']
|
||||
const alertText = `${count} ${count > 1 ? plural : singular} hidden`
|
||||
const tooltipText = `${hiddenCodes.slice(0, Math.min(count, 10)).join(', ')} ${count > 10 ? '+ ' + (count - 10).toString() + ' others' : ''}`
|
||||
|
||||
return (
|
||||
<Tooltip title={tooltipText}>
|
||||
<span style={ooditable ? { marginLeft: 'auto' } : {}}>
|
||||
<StyledMessage showIcon severity="warning" sx={{ py: 0, px: 1, m: 0 }}>
|
||||
{alertText}
|
||||
</StyledMessage>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ProgrammeCourse } from '@oodikone/shared/types'
|
||||
|
||||
export const getHiddenCourseCodes = (courses: ProgrammeCourse[]): string[] =>
|
||||
courses.filter(course => course.visible.visibility === false).map(course => course.code)
|
||||
|
||||
/**
|
||||
* A module is hidden if every course that has it as the parent is hidden
|
||||
*/
|
||||
export const getHiddenModuleCodes = (courses: ProgrammeCourse[]): string[] => {
|
||||
const moduleVisibility = new Map<string, boolean>()
|
||||
for (const course of courses) {
|
||||
if (!course.parent_code) continue
|
||||
const visible = course.visible.visibility !== false
|
||||
moduleVisibility.set(course.parent_code, (moduleVisibility.get(course.parent_code) ?? false) || visible)
|
||||
}
|
||||
|
||||
return [...moduleVisibility.entries()].filter(([, visible]) => !visible).map(([code]) => code)
|
||||
}
|
||||
@@ -17,18 +17,19 @@ export const OodiTableContainer = <OTData extends RowData>({
|
||||
cy,
|
||||
}: {
|
||||
table: TableType<OTData>
|
||||
toolbarContent?: ReactNode
|
||||
toolbarContent?: ReactNode | ((table: TableType<OTData>) => ReactNode)
|
||||
isExportView?: true
|
||||
cy?: string
|
||||
}) => {
|
||||
const verticalHeaders = table.getState().useVerticalHeaders ?? []
|
||||
const zebraStriped = table.getState().useZebrastripes
|
||||
const toolbar = typeof toolbarContent === 'function' ? toolbarContent(table) : toolbarContent
|
||||
|
||||
return (
|
||||
<Paper data-cy={cy} sx={{ my: 2, borderRadius: 0 }} variant="outlined">
|
||||
{!!toolbarContent && (
|
||||
<Stack direction="row" spacing={3} sx={{ p: 2, justifyContent: 'flex-start' }}>
|
||||
{toolbarContent}
|
||||
{!!toolbar && (
|
||||
<Stack direction="row" spacing={3} sx={{ p: 2 }} useFlexGap>
|
||||
{toolbar}
|
||||
</Stack>
|
||||
)}
|
||||
<TableContainer
|
||||
|
||||
@@ -72,3 +72,17 @@ const tableOptions = {
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Toolbar
|
||||
|
||||
`toolbarContent` is rendered above the table. Pass a function instead of a node when the toolbar needs the table
|
||||
instance, e.g. to act on the currently filtered rows. Keep filtering in the table (column/global filters) and read the
|
||||
result from it rather than reimplementing the filter outside.
|
||||
|
||||
```tsx
|
||||
<OodiTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
toolbarContent={table => <CopyButton rows={table.getFilteredRowModel().rows.map(row => row.original)} />}
|
||||
/>
|
||||
```
|
||||
|
||||
@@ -13,7 +13,7 @@ import { OodiTableContainer } from '@/components/OodiTable/OodiTable'
|
||||
* @param data - Array of data objects of type TData
|
||||
* @param columns - Array of column definitions for type TData
|
||||
* @param isExportView (default false) Is table used in an export modal to provide preview
|
||||
* @param toolbarContent - Shown above the table
|
||||
* @param toolbarContent - Shown above the table. Pass a function to read the table instance, e.g. filtered rows
|
||||
* @param options - Optional: Additional TableOptions
|
||||
* @param cy - Optional: data-cy tag provided to the wrapper around the TableContainer
|
||||
* @returns Table instance
|
||||
@@ -31,7 +31,7 @@ export const OodiTable = <TData extends RowData>({
|
||||
columns: ColumnDef<TData, any>[]
|
||||
isExportView?: true
|
||||
options?: Partial<TableOptions<TData>>
|
||||
toolbarContent?: ReactNode
|
||||
toolbarContent?: ReactNode | ((table: Table<TData>) => ReactNode)
|
||||
cy?: string
|
||||
}) => {
|
||||
const [fallbackData] = useState()
|
||||
|
||||
@@ -6,12 +6,11 @@ import Typography from '@mui/material/Typography'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
|
||||
import { StudentAmountLimiter } from '@/components/common/StudentAmountLimiter'
|
||||
import { ExtendedCurriculumDetails } from '@/hooks/useCurriculums'
|
||||
import { useCurriculum } from '@/hooks/useCurriculum'
|
||||
|
||||
type CourseTableModeSelectorProps = {
|
||||
courseTableMode: 'curriculum' | 'all'
|
||||
setCourseTableMode: Dispatch<SetStateAction<'curriculum' | 'all'>>
|
||||
curriculum: ExtendedCurriculumDetails | null
|
||||
studentAmountLimit: number
|
||||
onStudentAmountLimitChange: (input: string) => void
|
||||
}
|
||||
@@ -20,47 +19,50 @@ export const CourseTableModeSelector = ({
|
||||
courseTableMode,
|
||||
onStudentAmountLimitChange,
|
||||
setCourseTableMode,
|
||||
curriculum,
|
||||
studentAmountLimit,
|
||||
}: CourseTableModeSelectorProps) => (
|
||||
<RadioGroup>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Radio
|
||||
checked={courseTableMode === 'curriculum'}
|
||||
onChange={() => setCourseTableMode('curriculum')}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Stack flexDirection="row" gap={1} sx={{ alignContent: 'center' }}>
|
||||
<Typography fontWeight={500}>Curriculum</Typography>
|
||||
<Typography
|
||||
fontWeight={800}
|
||||
sx={{
|
||||
color: courseTableMode === 'curriculum' ? 'text.primary' : 'text.disabled',
|
||||
}}
|
||||
>
|
||||
{curriculum?.name ?? 'unavailable'}
|
||||
</Typography>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', mt: '0.5em' }}>
|
||||
}: CourseTableModeSelectorProps) => {
|
||||
const { curriculum } = useCurriculum()
|
||||
|
||||
return (
|
||||
<RadioGroup>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<>
|
||||
<Radio checked={courseTableMode === 'all'} onChange={() => setCourseTableMode('all')} size="small" />
|
||||
<StudentAmountLimiter
|
||||
disabled={courseTableMode !== 'all'}
|
||||
onStudentAmountLimitChange={value => onStudentAmountLimitChange(value.toString())}
|
||||
studentAmountLimit={studentAmountLimit}
|
||||
/>
|
||||
</>
|
||||
<Radio
|
||||
checked={courseTableMode === 'curriculum'}
|
||||
onChange={() => setCourseTableMode('curriculum')}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Stack flexDirection="row" gap={1} sx={{ alignContent: 'center' }}>
|
||||
<Typography fontWeight={500}>Curriculum</Typography>
|
||||
<Typography
|
||||
fontWeight={800}
|
||||
sx={{
|
||||
color: courseTableMode === 'curriculum' ? 'text.primary' : 'text.disabled',
|
||||
}}
|
||||
>
|
||||
{curriculum?.name ?? 'unavailable'}
|
||||
</Typography>
|
||||
</Stack>
|
||||
}
|
||||
label=""
|
||||
value={'all'}
|
||||
/>
|
||||
</Stack>
|
||||
</RadioGroup>
|
||||
)
|
||||
<Stack direction="row" sx={{ alignItems: 'center', mt: '0.5em' }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<>
|
||||
<Radio checked={courseTableMode === 'all'} onChange={() => setCourseTableMode('all')} size="small" />
|
||||
<StudentAmountLimiter
|
||||
disabled={courseTableMode !== 'all'}
|
||||
onStudentAmountLimitChange={value => onStudentAmountLimitChange(value.toString())}
|
||||
studentAmountLimit={studentAmountLimit}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
label=""
|
||||
value={'all'}
|
||||
/>
|
||||
</Stack>
|
||||
</RadioGroup>
|
||||
)
|
||||
}
|
||||
|
||||
+4
-4
@@ -5,19 +5,20 @@ import DialogContent from '@mui/material/DialogContent'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { DegreeCoursesTab } from '@/pages/StudyProgramme/DegreeCoursesTab'
|
||||
import { DegreeSettingsTab, DegreeCoursesTab } from '@/pages/StudyProgramme/DegreeCoursesTab'
|
||||
import { VisibilityIcon } from '@/theme'
|
||||
import { ManageCoursesShownInfo } from '@/pages/StudyProgramme/DegreeCoursesTab/InfoBox'
|
||||
|
||||
const defaultText = 'Manage course visibility'
|
||||
const defaultIcon = <VisibilityIcon />
|
||||
|
||||
export const FilterDegreeCoursesModal = ({
|
||||
degreeProgramme,
|
||||
initialTab,
|
||||
text,
|
||||
icon,
|
||||
}: {
|
||||
degreeProgramme: string
|
||||
initialTab: DegreeSettingsTab
|
||||
text?: string
|
||||
icon?: React.ReactNode
|
||||
}) => {
|
||||
@@ -31,8 +32,7 @@ export const FilterDegreeCoursesModal = ({
|
||||
<Dialog maxWidth="md" onClose={() => setModalOpen(false)} open={modalOpen}>
|
||||
<DialogTitle>Degree programme settings</DialogTitle>
|
||||
<DialogContent>
|
||||
<ManageCoursesShownInfo />
|
||||
<DegreeCoursesTab combinedProgramme="" degreeProgramme={degreeProgramme} />
|
||||
<DegreeCoursesTab degreeProgramme={degreeProgramme} initialTab={initialTab} />
|
||||
<Button color="primary" onClick={() => setModalOpen(false)} style={{ marginTop: '10px' }} variant="contained">
|
||||
Close
|
||||
</Button>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { FilterDegreeCoursesModal } from '@/components/PopulationComponents/Popu
|
||||
import { PopulationCourseStats } from '@/components/PopulationCourseStats'
|
||||
import { PopulationCourseStatsFlat } from '@/components/PopulationCourseStats/PopulationCourseStatsFlat'
|
||||
import { SegmentDimmer } from '@/components/SegmentDimmer'
|
||||
import { ExtendedCurriculumDetails } from '@/hooks/useCurriculums'
|
||||
import { PopulationQuery } from '@/types/populationSearch'
|
||||
import { FilteredCourse } from '@/util/coursesOfPopulation'
|
||||
|
||||
@@ -14,7 +13,6 @@ export const PopulationCourses = ({
|
||||
query,
|
||||
filteredCourses,
|
||||
onlyIamRights,
|
||||
curriculum,
|
||||
courseTableMode,
|
||||
studentAmountLimit,
|
||||
setShowModules,
|
||||
@@ -24,7 +22,6 @@ export const PopulationCourses = ({
|
||||
query: Pick<PopulationQuery, 'programme' | 'years'>
|
||||
filteredCourses: FilteredCourse[]
|
||||
onlyIamRights: boolean
|
||||
curriculum: ExtendedCurriculumDetails
|
||||
courseTableMode: 'curriculum' | 'all'
|
||||
studentAmountLimit: number
|
||||
setShowModules: (input: boolean) => void
|
||||
@@ -39,7 +36,11 @@ export const PopulationCourses = ({
|
||||
<>
|
||||
<Stack direction="row" spacing={2} sx={{ my: 1, justifyContent: 'space-between' }}>
|
||||
{query.programme && !onlyIamRights ? (
|
||||
<FilterDegreeCoursesModal degreeProgramme={query.programme} text="Manage course / module visibility" />
|
||||
<FilterDegreeCoursesModal
|
||||
degreeProgramme={query.programme}
|
||||
initialTab="visibility"
|
||||
text="Manage course / module visibility"
|
||||
/>
|
||||
) : null}
|
||||
<InfoBox content={tooltipText} />
|
||||
</Stack>
|
||||
@@ -47,7 +48,6 @@ export const PopulationCourses = ({
|
||||
{courseTableMode === 'curriculum' ? (
|
||||
<PopulationCourseStats
|
||||
courseTableMode={courseTableMode}
|
||||
curriculum={curriculum}
|
||||
filteredCourses={filteredCourses}
|
||||
onlyIamRights={onlyIamRights}
|
||||
pending={isLoading}
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
type ExportModule = {
|
||||
courses: { code: string; groupId: string }[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Student rows are keyed by course groupId, which is not readable.
|
||||
* Maps each course column's groupId to its course code for use as a key (and header) in the Excel export.
|
||||
*/
|
||||
export const getGroupIdToExportKey = (modules: Map<string, ExportModule>): Record<string, string> => {
|
||||
const groupIdToExportKey: Record<string, string> = {}
|
||||
const usedKeys = new Set<string>()
|
||||
|
||||
for (const { courses } of modules.values()) {
|
||||
for (const { code, groupId } of courses) {
|
||||
if (groupId in groupIdToExportKey) continue
|
||||
|
||||
const key = usedKeys.has(code) ? `${code} (${groupId})` : code
|
||||
groupIdToExportKey[groupId] = key
|
||||
usedKeys.add(key)
|
||||
}
|
||||
}
|
||||
|
||||
return groupIdToExportKey
|
||||
}
|
||||
|
||||
export const renameKeys = <T extends object>(row: T, keyMap: Record<string, string>): Record<string, unknown> =>
|
||||
Object.fromEntries(Object.entries(row).map(([key, value]) => [keyMap[key] ?? key, value]))
|
||||
+47
-49
@@ -5,15 +5,19 @@ import { OodiTable } from '@/components/OodiTable'
|
||||
import { OodiTableExcelExport } from '@/components/OodiTable/excelExport'
|
||||
import { IncludeSubstitutionsToggle } from '@/components/PopulationComponents/Students/IncludeSubstitutionsToggle'
|
||||
import { useGetColumnDefinitions } from '@/components/PopulationComponents/Students/Table/CoursesTab/columnDefinitions'
|
||||
import {
|
||||
getGroupIdToExportKey,
|
||||
renameKeys,
|
||||
} from '@/components/PopulationComponents/Students/Table/CoursesTab/excelExport'
|
||||
import { useToggle } from '@/hooks/toggle'
|
||||
import { ExtendedCurriculumDetails } from '@/hooks/useCurriculums'
|
||||
import { useCurriculum } from '@/hooks/useCurriculum'
|
||||
import { FilteredCourse } from '@/util/coursesOfPopulation'
|
||||
import { CreditTypeCode, Name, ProgrammeCourse } from '@oodikone/shared/types'
|
||||
import { FormattedStudent, StudentCourse } from '@oodikone/shared/types/studentData'
|
||||
import { FilterDegreeCoursesModal } from '@/components/PopulationComponents/PopulationCourses/FilterDegreeCoursesModal'
|
||||
import { HiddenCourseAlert } from '@/components/HiddenCourseAlert'
|
||||
|
||||
type CoursesTabContainerProps = {
|
||||
curriculum: ExtendedCurriculumDetails
|
||||
students: FormattedStudent[]
|
||||
courses: FilteredCourse[]
|
||||
idToGroupIdMap: Record<string, string>
|
||||
@@ -56,7 +60,7 @@ export type CourseTabModule = {
|
||||
const studentMapper = (
|
||||
student: FormattedStudent,
|
||||
includeSubstitutions: boolean,
|
||||
curriculumGroupIds: string[],
|
||||
curriculumGroupIds: Set<string>,
|
||||
substitutionsForGroupId: Record<string, string[][]>,
|
||||
idToGroupIdMap: Record<string, string>,
|
||||
groupIdToCode: Record<string, string>
|
||||
@@ -65,25 +69,20 @@ const studentMapper = (
|
||||
|
||||
// NB: there can be many attainments/enrollments for each course group
|
||||
// All passed courses that are included in curriculum
|
||||
const passedCourses = student.courses.filter(
|
||||
course => curriculumGroupIds.includes(idToGroupIdMap[course.course_id]) && course.passed
|
||||
)
|
||||
const passedCourses = student.courses.filter(course => curriculumGroupIds.has(course.courseGroupId) && course.passed)
|
||||
const enrollments = student.enrollments.filter(enrollment =>
|
||||
curriculumGroupIds.includes(idToGroupIdMap[enrollment.course_id])
|
||||
curriculumGroupIds.has(idToGroupIdMap[enrollment.course_id])
|
||||
)
|
||||
const hopsGroupIds = student.studyplans.flatMap(studyPlan =>
|
||||
studyPlan.included_courses
|
||||
.map(id => idToGroupIdMap[id] ?? id)
|
||||
.filter(groupId => curriculumGroupIds.includes(groupId))
|
||||
studyPlan.included_courses.map(id => idToGroupIdMap[id] ?? id).filter(groupId => curriculumGroupIds.has(groupId))
|
||||
)
|
||||
|
||||
// All passed courses including random AY-codes etc, translated to their groupId
|
||||
const allPassedGroupIds = student.courses
|
||||
.filter(course => course.passed)
|
||||
.map(course => idToGroupIdMap[course.course_id])
|
||||
.filter(Boolean)
|
||||
// All passed courses including random AY-codes etc
|
||||
const allPassedGroupIds = new Set(
|
||||
student.courses.filter(({ passed }) => passed).map(({ courseGroupId }) => courseGroupId)
|
||||
)
|
||||
|
||||
const substitutionsToCurriculumGroups = curriculumGroupIds.reduce<Record<string, string[][]>>((acc, groupId) => {
|
||||
const substitutionsToCurriculumGroups = [...curriculumGroupIds].reduce<Record<string, string[][]>>((acc, groupId) => {
|
||||
const substitutionsToCurriculumGroup = substitutionsForGroupId[groupId]
|
||||
if (substitutionsToCurriculumGroup) {
|
||||
acc[groupId] = substitutionsToCurriculumGroup
|
||||
@@ -96,19 +95,18 @@ const studentMapper = (
|
||||
Record<string, SubstitutedByEntry[]>
|
||||
>((acc, groupId) => {
|
||||
const passedSubstitutionGroups = substitutionsToCurriculumGroups[groupId].filter(substGroup =>
|
||||
substGroup.every(sgGroupId => allPassedGroupIds.includes(sgGroupId))
|
||||
substGroup.every(sgGroupId => allPassedGroupIds.has(sgGroupId))
|
||||
)
|
||||
// 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 groupIds are student's completed courses => they exist under student.courses
|
||||
const passedSubstitutionGroupCourses = passedSubstitutionGroups
|
||||
.map(sg =>
|
||||
sg.map((sgGroupId): SubstitutedByEntry => {
|
||||
const course = student.courses.find(c => idToGroupIdMap[c.course_id] === sgGroupId)!
|
||||
const course = student.courses.find(({ courseGroupId }) => courseGroupId === sgGroupId)!
|
||||
return { code: groupIdToCode[sgGroupId] ?? sgGroupId, grade: course.grade, date: course.date }
|
||||
})
|
||||
)
|
||||
.toSorted((a, b) => b.length - a.length)
|
||||
.at(0)! // We know that this will exist
|
||||
.toSorted((a, b) => b.length - a.length)[0] // We know that this will exist
|
||||
if (passedSubstitutionGroups.length) {
|
||||
acc[groupId] = passedSubstitutionGroupCourses
|
||||
}
|
||||
@@ -125,8 +123,8 @@ const studentMapper = (
|
||||
// TODO: Same as above
|
||||
const enrolledSubstitutionGroupEnrollments = enrolledSubstitutionGroups
|
||||
.map(sg => sg.map(sgGroupId => student.enrollments.find(e => idToGroupIdMap[e.course_id] === sgGroupId)!))
|
||||
.toSorted((a, b) => b.length - a.length)
|
||||
.at(0)! // We know that this will exist
|
||||
.toSorted((a, b) => b.length - a.length)[0]
|
||||
// We know that this will exist
|
||||
if (enrolledSubstitutionGroupEnrollments?.length) {
|
||||
acc[groupId] = {
|
||||
codes: enrolledSubstitutionGroupEnrollments.map(e => ({
|
||||
@@ -164,7 +162,7 @@ const studentMapper = (
|
||||
|
||||
const mapCourses = (coursesToAdd: typeof passedCourses) => {
|
||||
for (const course of coursesToAdd) {
|
||||
const groupId = idToGroupIdMap[course.course_id]
|
||||
const groupId = course.courseGroupId
|
||||
|
||||
if (!courseMap[groupId] || compareCourseGrades(courseMap[groupId], course)) {
|
||||
courseMap[groupId] = {
|
||||
@@ -263,13 +261,8 @@ const gradeOrdering = ['0', 'Hyl.', 'TT', 'HT', '1', '2', '3', '4', '5', 'Hyv.']
|
||||
const compareCourseGrades = (previous: StudentCourse, current: StudentCourse) =>
|
||||
gradeOrdering.indexOf(previous.grade) <= gradeOrdering.indexOf(current.grade)
|
||||
|
||||
export const CoursesTabContainer = ({
|
||||
curriculum,
|
||||
students,
|
||||
courses,
|
||||
idToGroupIdMap,
|
||||
programme,
|
||||
}: CoursesTabContainerProps) => {
|
||||
export const CoursesTabContainer = ({ students, courses, idToGroupIdMap, programme }: CoursesTabContainerProps) => {
|
||||
const { curriculum } = useCurriculum()
|
||||
const { visible: namesVisible } = useStudentNameVisibility()
|
||||
const [includeSubstitutions, toggleIncludeSubstitutions] = useToggle(true)
|
||||
|
||||
@@ -279,25 +272,21 @@ export const CoursesTabContainer = ({
|
||||
)
|
||||
|
||||
const curriculumCourses = useMemo(
|
||||
() => [...curriculum.defaultProgrammeCourses, ...curriculum.secondProgrammeCourses].filter(nonVisible),
|
||||
() =>
|
||||
curriculum
|
||||
? [...curriculum.defaultProgrammeCourses, ...curriculum.secondProgrammeCourses].filter(nonVisible)
|
||||
: [],
|
||||
[curriculum]
|
||||
)
|
||||
|
||||
// Curriculum courses only carry a code, so resolve each to the groupId of the matching course in this
|
||||
// population (if the course has no data in the population there is nothing to resolve, so fall back to
|
||||
// the code itself - it is only ever used as a unique key at that point, never for matching student data).
|
||||
const codeToGroupId: Record<string, string> = useMemo(
|
||||
() => Object.fromEntries(courses.map(({ course }) => [course.code, course.groupId])),
|
||||
[courses]
|
||||
)
|
||||
const groupIdToCode: Record<string, string> = useMemo(
|
||||
() => Object.fromEntries(courses.map(({ course }) => [course.groupId, course.code])),
|
||||
[courses]
|
||||
)
|
||||
|
||||
const curriculumGroupIds = useMemo(
|
||||
() => curriculumCourses.map(course => codeToGroupId[course.code] ?? course.code),
|
||||
[curriculumCourses, codeToGroupId]
|
||||
const curriculumCourseGroupIds = useMemo(
|
||||
() => new Set(curriculumCourses.map(course => course.group_id)),
|
||||
[curriculumCourses]
|
||||
)
|
||||
|
||||
// All substitutionGroups (already groupIds) for a given curriculum course's groupId
|
||||
@@ -321,7 +310,7 @@ export const CoursesTabContainer = ({
|
||||
if (!acc.has(parent)) {
|
||||
acc.set(parent, { name: course.parent_name, courses: [] })
|
||||
}
|
||||
const groupId = codeToGroupId[course.code] ?? course.code
|
||||
const groupId = course.group_id
|
||||
const studyModule = acc.get(parent)!
|
||||
// Prevent course from appearing under the same module twice
|
||||
if (!studyModule.courses.some(existing => existing.groupId === groupId)) {
|
||||
@@ -338,7 +327,7 @@ export const CoursesTabContainer = ({
|
||||
|
||||
// Returns fully sorted map
|
||||
return new Map(Array.from(unsorted.entries()).sort())
|
||||
}, [curriculumCourses, codeToGroupId])
|
||||
}, [curriculumCourses])
|
||||
|
||||
/**
|
||||
* Adds passed courses by the highest grade / most recent enrollments / hops status of courses
|
||||
@@ -351,13 +340,13 @@ export const CoursesTabContainer = ({
|
||||
studentMapper(
|
||||
student,
|
||||
includeSubstitutions,
|
||||
curriculumGroupIds,
|
||||
curriculumCourseGroupIds,
|
||||
substitutionsForGroupId,
|
||||
idToGroupIdMap,
|
||||
groupIdToCode
|
||||
)
|
||||
),
|
||||
[students, includeSubstitutions, curriculumGroupIds, substitutionsForGroupId, idToGroupIdMap, groupIdToCode]
|
||||
[students, includeSubstitutions, curriculumCourseGroupIds, substitutionsForGroupId, idToGroupIdMap, groupIdToCode]
|
||||
)
|
||||
|
||||
const columns = useGetColumnDefinitions(coursesByParentModule)
|
||||
@@ -376,16 +365,24 @@ export const CoursesTabContainer = ({
|
||||
},
|
||||
}
|
||||
|
||||
// Export courses by their code instead of the groupId
|
||||
const groupIdToExportKey = useMemo(() => getGroupIdToExportKey(coursesByParentModule), [coursesByParentModule])
|
||||
|
||||
const keysForExport: string[] = useMemo(() => {
|
||||
const squashGroups = column => {
|
||||
if (column.columns) return column.columns.flatMap(squashGroups)
|
||||
return [column.accessorKey ?? column.id?.split(';')?.[1]]
|
||||
}
|
||||
|
||||
const keys = columns.flatMap(squashGroups)
|
||||
const keys = columns.flatMap(squashGroups).map(key => groupIdToExportKey[key] ?? key)
|
||||
// Remove duplicates preserving order of initial occurences
|
||||
return keys.filter((key, idx) => keys.indexOf(key) === idx)
|
||||
}, [columns])
|
||||
}, [columns, groupIdToExportKey])
|
||||
|
||||
const exportData = useMemo(
|
||||
() => formattedStudents.map(student => renameKeys(student, groupIdToExportKey)),
|
||||
[formattedStudents, groupIdToExportKey]
|
||||
)
|
||||
|
||||
return (
|
||||
<OodiTable
|
||||
@@ -395,13 +392,14 @@ export const CoursesTabContainer = ({
|
||||
options={tableOptions}
|
||||
toolbarContent={
|
||||
<>
|
||||
<OodiTableExcelExport data={formattedStudents} exportColumnKeys={keysForExport} />
|
||||
<FilterDegreeCoursesModal degreeProgramme={programme} />
|
||||
<OodiTableExcelExport data={exportData} exportColumnKeys={keysForExport} />
|
||||
<FilterDegreeCoursesModal degreeProgramme={programme} initialTab="visibility" />
|
||||
<StudentNameVisibilityToggle />
|
||||
<IncludeSubstitutionsToggle
|
||||
includeSubstitutions={includeSubstitutions}
|
||||
toggleIncludeSubstitutions={toggleIncludeSubstitutions}
|
||||
/>
|
||||
<HiddenCourseAlert ooditable />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
+7
-1
@@ -14,6 +14,7 @@ import { OodiTableExcelExport } from '@/components/OodiTable/excelExport'
|
||||
import type { FormattedModules, ModuleTabStudent } from '@/components/PopulationComponents/Students/Table/ModulesTab'
|
||||
import { CheckIcon, CropSquareIcon } from '@/theme'
|
||||
import { FilterDegreeCoursesModal } from '@/components/PopulationComponents/PopulationCourses/FilterDegreeCoursesModal'
|
||||
import { HiddenCourseAlert } from '@/components/HiddenCourseAlert'
|
||||
|
||||
const getModuleIfExists = (student: ModuleTabStudent, moduleCode: string) =>
|
||||
student.studyModulesInHOPS.find(studyModule => studyModule.code === moduleCode) ?? null
|
||||
@@ -192,8 +193,13 @@ export const ModulesTab = ({
|
||||
toolbarContent={
|
||||
<>
|
||||
<OodiTableExcelExport data={exportData} exportColumnKeys={accessorKeys} />
|
||||
<FilterDegreeCoursesModal degreeProgramme={programme} text="Manage module visibility" />
|
||||
<FilterDegreeCoursesModal
|
||||
degreeProgramme={programme}
|
||||
initialTab="visibility"
|
||||
text="Manage module visibility"
|
||||
/>
|
||||
<StudentNameVisibilityToggle />
|
||||
<HiddenCourseAlert ooditable modules />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
+2
-3
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { ModulesTab } from '@/components/PopulationComponents/Students/Table/ModulesTab/ModulesTable'
|
||||
import { ExtendedCurriculumDetails } from '@/hooks/useCurriculums'
|
||||
import { useCurriculum } from '@/hooks/useCurriculum'
|
||||
import { FilteredCourse } from '@/util/coursesOfPopulation'
|
||||
import { createLocaleComparator } from '@/util/comparator'
|
||||
import { formatISODate } from '@/util/timeAndDate'
|
||||
@@ -86,18 +86,17 @@ const getDegreeProgrammeCodes = (curriculumModules): string[] => {
|
||||
}
|
||||
|
||||
export const ModulesTabContainer = ({
|
||||
curriculum,
|
||||
students,
|
||||
courses,
|
||||
idToGroupIdMap,
|
||||
programme,
|
||||
}: {
|
||||
curriculum: ExtendedCurriculumDetails | null | undefined
|
||||
students: Student[]
|
||||
courses: FilteredCourse[]
|
||||
idToGroupIdMap: Record<string, string>
|
||||
programme: string
|
||||
}) => {
|
||||
const { curriculum } = useCurriculum()
|
||||
const curriculumModules = useMemo(
|
||||
() => (curriculum ? [...curriculum.defaultProgrammeModules, ...curriculum.secondProgrammeModules] : []),
|
||||
[curriculum]
|
||||
|
||||
+254
-459
@@ -2,10 +2,7 @@ import Box from '@mui/material/Box'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { createColumnHelper } from '@tanstack/react-table'
|
||||
import dayjs, { extend as dayjsExtend } from 'dayjs'
|
||||
import isBetween from 'dayjs/plugin/isBetween'
|
||||
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
|
||||
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
|
||||
import dayjs from 'dayjs'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { Link } from '@/components/common/Link'
|
||||
@@ -15,116 +12,44 @@ import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { OodiTable } from '@/components/OodiTable'
|
||||
import { CopyStudentNumbersHeader } from '@/components/OodiTable/common/CopyStudentNumbersHeader'
|
||||
import { OodiTableExcelExport } from '@/components/OodiTable/excelExport'
|
||||
import { FilterDegreeCoursesModal } from '@/components/PopulationComponents/PopulationCourses/FilterDegreeCoursesModal'
|
||||
import { TableInfo } from '@/components/PopulationComponents/Students/Table/ProgressTab/info'
|
||||
import { DateFormat } from '@/constants/date'
|
||||
import { ExtendedCurriculumDetails } from '@/hooks/useCurriculums'
|
||||
import {
|
||||
getCellText,
|
||||
getCourseGroups,
|
||||
getCriteriaHeaders,
|
||||
hasCreditTransfer,
|
||||
hasEnrolled,
|
||||
hasFailed,
|
||||
hasPassedDuringAcademicYear,
|
||||
hasPassedOutsideAcademicYear,
|
||||
IdToGroupId,
|
||||
Label,
|
||||
} from '@/components/PopulationComponents/Students/Table/ProgressTab/util'
|
||||
import { useCurriculum } from '@/hooks/useCurriculum'
|
||||
import { useSemesters } from '@/hooks/useSemesters'
|
||||
import { useGetProgressCriteriaQuery } from '@/redux/progressCriteria'
|
||||
import { CheckIcon, CloseIcon, EditIcon, RemoveIcon, SwapHorizIcon } from '@/theme'
|
||||
import { isMedicalProgramme } from '@/util/studyProgramme'
|
||||
import { formatDate } from '@/util/timeAndDate'
|
||||
import {
|
||||
CreditTypeCode,
|
||||
EnrollmentState,
|
||||
FormattedStudent,
|
||||
Name,
|
||||
ProgressCriteria,
|
||||
SemesterEnrollment,
|
||||
} from '@oodikone/shared/types'
|
||||
import { StudentCourse } from '@oodikone/shared/types/studentData'
|
||||
import { keyBy, range } from '@oodikone/shared/util'
|
||||
import { FormattedStudent, ProgrammeCourse, ProgressCriteria, SemesterEnrollment } from '@oodikone/shared/types'
|
||||
import { keyBy } from '@oodikone/shared/util'
|
||||
import '@/components/PopulationComponents/Students/Table/ProgressTab/index.css'
|
||||
import { FilterDegreeCoursesModal } from '@/components/PopulationComponents/PopulationCourses/FilterDegreeCoursesModal'
|
||||
|
||||
dayjsExtend(isBetween)
|
||||
dayjsExtend(isSameOrBefore)
|
||||
dayjsExtend(isSameOrAfter)
|
||||
|
||||
const columnHelper = createColumnHelper<FormattedStudent>()
|
||||
|
||||
type IdToGroupId = Record<string, string>
|
||||
|
||||
/** Return course + all of it's substitution groups (courses are identified by their course group id) */
|
||||
const getCourseGroups = (
|
||||
courseGroupId: string,
|
||||
criteria: ProgressCriteria | undefined,
|
||||
student: FormattedStudent,
|
||||
idToGroupId: IdToGroupId
|
||||
): StudentCourse[][] => {
|
||||
const studentGroupIds = student.courses.map(({ course_id }) => idToGroupId[course_id])
|
||||
|
||||
// Return groups that have all courses in student.courses
|
||||
const courseGroups: StudentCourse[][] = []
|
||||
for (const group of [[courseGroupId]].concat(criteria?.allCourseGroups[courseGroupId] ?? [])) {
|
||||
if (group.every(groupId => studentGroupIds.includes(groupId))) {
|
||||
// We know all of the group's courses exist, we just checked that
|
||||
courseGroups.push(
|
||||
...group.map(groupId =>
|
||||
student.courses
|
||||
.filter(course => idToGroupId[course.course_id] === groupId)
|
||||
.toSorted((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return courseGroups
|
||||
const helpTexts = {
|
||||
0: 'No information',
|
||||
1: 'Active',
|
||||
2: 'Absent',
|
||||
3: 'Passive',
|
||||
}
|
||||
|
||||
/**
|
||||
Filter out failed courses that don't "break" a given group eg. remove the only instance of a course code
|
||||
|
||||
NOTE: Does not automatically remove all failed courses!
|
||||
*/
|
||||
const filterFailed = (group: StudentCourse[]) => (course: StudentCourse) =>
|
||||
course.passed || !group.find(otherCourse => course.course_code === otherCourse.course_code && otherCourse.passed)
|
||||
|
||||
const hasCreditTransfer = (courseGroups: StudentCourse[][]) =>
|
||||
courseGroups.some(group => {
|
||||
const passedCredits = group.filter(filterFailed(group))
|
||||
return passedCredits.length === 0
|
||||
? false
|
||||
: passedCredits.some(course => course.credittypecode === CreditTypeCode.APPROVED)
|
||||
})
|
||||
|
||||
/** Check if any courseGroup has
|
||||
1. all courses passed
|
||||
2. *LATEST* attainment gotten during the wanted academic year
|
||||
3. All attainments gotten before the wanted academic year's end
|
||||
*/
|
||||
const hasPassedDuringAcademicYear = (courseGroups: StudentCourse[][], start: dayjs.Dayjs, end: dayjs.Dayjs) => {
|
||||
return courseGroups.some(group => {
|
||||
const passedCredits = group.filter(filterFailed(group))
|
||||
return passedCredits.length === 0
|
||||
? false
|
||||
: passedCredits.some(credit => dayjs(credit.date).isBetween(dayjs(start), dayjs(end))) &&
|
||||
passedCredits.every(credit => dayjs(credit.date).isBefore(dayjs(end)))
|
||||
})
|
||||
const enrollmentClassNames = {
|
||||
0: 'label-none',
|
||||
1: 'label-present',
|
||||
2: 'label-absent',
|
||||
3: 'label-passive',
|
||||
}
|
||||
|
||||
/** Check if any group has only passed courses */
|
||||
const hasPassedOutsideAcademicYear = (courseGroups: StudentCourse[][]) =>
|
||||
courseGroups.some(group => {
|
||||
const passedCredits = group.filter(filterFailed(group))
|
||||
return passedCredits.length === 0 ? false : passedCredits.every(credit => credit.passed)
|
||||
})
|
||||
|
||||
/**
|
||||
Check if there is a failed course that was not removed by the filterFailed eg. there is
|
||||
a failed course that has no passed attainment
|
||||
*/
|
||||
const hasFailed = (courseGroups: StudentCourse[][]) =>
|
||||
courseGroups.some(group => {
|
||||
const passedCredits = group.filter(filterFailed(group))
|
||||
return passedCredits.length === 0 ? false : passedCredits.some(course => !course.passed)
|
||||
})
|
||||
|
||||
/** Return enrollments only for main courses */
|
||||
const hasEnrolled = (student: FormattedStudent, courseGroupId: string, idToGroupId: IdToGroupId) =>
|
||||
student.enrollments?.some(enrollment => idToGroupId[enrollment.course_id] === courseGroupId)
|
||||
|
||||
const getEnrollment = (student: FormattedStudent, courseGroupId: string, idToGroupId: IdToGroupId) =>
|
||||
student.enrollments.filter(enrollment => idToGroupId[enrollment.course_id] === courseGroupId)
|
||||
|
||||
const getRowContent = (
|
||||
student: FormattedStudent,
|
||||
label: Label,
|
||||
@@ -134,7 +59,7 @@ const getRowContent = (
|
||||
criteria: ProgressCriteria | undefined,
|
||||
idToGroupId: IdToGroupId
|
||||
) => {
|
||||
if (label.code.includes('Credits')) {
|
||||
if (label.code === 'Credits') {
|
||||
if (student.criteriaProgress[year]?.credits) {
|
||||
return <CheckIcon color="success" />
|
||||
}
|
||||
@@ -167,73 +92,50 @@ const getRowContent = (
|
||||
return null
|
||||
}
|
||||
|
||||
const getExcelText = (
|
||||
label: Label,
|
||||
criteria: ProgressCriteria | undefined,
|
||||
student: FormattedStudent,
|
||||
year: string,
|
||||
idToGroupId: IdToGroupId
|
||||
const getSemesterEnrollmentStatus = (
|
||||
semesterEnrollments: SemesterEnrollment[] | null | undefined,
|
||||
semesters: number[]
|
||||
) => {
|
||||
if (label.code.includes('Credits')) {
|
||||
return student.criteriaProgress[year]?.credits ? 'Passed' : ''
|
||||
}
|
||||
if (!label.groupId) return ''
|
||||
|
||||
const courseGroups = getCourseGroups(label.groupId, criteria, student, idToGroupId)
|
||||
|
||||
if (hasPassedOutsideAcademicYear(courseGroups)) {
|
||||
const latestCompletedGroup = courseGroups.find(group => group.every(course => course.passed))
|
||||
return `Passed ${latestCompletedGroup?.length === 1 ? formatDate(latestCompletedGroup[0]?.date, DateFormat.ISO_DATE) : '(substituted)'}`
|
||||
}
|
||||
|
||||
if (hasFailed(courseGroups)) {
|
||||
const latestFailedCourse = courseGroups
|
||||
.find(group => group.some(course => !course.passed))
|
||||
?.find(course => !course.passed)
|
||||
return `Failed ${formatDate(latestFailedCourse ? latestFailedCourse.date : '(substituted)', DateFormat.ISO_DATE)}`
|
||||
}
|
||||
|
||||
if (hasEnrolled(student, label.groupId, idToGroupId)) {
|
||||
const enrollment = getEnrollment(student, label.groupId, idToGroupId)
|
||||
return `Enrollment ${formatDate(enrollment[0].enrollment_date_time, DateFormat.ISO_DATE)}`
|
||||
}
|
||||
|
||||
return ''
|
||||
const fall = semesterEnrollments?.find(semester => semester.semester === Math.min(...semesters))?.type ?? 0
|
||||
const spring = semesterEnrollments?.find(semester => semester.semester === Math.max(...semesters))?.type ?? 0
|
||||
return { fall, spring }
|
||||
}
|
||||
|
||||
const getCriteriaHeaders = (months: number, programme: string) => {
|
||||
const criteriaHeaders = [
|
||||
{ title: months < 12 ? 'Academic year 1 (in progress)' : 'Academic year 1', year: 'year1', label: 'yearOne' },
|
||||
{ title: months < 24 ? 'Academic year 2 (in progress)' : 'Academic year 2', year: 'year2', label: 'yearTwo' },
|
||||
{ title: months < 36 ? 'Academic year 3 (in progress)' : 'Academic year 3', year: 'year3', label: 'yearThree' },
|
||||
]
|
||||
if (isMedicalProgramme(programme)) {
|
||||
criteriaHeaders.push(
|
||||
{ title: months < 48 ? 'Academic year 4 (in progress)' : 'Academic year 4', year: 'year4', label: 'yearFour' },
|
||||
{ title: months < 60 ? 'Academic year 5 (in progress)' : 'Academic year 5', year: 'year5', label: 'yearFive' },
|
||||
{ title: months < 72 ? 'Academic year 6 (in progress)' : 'Academic year 6', year: 'year6', label: 'yearSix' }
|
||||
)
|
||||
}
|
||||
return criteriaHeaders
|
||||
const getSemesterEnrollmentContent = (
|
||||
semesterEnrollments: SemesterEnrollment[] | null | undefined,
|
||||
semesters: number[]
|
||||
) => {
|
||||
const { fall, spring } = getSemesterEnrollmentStatus(semesterEnrollments, semesters)
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '0.2rem' }}>
|
||||
<div className={`enrollment-label ${enrollmentClassNames[fall]}`} title={`Fall: ${helpTexts[fall]}`} />
|
||||
<div className={`enrollment-label ${enrollmentClassNames[spring]}`} title={`Spring: ${helpTexts[spring]}`} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type Label = {
|
||||
/** Course code for course labels, otherwise a special value ('Credits', 'Criteria', 'Enrollment') */
|
||||
code: string
|
||||
/** Set for course labels */
|
||||
groupId?: string
|
||||
name: Name | null
|
||||
}
|
||||
/** Start and end of the student's nth (0-indexed) academic year, counting from the population's start */
|
||||
const getAcademicYear = (months: number, yearIndex: number) => ({
|
||||
start: dayjs()
|
||||
.subtract(months - 1, 'months')
|
||||
.startOf('month')
|
||||
.add(yearIndex, 'years'),
|
||||
end: dayjs()
|
||||
.subtract(months - 12, 'months')
|
||||
.endOf('month')
|
||||
.add(yearIndex, 'years'),
|
||||
})
|
||||
|
||||
/** Column title in the Excel export, unique over all years */
|
||||
const getExcelKey = (label: Label, yearIndex: number) => `Year ${yearIndex + 1}: ${label.name}`
|
||||
|
||||
export const ProgressTable = ({
|
||||
curriculum,
|
||||
idToGroupIdMap,
|
||||
students,
|
||||
months,
|
||||
programme,
|
||||
studyGuidanceGroupProgramme,
|
||||
}: {
|
||||
curriculum?: ExtendedCurriculumDetails | null
|
||||
idToGroupIdMap: IdToGroupId
|
||||
students: FormattedStudent[]
|
||||
months: number
|
||||
@@ -242,146 +144,163 @@ export const ProgressTable = ({
|
||||
}) => {
|
||||
const { visible: namesVisible } = useStudentNameVisibility()
|
||||
const { getTextIn } = useLanguage()
|
||||
const { curriculum } = useCurriculum()
|
||||
const { data: criteria } = useGetProgressCriteriaQuery({ programmeCode: programme }, { skip: !programme })
|
||||
const { semesters: allSemesters } = useSemesters()
|
||||
const isStudyGuidanceGroupProgramme = studyGuidanceGroupProgramme !== ''
|
||||
const creditMonths = [12, 24, 36, 48, 60, 72]
|
||||
const defaultCourses = keyBy(curriculum?.defaultProgrammeCourses, 'group_id')
|
||||
const coursesSecondProgramme = keyBy(curriculum?.secondProgrammeCourses, 'group_id')
|
||||
|
||||
const getCourse = (courseGroupId: string) => defaultCourses[courseGroupId] ?? coursesSecondProgramme[courseGroupId]
|
||||
const labelCriteria = useMemo(() => {
|
||||
const courses: Record<string, ProgrammeCourse | undefined> = {
|
||||
...keyBy(curriculum?.secondProgrammeCourses, 'group_id'),
|
||||
...keyBy(curriculum?.defaultProgrammeCourses, 'group_id'),
|
||||
}
|
||||
|
||||
const labelCriteria = Object.keys(criteria?.courses ?? {}).reduce<Record<string, Label[]>>((acc, year, index) => {
|
||||
acc[year] = [
|
||||
{
|
||||
code: 'Credits',
|
||||
name: {
|
||||
fi: `${creditMonths[index]} mos.: ${criteria?.credits[year]}`,
|
||||
en: `${creditMonths[index]} mos.: ${criteria?.credits[year]}`,
|
||||
sv: `${creditMonths[index]} mos.: ${criteria?.credits[year]}`,
|
||||
return Object.keys(criteria?.courses ?? {}).reduce<Record<string, Label[]>>((acc, year, index) => {
|
||||
acc[year] = [
|
||||
{
|
||||
code: 'Credits',
|
||||
name: `At least ${criteria?.credits[year] ?? 0} credits during first ${(index + 1) * 12} months`,
|
||||
},
|
||||
},
|
||||
...(criteria?.courses[year] ?? [])
|
||||
.map((courseGroupId: string): Label => {
|
||||
const course = getCourse(courseGroupId)
|
||||
return { code: course?.code ?? courseGroupId, groupId: courseGroupId, name: course?.name ?? null }
|
||||
})
|
||||
.sort((a, b) => a.code.localeCompare(b.code)),
|
||||
{
|
||||
code: 'Criteria',
|
||||
name: {
|
||||
fi: `Year ${index + 1}: Fullfilled`,
|
||||
en: `Year ${index + 1}: Fullfilled`,
|
||||
sv: `Year ${index + 1}: Fullfilled`,
|
||||
...(criteria?.courses[year] ?? [])
|
||||
.map((courseGroupId: string): Label => {
|
||||
const course = courses[courseGroupId]
|
||||
const code = course?.code ?? courseGroupId
|
||||
return {
|
||||
code,
|
||||
groupId: courseGroupId,
|
||||
name: `${code} ${course ? getTextIn(course.name) : 'Unknown course'}`,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.code.localeCompare(b.code)),
|
||||
{
|
||||
code: 'Criteria',
|
||||
name: `Year ${index + 1} criteria fulfilled`,
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'Enrollment',
|
||||
name: { en: `Year ${index + 1}` },
|
||||
},
|
||||
]
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const criteriaHeaders = getCriteriaHeaders(months, programme)
|
||||
|
||||
const studentNumberToSemesterEnrollmentsMap = students.reduce<Record<string, SemesterEnrollment[] | null>>(
|
||||
(acc, student) => {
|
||||
const correctStudyRight = student.studyRights.find(studyRight =>
|
||||
studyRight.studyRightElements.some(element => element.code === programme)
|
||||
)
|
||||
if (correctStudyRight) {
|
||||
acc[student.studentNumber] = correctStudyRight.semesterEnrollments
|
||||
}
|
||||
{
|
||||
code: 'Enrollment',
|
||||
name: 'Semester enrollments',
|
||||
},
|
||||
]
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
}, {})
|
||||
}, [criteria, curriculum, getTextIn])
|
||||
|
||||
const criteriaHeaders = useMemo(
|
||||
() => getCriteriaHeaders(months, programme).filter((_, yearIndex) => yearIndex === 0 || yearIndex * 12 < months),
|
||||
[months, programme]
|
||||
)
|
||||
|
||||
const helpTexts = {
|
||||
0: 'No information',
|
||||
1: 'Active',
|
||||
2: 'Absent',
|
||||
3: 'Passive',
|
||||
}
|
||||
const semesterEnrollmentsByStudent = useMemo(
|
||||
() =>
|
||||
students.reduce<Record<string, SemesterEnrollment[] | null>>((acc, student) => {
|
||||
const correctStudyRight = student.studyRights.find(studyRight =>
|
||||
studyRight.studyRightElements.some(element => element.code === programme)
|
||||
)
|
||||
if (correctStudyRight) {
|
||||
acc[student.studentNumber] = correctStudyRight.semesterEnrollments
|
||||
}
|
||||
return acc
|
||||
}, {}),
|
||||
[students, programme]
|
||||
)
|
||||
|
||||
const getSemesterEnrollmentStatus = (student: FormattedStudent, semesters: number[]) => {
|
||||
const fall =
|
||||
studentNumberToSemesterEnrollmentsMap[student.studentNumber]?.find(
|
||||
semester => semester.semester === Math.min(...semesters)
|
||||
)?.type ?? 0
|
||||
/** Academic year date range and its semester codes for each shown year */
|
||||
const academicYears = useMemo(
|
||||
() =>
|
||||
criteriaHeaders.map((_, yearIndex) => {
|
||||
const { start, end } = getAcademicYear(months, yearIndex)
|
||||
const semesters = Object.values(allSemesters)
|
||||
.filter(
|
||||
semester => dayjs(semester.startdate).isSameOrAfter(start) && dayjs(semester.enddate).isSameOrBefore(end)
|
||||
)
|
||||
.map(semester => semester.semestercode)
|
||||
return { start, end, semesters }
|
||||
}),
|
||||
[criteriaHeaders, months, allSemesters]
|
||||
)
|
||||
|
||||
const spring =
|
||||
studentNumberToSemesterEnrollmentsMap[student.studentNumber]?.find(
|
||||
semester => semester.semester === Math.max(...semesters)
|
||||
)?.type ?? 0
|
||||
const excelData = useMemo(
|
||||
() =>
|
||||
students.map(student => ({
|
||||
'Student number': student.studentNumber,
|
||||
'Last name': student.lastname,
|
||||
'First names': student.firstnames,
|
||||
...Object.fromEntries(
|
||||
criteriaHeaders.flatMap(({ year, label: yearLabel }, yearIndex) =>
|
||||
(labelCriteria[yearLabel] ?? []).flatMap(label => {
|
||||
const key = getExcelKey(label, yearIndex)
|
||||
switch (label.code) {
|
||||
case 'Criteria':
|
||||
return [[key, student.criteriaProgress[year]?.totalSatisfied ?? 0]]
|
||||
|
||||
return { fall, spring }
|
||||
}
|
||||
case 'Enrollment': {
|
||||
const { fall, spring } = getSemesterEnrollmentStatus(
|
||||
semesterEnrollmentsByStudent[student.studentNumber],
|
||||
academicYears[yearIndex].semesters
|
||||
)
|
||||
return [
|
||||
[`${key} - FALL`, helpTexts[fall]],
|
||||
[`${key} - SPRING`, helpTexts[spring]],
|
||||
]
|
||||
}
|
||||
|
||||
const getSemesterEnrollmentVal = (student: FormattedStudent, semesters: number[]): [string, string] => {
|
||||
const { fall, spring } = getSemesterEnrollmentStatus(student, semesters)
|
||||
return [helpTexts[fall], helpTexts[spring]]
|
||||
}
|
||||
default:
|
||||
return [[key, getCellText(label, criteria, student, year, idToGroupIdMap)]]
|
||||
}
|
||||
})
|
||||
)
|
||||
),
|
||||
})),
|
||||
[students, criteriaHeaders, labelCriteria, semesterEnrollmentsByStudent, academicYears, criteria, idToGroupIdMap]
|
||||
)
|
||||
|
||||
const getSemesterEnrollmentContent = (student: FormattedStudent, semesters: number[]) => {
|
||||
const enrollmentTypes = {
|
||||
0: { className: 'label-none' },
|
||||
1: { className: 'label-present' },
|
||||
2: { className: 'label-absent' },
|
||||
3: { className: 'label-passive' },
|
||||
const accessorKeys = useMemo(
|
||||
() => [
|
||||
'Student number',
|
||||
'Last name',
|
||||
'First names',
|
||||
...criteriaHeaders.flatMap(({ label: yearLabel }, yearIndex) =>
|
||||
(labelCriteria[yearLabel] ?? []).flatMap(label => {
|
||||
const key = getExcelKey(label, yearIndex)
|
||||
return label.code === 'Enrollment' ? [`${key} - FALL`, `${key} - SPRING`] : [key]
|
||||
})
|
||||
),
|
||||
],
|
||||
[criteriaHeaders, labelCriteria]
|
||||
)
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const getSortingValue = (student: FormattedStudent, label: Label, year: string) => {
|
||||
// Values:
|
||||
// -1 Course completed
|
||||
// 0 Failed
|
||||
// 1 Enrolled
|
||||
// 2 None of above
|
||||
if (!label.groupId) return 2
|
||||
if (student.criteriaProgress[year]?.coursesSatisfied?.[label.groupId]) return -1
|
||||
if (student.courses.find(course => idToGroupIdMap[course.course_id] === label.groupId)?.passed === false) return 0
|
||||
if (hasEnrolled(student, label.groupId, idToGroupIdMap)) return 1
|
||||
return 2
|
||||
}
|
||||
const fall =
|
||||
studentNumberToSemesterEnrollmentsMap[student.studentNumber]?.find(
|
||||
semester => semester.semester === Math.min(...semesters)
|
||||
)?.type ?? 0
|
||||
const spring =
|
||||
studentNumberToSemesterEnrollmentsMap[student.studentNumber]?.find(
|
||||
semester => semester.semester === Math.max(...semesters)
|
||||
)?.type ?? 0
|
||||
|
||||
const fallText = `Fall: ${helpTexts[fall]}`
|
||||
const springText = `Spring: ${helpTexts[spring]}`
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '0.2rem' }}>
|
||||
<div className={`enrollment-label ${enrollmentTypes[fall]?.className}`} title={fallText} />
|
||||
<div className={`enrollment-label ${enrollmentTypes[spring]?.className}`} title={springText} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const getSortingValue = (student: FormattedStudent, label: Label, year: string) => {
|
||||
// Values:
|
||||
// -1 Course completed
|
||||
// 0 Failed
|
||||
// 1 Enrolled
|
||||
// 2 None of aove
|
||||
if (!label.groupId) return 2
|
||||
const criteriaCourses = student.criteriaProgress[year]?.coursesSatisfied
|
||||
const course = criteriaCourses?.[label.groupId]
|
||||
|
||||
if (course) return -1
|
||||
if (student.courses.find(course => idToGroupIdMap[course.course_id] === label.groupId)?.passed === false) return 0
|
||||
if (!course && hasEnrolled(student, label.groupId, idToGroupIdMap)) return 1
|
||||
return 2
|
||||
}
|
||||
|
||||
const createContent = (labels: Label[], year: string, start: dayjs.Dayjs, end: dayjs.Dayjs, semesters: number[]) => {
|
||||
return (
|
||||
labels?.map(label =>
|
||||
const createContent = (labels: Label[], year: string, yearIndex: number) => {
|
||||
const { start, end, semesters } = academicYears[yearIndex]
|
||||
return labels.map(label =>
|
||||
columnHelper.accessor(() => undefined, {
|
||||
id: `${year}-${label.groupId ?? label.code}-${label.name?.fi ?? ''}`,
|
||||
header: getTextIn(label.name) ?? label.code,
|
||||
id: `${year}-${label.groupId ?? label.code}`,
|
||||
header: label.name,
|
||||
cell: ({ row: { original: student } }) => {
|
||||
const title = label.code.includes('Criteria')
|
||||
? `${student.criteriaProgress[year]?.totalSatisfied ?? 0} criteria fullfilled`
|
||||
: getExcelText(label, criteria, student, year, idToGroupIdMap)
|
||||
const title =
|
||||
label.code === 'Criteria'
|
||||
? `${student.criteriaProgress[year]?.totalSatisfied ?? 0} criteria fulfilled`
|
||||
: getCellText(label, criteria, student, year, idToGroupIdMap)
|
||||
|
||||
const content = label.code.includes('Criteria')
|
||||
? (student.criteriaProgress[year]?.totalSatisfied ?? 0)
|
||||
: label.code.includes('Enrollment')
|
||||
? getSemesterEnrollmentContent(student, semesters)
|
||||
: getRowContent(student, label, year, start, end, criteria, idToGroupIdMap)
|
||||
const content =
|
||||
label.code === 'Criteria'
|
||||
? (student.criteriaProgress[year]?.totalSatisfied ?? 0)
|
||||
: label.code === 'Enrollment'
|
||||
? getSemesterEnrollmentContent(semesterEnrollmentsByStudent[student.studentNumber], semesters)
|
||||
: getRowContent(student, label, year, start, end, criteria, idToGroupIdMap)
|
||||
|
||||
return (
|
||||
<Tooltip title={title}>
|
||||
@@ -397,18 +316,26 @@ export const ProgressTable = ({
|
||||
case 'Credits':
|
||||
// NOTE: Convert boolean to int: (true -> 1, false -> 0) with + operator
|
||||
// Also notice rowB rowA order
|
||||
return +rowB.original.criteriaProgress[year].credits - +rowA.original.criteriaProgress[year].credits
|
||||
return (
|
||||
+!!rowB.original.criteriaProgress[year]?.credits - +!!rowA.original.criteriaProgress[year]?.credits
|
||||
)
|
||||
|
||||
case 'Criteria':
|
||||
// NOTE: rowB rowA order
|
||||
return (
|
||||
rowB.original.criteriaProgress[year].totalSatisfied -
|
||||
rowA.original.criteriaProgress[year].totalSatisfied
|
||||
(rowB.original.criteriaProgress[year]?.totalSatisfied ?? 0) -
|
||||
(rowA.original.criteriaProgress[year]?.totalSatisfied ?? 0)
|
||||
)
|
||||
|
||||
case 'Enrollment': {
|
||||
const semestersA = getSemesterEnrollmentStatus(rowA.original, semesters)
|
||||
const semestersB = getSemesterEnrollmentStatus(rowB.original, semesters)
|
||||
const semestersA = getSemesterEnrollmentStatus(
|
||||
semesterEnrollmentsByStudent[rowA.original.studentNumber],
|
||||
semesters
|
||||
)
|
||||
const semestersB = getSemesterEnrollmentStatus(
|
||||
semesterEnrollmentsByStudent[rowB.original.studentNumber],
|
||||
semesters
|
||||
)
|
||||
return semestersA.fall + semestersA.spring - (semestersB.fall + semestersB.spring)
|
||||
}
|
||||
|
||||
@@ -417,135 +344,10 @@ export const ProgressTable = ({
|
||||
}
|
||||
},
|
||||
})
|
||||
) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
const generateYearColumns = (startYear: dayjs.Dayjs, endYear: dayjs.Dayjs, criteriaIndex: number) => {
|
||||
const semesters = Object.values(allSemesters)
|
||||
.filter(
|
||||
semester =>
|
||||
dayjs(semester.startdate).isSameOrAfter(startYear) && dayjs(semester.enddate).isSameOrBefore(endYear)
|
||||
)
|
||||
.map(semester => semester.semestercode)
|
||||
}
|
||||
|
||||
return columnHelper.group({
|
||||
id: criteriaHeaders[criteriaIndex].title,
|
||||
header: criteriaHeaders[criteriaIndex].title,
|
||||
columns: createContent(
|
||||
labelCriteria[criteriaHeaders[criteriaIndex].label],
|
||||
criteriaHeaders[criteriaIndex].year,
|
||||
startYear,
|
||||
endYear,
|
||||
semesters
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
const academicYearStart = dayjs()
|
||||
.subtract(months - 1, 'months')
|
||||
.startOf('month')
|
||||
const academicYearEnd = dayjs()
|
||||
.subtract(months - 12, 'months')
|
||||
.endOf('month')
|
||||
|
||||
// HACK: Have fun with this one.
|
||||
const excelData = useMemo(
|
||||
() =>
|
||||
students.map(student => ({
|
||||
'Student number': student.studentNumber,
|
||||
'Last name': student.lastname,
|
||||
'First names': student.firstnames,
|
||||
...Object.fromEntries(
|
||||
range(0, isMedicalProgramme(programme) ? 6 : 3)
|
||||
.filter(year => year * 12 < months)
|
||||
.flatMap(year => {
|
||||
const startYear = dayjs(academicYearStart).add(year, 'years')
|
||||
const endYear = dayjs(academicYearEnd).add(year, 'years')
|
||||
|
||||
const semesters = Object.values(allSemesters)
|
||||
.filter(
|
||||
semester =>
|
||||
dayjs(semester.startdate).isSameOrAfter(startYear) &&
|
||||
dayjs(semester.enddate).isSameOrBefore(endYear)
|
||||
)
|
||||
.map(semester => semester.semestercode)
|
||||
|
||||
return (
|
||||
labelCriteria[criteriaHeaders[year].label]?.flatMap(label => {
|
||||
let result: (string | number | null | undefined)[][] = []
|
||||
switch (label.code) {
|
||||
case 'Criteria': {
|
||||
result = [
|
||||
[getTextIn(label.name), student.criteriaProgress[`year${year + 1}`]?.totalSatisfied ?? 0],
|
||||
]
|
||||
break
|
||||
}
|
||||
|
||||
case 'Enrollment': {
|
||||
result = (() => {
|
||||
const [fall, spring] = getSemesterEnrollmentVal(student, semesters)
|
||||
return [
|
||||
[`${getTextIn(label.name)} - FALL`, fall],
|
||||
[`${getTextIn(label.name)} - SPRING`, spring],
|
||||
]
|
||||
})()
|
||||
break
|
||||
}
|
||||
|
||||
case 'Credits': {
|
||||
result = [
|
||||
[getTextIn(label.name), student.criteriaProgress[`year${year + 1}`]?.credits ? 'Passed' : ''],
|
||||
]
|
||||
break
|
||||
}
|
||||
|
||||
default: {
|
||||
const course = student.courses.find(course => idToGroupIdMap[course.course_id] === label.groupId)
|
||||
const enrollment = student.enrollments.find(
|
||||
enrollment => idToGroupIdMap[enrollment.course_id] === label.groupId
|
||||
)
|
||||
result = [
|
||||
[
|
||||
getTextIn(label.name),
|
||||
course?.passed ? 'Passed' : enrollment?.state === EnrollmentState.ENROLLED ? 'Enrolled' : '',
|
||||
],
|
||||
]
|
||||
break
|
||||
}
|
||||
}
|
||||
return result
|
||||
}) ?? []
|
||||
)
|
||||
})
|
||||
.filter(([key, _]) => !!key)
|
||||
),
|
||||
})),
|
||||
[students, allSemesters, programme, labelCriteria, idToGroupIdMap]
|
||||
)
|
||||
|
||||
const accessorKeys = useMemo(
|
||||
() => [
|
||||
'Student number',
|
||||
'Last name',
|
||||
'First names',
|
||||
...range(0, isMedicalProgramme(programme) ? 6 : 3)
|
||||
.filter(year => year * 12 < months)
|
||||
.flatMap(year => {
|
||||
return (
|
||||
labelCriteria[criteriaHeaders[year].label]?.flatMap(label => {
|
||||
const labelName = getTextIn(label.name) ?? ''
|
||||
return label.code === 'Enrollment' ? [`${labelName} - FALL`, `${labelName} - SPRING`] : [labelName]
|
||||
}) ?? []
|
||||
)
|
||||
})
|
||||
.filter(key => !!key),
|
||||
],
|
||||
[programme, labelCriteria]
|
||||
)
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const columns = [
|
||||
return [
|
||||
columnHelper.group({
|
||||
header: 'Student',
|
||||
columns: [
|
||||
@@ -571,37 +373,28 @@ export const ProgressTable = ({
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
generateYearColumns(academicYearStart, academicYearEnd, 0),
|
||||
...criteriaHeaders.map(({ title, year, label }, yearIndex) =>
|
||||
columnHelper.group({
|
||||
id: title,
|
||||
header: title,
|
||||
columns: createContent(labelCriteria[label] ?? [], year, yearIndex),
|
||||
})
|
||||
),
|
||||
]
|
||||
|
||||
const addYearColumns = (year: number) => {
|
||||
if (months > year * 12) {
|
||||
columns.push(
|
||||
generateYearColumns(
|
||||
dayjs(academicYearStart).add(year, 'years'),
|
||||
dayjs(academicYearEnd).add(year, 'years'),
|
||||
year
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
addYearColumns(1)
|
||||
addYearColumns(2)
|
||||
|
||||
if (isMedicalProgramme(programme)) {
|
||||
addYearColumns(3)
|
||||
addYearColumns(4)
|
||||
addYearColumns(5)
|
||||
}
|
||||
|
||||
return columns
|
||||
}, [criteria, students, curriculum, getTextIn, namesVisible, idToGroupIdMap])
|
||||
}, [
|
||||
criteria,
|
||||
criteriaHeaders,
|
||||
labelCriteria,
|
||||
academicYears,
|
||||
semesterEnrollmentsByStudent,
|
||||
namesVisible,
|
||||
idToGroupIdMap,
|
||||
])
|
||||
|
||||
const isCriteriaSet =
|
||||
criteria && Object.keys(criteria.courses).some(yearCourses => criteria.courses[yearCourses].length > 0)
|
||||
|
||||
const tableOptions = {}
|
||||
criteria &&
|
||||
(Object.keys(criteria.courses).some(yearCourses => criteria.courses[yearCourses].length > 0) ||
|
||||
Object.values(criteria.credits).some(creditCriteria => creditCriteria > 0))
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -612,28 +405,30 @@ export const ProgressTable = ({
|
||||
</h5>
|
||||
)}
|
||||
<TableInfo />
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<Box sx={{ width: '100%' }}>
|
||||
{isCriteriaSet ? (
|
||||
<OodiTable
|
||||
columns={columns}
|
||||
data={students}
|
||||
options={tableOptions}
|
||||
toolbarContent={
|
||||
<>
|
||||
<OodiTableExcelExport data={excelData} exportColumnKeys={accessorKeys} />
|
||||
<FilterDegreeCoursesModal degreeProgramme={programme} text="Modify criteria" icon={<EditIcon />} />
|
||||
<StudentNameVisibilityToggle />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="h6">
|
||||
There is no criteria set for this programme. You can create one{' '}
|
||||
<Link to={`/study-programme/${programme}?tab=3`}>here</Link>.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ width: '100%' }}>
|
||||
{isCriteriaSet ? (
|
||||
<OodiTable
|
||||
columns={columns}
|
||||
data={students}
|
||||
toolbarContent={
|
||||
<>
|
||||
<OodiTableExcelExport data={excelData} exportColumnKeys={accessorKeys} />
|
||||
<FilterDegreeCoursesModal
|
||||
degreeProgramme={programme}
|
||||
initialTab="criteria"
|
||||
icon={<EditIcon />}
|
||||
text="Modify criteria"
|
||||
/>
|
||||
<StudentNameVisibilityToggle />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="h6">
|
||||
There is no criteria set for this programme. You can create one{' '}
|
||||
<Link to={`/study-programme/${programme}?tab=3`}>here</Link>.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
import dayjs, { extend as dayjsExtend } from 'dayjs'
|
||||
import isBetween from 'dayjs/plugin/isBetween'
|
||||
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
|
||||
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
|
||||
|
||||
import { DateFormat } from '@/constants/date'
|
||||
import { isMedicalProgramme } from '@/util/studyProgramme'
|
||||
import { formatDate } from '@/util/timeAndDate'
|
||||
import { CreditTypeCode, EnrollmentState, FormattedStudent, ProgressCriteria } from '@oodikone/shared/types'
|
||||
import { StudentCourse } from '@oodikone/shared/types/studentData'
|
||||
|
||||
dayjsExtend(isBetween)
|
||||
dayjsExtend(isSameOrBefore)
|
||||
dayjsExtend(isSameOrAfter)
|
||||
|
||||
export type IdToGroupId = Record<string, string>
|
||||
|
||||
export type Label = {
|
||||
/** Course code for course labels, otherwise a special value ('Credits', 'Criteria', 'Enrollment') */
|
||||
code: string
|
||||
/** Set for course labels */
|
||||
groupId?: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
Return the attainments of the course and of each of its substitution groups, newest first. One entry per group;
|
||||
a group is only included if the student has attainments for all of its courses.
|
||||
*/
|
||||
export const getCourseGroups = (
|
||||
courseGroupId: string,
|
||||
criteria: ProgressCriteria | undefined,
|
||||
student: FormattedStudent,
|
||||
idToGroupId: IdToGroupId
|
||||
): StudentCourse[][] => {
|
||||
const studentGroupIds = new Set(student.courses.map(({ course_id }) => idToGroupId[course_id]))
|
||||
|
||||
return [[courseGroupId]]
|
||||
.concat(criteria?.allCourseGroups[courseGroupId] ?? [])
|
||||
.filter(group => group.every(groupId => studentGroupIds.has(groupId)))
|
||||
.map(group =>
|
||||
student.courses
|
||||
.filter(course => group.includes(idToGroupId[course.course_id]))
|
||||
.toSorted((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
Filter out failed courses that don't "break" a given group eg. remove the only instance of a course code
|
||||
|
||||
NOTE: Does not automatically remove all failed courses!
|
||||
*/
|
||||
const filterFailed = (group: StudentCourse[]) => (course: StudentCourse) =>
|
||||
course.passed || !group.find(otherCourse => course.course_code === otherCourse.course_code && otherCourse.passed)
|
||||
|
||||
const isGroupPassed = (group: StudentCourse[]) => {
|
||||
const passedCredits = group.filter(filterFailed(group))
|
||||
return passedCredits.length > 0 && passedCredits.every(credit => credit.passed)
|
||||
}
|
||||
|
||||
export const hasCreditTransfer = (courseGroups: StudentCourse[][]) =>
|
||||
courseGroups.some(
|
||||
group => isGroupPassed(group) && group.some(course => course.credittypecode === CreditTypeCode.APPROVED)
|
||||
)
|
||||
|
||||
/** Check if any courseGroup has
|
||||
1. all courses passed
|
||||
2. *LATEST* attainment gotten during the wanted academic year
|
||||
3. All attainments gotten before the wanted academic year's end
|
||||
*/
|
||||
export const hasPassedDuringAcademicYear = (courseGroups: StudentCourse[][], start: dayjs.Dayjs, end: dayjs.Dayjs) =>
|
||||
courseGroups.some(group => {
|
||||
if (!isGroupPassed(group)) return false
|
||||
const passedCredits = group.filter(credit => credit.passed)
|
||||
return (
|
||||
passedCredits.some(credit => dayjs(credit.date).isBetween(start, end)) &&
|
||||
passedCredits.every(credit => dayjs(credit.date).isBefore(end))
|
||||
)
|
||||
})
|
||||
|
||||
/** Check if any group has only passed courses */
|
||||
export const hasPassedOutsideAcademicYear = (courseGroups: StudentCourse[][]) => courseGroups.some(isGroupPassed)
|
||||
|
||||
/**
|
||||
Check if there is a failed course that was not removed by the filterFailed eg. there is
|
||||
a failed course that has no passed attainment
|
||||
*/
|
||||
export const hasFailed = (courseGroups: StudentCourse[][]) =>
|
||||
courseGroups.some(group => group.filter(filterFailed(group)).some(course => !course.passed))
|
||||
|
||||
/** Return enrollments only for main courses */
|
||||
export const getEnrollments = (student: FormattedStudent, courseGroupId: string, idToGroupId: IdToGroupId) =>
|
||||
(student.enrollments ?? []).filter(
|
||||
enrollment => enrollment.state === EnrollmentState.ENROLLED && idToGroupId[enrollment.course_id] === courseGroupId
|
||||
)
|
||||
|
||||
export const hasEnrolled = (student: FormattedStudent, courseGroupId: string, idToGroupId: IdToGroupId) =>
|
||||
getEnrollments(student, courseGroupId, idToGroupId).length > 0
|
||||
|
||||
/** Text representation of a cell, used for the tooltip and the Excel export */
|
||||
export const getCellText = (
|
||||
label: Label,
|
||||
criteria: ProgressCriteria | undefined,
|
||||
student: FormattedStudent,
|
||||
year: string,
|
||||
idToGroupId: IdToGroupId
|
||||
) => {
|
||||
if (label.code === 'Credits') {
|
||||
return student.criteriaProgress[year]?.credits ? 'Passed' : ''
|
||||
}
|
||||
if (!label.groupId) return ''
|
||||
|
||||
const courseGroups = getCourseGroups(label.groupId, criteria, student, idToGroupId)
|
||||
|
||||
const passedGroup = courseGroups.find(isGroupPassed)
|
||||
if (passedGroup) {
|
||||
const isMainCourse = passedGroup.every(course => idToGroupId[course.course_id] === label.groupId)
|
||||
const latestPassed = passedGroup.find(course => course.passed)
|
||||
return `Passed ${isMainCourse ? formatDate(latestPassed?.date, DateFormat.ISO_DATE) : '(substituted)'}`
|
||||
}
|
||||
|
||||
if (hasFailed(courseGroups)) {
|
||||
const latestFailedCourse = courseGroups
|
||||
.flatMap(group => group.filter(filterFailed(group)))
|
||||
.find(course => !course.passed)
|
||||
return `Failed ${latestFailedCourse ? formatDate(latestFailedCourse.date, DateFormat.ISO_DATE) : '(substituted)'}`
|
||||
}
|
||||
|
||||
const [enrollment] = getEnrollments(student, label.groupId, idToGroupId)
|
||||
if (enrollment) {
|
||||
return `Enrollment ${formatDate(enrollment.enrollment_date_time, DateFormat.ISO_DATE)}`
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const yearKeys = [
|
||||
{ year: 'year1', label: 'yearOne' },
|
||||
{ year: 'year2', label: 'yearTwo' },
|
||||
{ year: 'year3', label: 'yearThree' },
|
||||
{ year: 'year4', label: 'yearFour' },
|
||||
{ year: 'year5', label: 'yearFive' },
|
||||
{ year: 'year6', label: 'yearSix' },
|
||||
] as const
|
||||
|
||||
export const getCriteriaHeaders = (months: number, programme: string) =>
|
||||
yearKeys.slice(0, isMedicalProgramme(programme) ? 6 : 3).map(({ year, label }, index) => ({
|
||||
title: `Academic year ${index + 1}${months < (index + 1) * 12 ? ' (in progress)' : ''}`,
|
||||
year,
|
||||
label,
|
||||
}))
|
||||
@@ -13,7 +13,6 @@ import { type FormattedStudentData, GeneralTab } from '@/components/PopulationCo
|
||||
import { ModulesTabContainer as ModulesTab } from '@/components/PopulationComponents/Students/Table/ModulesTab'
|
||||
import { ProgressTable as ProgressTab } from '@/components/PopulationComponents/Students/Table/ProgressTab'
|
||||
import { TagsTab } from '@/components/PopulationComponents/Students/Table/TagsTab'
|
||||
import { ExtendedCurriculumDetails } from '@/hooks/useCurriculums'
|
||||
import { FilteredCourse } from '@/util/coursesOfPopulation'
|
||||
import { useParseQueryParams } from '@/util/queryparams'
|
||||
import { isBachelorOrLicentiateProgramme } from '@/util/studyProgramme'
|
||||
@@ -29,7 +28,6 @@ type PopulationDetails = CommonProps & {
|
||||
variant: 'population'
|
||||
programme: string
|
||||
combinedProgramme?: string
|
||||
curriculum: ExtendedCurriculumDetails | null
|
||||
filteredCourses: FilteredCourse[]
|
||||
idToGroupIdMap: Record<string, string>
|
||||
}
|
||||
@@ -40,7 +38,6 @@ type CoursePopulation = CommonProps & {
|
||||
|
||||
type StudyGuidanceGroup = CommonProps & {
|
||||
variant: 'studyGuidanceGroupPopulation'
|
||||
curriculum: ExtendedCurriculumDetails | null
|
||||
studyGuidanceGroup: any
|
||||
year: string
|
||||
filteredCourses: FilteredCourse[]
|
||||
@@ -63,7 +60,6 @@ export const PopulationStudents = ({
|
||||
variant,
|
||||
programme,
|
||||
combinedProgramme,
|
||||
curriculum,
|
||||
filteredStudents,
|
||||
filteredCourses,
|
||||
idToGroupIdMap,
|
||||
@@ -101,7 +97,6 @@ export const PopulationStudents = ({
|
||||
Courses: (
|
||||
<CoursesTab
|
||||
courses={filteredCourses ?? []}
|
||||
curriculum={curriculum} // TODO: add guard for missing curriculum (it should never be missing)
|
||||
idToGroupIdMap={idToGroupIdMap ?? {}}
|
||||
students={filteredStudents}
|
||||
programme={programme}
|
||||
@@ -110,7 +105,6 @@ export const PopulationStudents = ({
|
||||
Modules: (
|
||||
<ModulesTab
|
||||
courses={filteredCourses ?? []}
|
||||
curriculum={curriculum}
|
||||
idToGroupIdMap={idToGroupIdMap ?? {}}
|
||||
students={filteredStudents}
|
||||
programme={programme}
|
||||
@@ -119,7 +113,6 @@ export const PopulationStudents = ({
|
||||
Tags: <TagsTab combinedProgramme={combinedProgramme} programme={programme} students={filteredStudents} />,
|
||||
Progress: (
|
||||
<ProgressTab
|
||||
curriculum={curriculum}
|
||||
idToGroupIdMap={idToGroupIdMap ?? {}}
|
||||
months={months ?? Infinity}
|
||||
programme={programme}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { CourseFilterToggle } from '@/components/PopulationCourseStats/CourseFil
|
||||
import { ModuleCourseToggle } from '@/components/PopulationCourseStats/ModuleCourseToggle'
|
||||
import { NorthEastIcon, KeyboardArrowRightIcon } from '@/theme'
|
||||
import { range } from '@oodikone/shared/util'
|
||||
import { HiddenCourseAlert } from '@/components/HiddenCourseAlert'
|
||||
|
||||
const mapCourseData = course =>
|
||||
course.courses
|
||||
@@ -182,6 +183,7 @@ export const GradeDistribution = ({
|
||||
{courseTableMode === 'all' && (
|
||||
<ModuleCourseToggle setShowModules={setShowModules} showModules={showModules} />
|
||||
)}
|
||||
{courseTableMode === 'curriculum' ? <HiddenCourseAlert ooditable /> : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ModuleCourseToggle } from '@/components/PopulationCourseStats/ModuleCou
|
||||
import { NorthEastIcon, KeyboardArrowRightIcon } from '@/theme'
|
||||
import { CourseModule, UnionOfFilteredModuleCourse } from '@/util/coursesOfPopulation'
|
||||
import { Unarray } from '@oodikone/shared/types'
|
||||
import { HiddenCourseAlert } from '@/components/HiddenCourseAlert'
|
||||
|
||||
const columnHelper = createColumnHelper<Unarray<UnionOfFilteredModuleCourse>>()
|
||||
|
||||
@@ -248,6 +249,7 @@ export const PassFailEnrollments = ({
|
||||
setShowModules !== undefined && ( // Check if optional fields are set
|
||||
<ModuleCourseToggle setShowModules={setShowModules} showModules={showModules} />
|
||||
)}
|
||||
{courseTableMode === 'curriculum' ? <HiddenCourseAlert ooditable /> : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { CourseFilterToggle } from '@/components/PopulationCourseStats/CourseFil
|
||||
import { ModuleCourseToggle } from '@/components/PopulationCourseStats/ModuleCourseToggle'
|
||||
import { NorthEastIcon, KeyboardArrowRightIcon } from '@/theme'
|
||||
import { range } from '@oodikone/shared/util'
|
||||
import { HiddenCourseAlert } from '@/components/HiddenCourseAlert'
|
||||
|
||||
const columnHelper = createColumnHelper()
|
||||
|
||||
@@ -183,12 +184,13 @@ export const PassingSemesters = ({ onlyIamRights, courseStatistics, courseTableM
|
||||
toolbarContent={
|
||||
<>
|
||||
<OodiTableExcelExport data={excelData} exportColumnKeys={accessorKeys} />
|
||||
<Button onClick={() => setCumulativeStats(!cumulativeStats)} sx={{ my: 'auto', mx: 1 }} variant="outlined">
|
||||
<Button onClick={() => setCumulativeStats(!cumulativeStats)} variant="outlined">
|
||||
{cumulativeStats ? 'Show yearly stats' : 'Show cumulative stats'}
|
||||
</Button>
|
||||
{courseTableMode === 'all' && (
|
||||
<ModuleCourseToggle setShowModules={setShowModules} showModules={showModules} />
|
||||
)}
|
||||
{courseTableMode === 'curriculum' ? <HiddenCourseAlert ooditable /> : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useState, useEffect } from 'react'
|
||||
import { GradeDistribution } from '@/components/PopulationCourseStats/GradeDistribution'
|
||||
import { PassFailEnrollments } from '@/components/PopulationCourseStats/PassFailEnrollments'
|
||||
import { PassingSemesters } from '@/components/PopulationCourseStats/PassingSemesters'
|
||||
import { ExtendedCurriculumDetails } from '@/hooks/useCurriculums'
|
||||
import { useCurriculum } from '@/hooks/useCurriculum'
|
||||
import { FilteredCourse, CourseModule, FilteredProgrammeCourse } from '@/util/coursesOfPopulation'
|
||||
import type { CourseStats } from '@oodikone/shared/routes/populations'
|
||||
import type { Module, CurriculumDetails } from '@oodikone/shared/types'
|
||||
@@ -14,7 +14,6 @@ type PopulationCourseStatsProps = {
|
||||
filteredCourses: FilteredCourse[]
|
||||
pending: boolean
|
||||
onlyIamRights: boolean
|
||||
curriculum: ExtendedCurriculumDetails
|
||||
courseTableMode: 'all' | 'curriculum'
|
||||
showModules: boolean
|
||||
setShowModules: (input: boolean) => void
|
||||
@@ -33,15 +32,17 @@ export const PopulationCourseStats = ({
|
||||
filteredCourses,
|
||||
pending,
|
||||
onlyIamRights,
|
||||
curriculum,
|
||||
courseTableMode,
|
||||
showModules,
|
||||
setShowModules,
|
||||
}: PopulationCourseStatsProps) => {
|
||||
const { curriculum } = useCurriculum()
|
||||
const [modules, setModules] = useState<CourseModule[]>([])
|
||||
const [tab, setTab] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!curriculum) return
|
||||
|
||||
// Change courses type from FilteredCourse[] to FilteredProgrammeCourse[] + add module-field to object so we can deconstruct it below
|
||||
const modules: Record<
|
||||
string,
|
||||
|
||||
@@ -31,7 +31,7 @@ import { AdvancedSettings } from '@/components/PopulationDetails/AdvancedSetting
|
||||
import { PopulationQueryCard } from '@/components/PopulationDetails/PopulationQueryCard'
|
||||
import { useColumns as columnsGeneralTab } from '@/components/PopulationDetails/studentColumns'
|
||||
import { useDebouncedState } from '@/hooks/debouncedState'
|
||||
import { useCurriculumState } from '@/hooks/useCurriculums'
|
||||
import { CurriculumProvider, useCurriculum } from '@/hooks/useCurriculum'
|
||||
import { useGetAuthorizedUserQuery } from '@/redux/auth'
|
||||
import { KeyboardBackspaceIcon } from '@/theme'
|
||||
import { PopulationQuery } from '@/types/populationSearch'
|
||||
@@ -48,7 +48,13 @@ type PopulationDetailsProps = {
|
||||
idToGroupIdMap: Record<string, string>
|
||||
}
|
||||
|
||||
export const PopulationDetails = ({
|
||||
export const PopulationDetails = (props: PopulationDetailsProps) => (
|
||||
<CurriculumProvider programmeCode={props.query.programme} year={props.query?.years?.[0]}>
|
||||
<PopulationDetailsContent {...props} />
|
||||
</CurriculumProvider>
|
||||
)
|
||||
|
||||
const PopulationDetailsContent = ({
|
||||
isLoading,
|
||||
query,
|
||||
populationTags,
|
||||
@@ -64,7 +70,7 @@ export const PopulationDetails = ({
|
||||
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false)
|
||||
const [courseTableMode, setCourseTableMode] = useState<'curriculum' | 'all'>('curriculum')
|
||||
const [studentAmountLimit, setStudentAmountLimit] = useDebouncedState(0, 1000)
|
||||
const [curriculum, curriculumList, setCurriculum] = useCurriculumState(programme, query?.years?.[0])
|
||||
const { curriculum, curriculumList, setSelectedCurriculum: setCurriculum } = useCurriculum()
|
||||
const [showModules, setShowModules] = useState(false) // Shows courses if modules not selected
|
||||
|
||||
const primaryHopsCreditFilter = useFilterSelector(hopsFilter.selectors.isPrimarySelected(undefined))
|
||||
@@ -93,7 +99,7 @@ export const PopulationDetails = ({
|
||||
|
||||
const panels = [
|
||||
{
|
||||
title: `Credit accumulation (for ${filteredStudents.length} students)`,
|
||||
title: `Credit accumulation (for ${filteredStudents.length} student${filteredStudents.length > 1 ? 's' : ''})`,
|
||||
content: filteredStudents.length ? (
|
||||
// TODO: make wrapper for when no data.
|
||||
<CreditAccumulationGraph
|
||||
@@ -122,14 +128,12 @@ export const PopulationDetails = ({
|
||||
<>
|
||||
<CourseTableModeSelector
|
||||
courseTableMode={courseTableMode}
|
||||
curriculum={curriculum}
|
||||
onStudentAmountLimitChange={onStudentAmountLimitChange}
|
||||
setCourseTableMode={setCourseTableMode}
|
||||
studentAmountLimit={studentAmountLimit}
|
||||
/>
|
||||
<PopulationCourses
|
||||
courseTableMode={courseTableMode}
|
||||
curriculum={curriculum}
|
||||
filteredCourses={filteredCourses}
|
||||
isLoading={isLoading}
|
||||
onlyIamRights={onlyIamRights}
|
||||
@@ -147,7 +151,6 @@ export const PopulationDetails = ({
|
||||
content: (
|
||||
<PopulationStudents
|
||||
combinedProgramme={combinedProgramme}
|
||||
curriculum={curriculum}
|
||||
filteredCourses={filteredCourses}
|
||||
filteredStudents={filteredStudents}
|
||||
idToGroupIdMap={idToGroupIdMap}
|
||||
|
||||
+31
-13
@@ -21,26 +21,46 @@ import { useFormat as formatGeneralTab } from '@/components/PopulationComponents
|
||||
import { useColumns as columnsGeneralTab } from '@/components/StudyGuidanceGroups/studentColumns'
|
||||
import { StudyGuidanceGroupPopulationCourses } from '@/components/StudyGuidanceGroups/StudyGuidanceGroupPopulationCourses'
|
||||
import { createAcademicYearStartDate } from '@/components/StudyGuidanceGroups/utils'
|
||||
import { useCurriculumState } from '@/hooks/useCurriculums'
|
||||
import { CurriculumProvider, useCurriculum } from '@/hooks/useCurriculum'
|
||||
import { KeyboardBackspaceIcon } from '@/theme'
|
||||
import { FilteredCourse } from '@/util/coursesOfPopulation'
|
||||
import { FormattedStudent } from '@oodikone/shared/types'
|
||||
import { GroupsWithTags } from '@oodikone/shared/types/studyGuidanceGroup'
|
||||
import { useLanguage } from '../LanguagePicker/useLanguage'
|
||||
|
||||
dayjsExtend(isBetween)
|
||||
|
||||
export const SingleStudyGuidanceGroupPanels = ({
|
||||
filteredStudents,
|
||||
filteredCourses,
|
||||
group,
|
||||
idToGroupIdMap,
|
||||
}: {
|
||||
type SingleStudyGuidanceGroupPanelsProps = {
|
||||
filteredStudents: FormattedStudent[]
|
||||
filteredCourses: FilteredCourse[]
|
||||
group: GroupsWithTags
|
||||
idToGroupIdMap: Record<string, string>
|
||||
}) => {
|
||||
}
|
||||
|
||||
const curriculumInfoBoxContent = {
|
||||
fi: 'Valitsee tarkasteltavan populaation opetussuunnitelman. Opetussuunnitelman valitseminen edellyttää koulutusohjelman asettamista populaatiolle.',
|
||||
en: 'Selects the curriculum to be used for the population. Setting a curriculum requires a degree programme to be set for the population.',
|
||||
}
|
||||
|
||||
export const SingleStudyGuidanceGroupPanels = (props: SingleStudyGuidanceGroupPanelsProps) => {
|
||||
const [programme] = props.group.tags?.studyProgramme?.split('+') ?? []
|
||||
const year = props.group.tags?.year ?? undefined
|
||||
|
||||
return (
|
||||
<CurriculumProvider programmeCode={programme} year={year}>
|
||||
<SingleStudyGuidanceGroupPanelsContent {...props} />
|
||||
</CurriculumProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const SingleStudyGuidanceGroupPanelsContent = ({
|
||||
filteredStudents,
|
||||
filteredCourses,
|
||||
group,
|
||||
idToGroupIdMap,
|
||||
}: SingleStudyGuidanceGroupPanelsProps) => {
|
||||
const { useFilterSelector, useFilterDispatch: filterDispatch } = useFilters()
|
||||
const { getTextIn } = useLanguage()
|
||||
|
||||
const groupYear = group.tags?.year
|
||||
const groupProgramme = group.tags?.studyProgramme
|
||||
@@ -53,7 +73,7 @@ export const SingleStudyGuidanceGroupPanels = ({
|
||||
years: groupYear ? [Number(groupYear)] : [],
|
||||
}
|
||||
|
||||
const [curriculum, curriculumList, setCurriculum] = useCurriculumState(programme, groupYear!) // TODO: fix year
|
||||
const { curriculum, curriculumList, setSelectedCurriculum: setCurriculum } = useCurriculum()
|
||||
|
||||
const creditDateFilterActive = useFilterSelector(creditDateFilter.selectors.isActive(undefined))
|
||||
const studyPlanFilterIsActive = useFilterSelector(studyPlanFilter.selectors.isActive(undefined))
|
||||
@@ -72,7 +92,7 @@ export const SingleStudyGuidanceGroupPanels = ({
|
||||
}
|
||||
const panels = [
|
||||
{
|
||||
title: `Credit accumulation (for ${filteredStudents.length} students)`,
|
||||
title: `Credit accumulation (for ${filteredStudents.length} student${filteredStudents.length > 1 ? 's' : ''})`,
|
||||
content: (
|
||||
<>
|
||||
{!!group.tags?.year && (
|
||||
@@ -104,7 +124,6 @@ export const SingleStudyGuidanceGroupPanels = ({
|
||||
title: 'Courses of population',
|
||||
content: (
|
||||
<StudyGuidanceGroupPopulationCourses
|
||||
curriculum={curriculum}
|
||||
filteredCourses={filteredCourses}
|
||||
studyProgramme={group.tags?.studyProgramme ? programme : null}
|
||||
year={groupYear}
|
||||
@@ -116,7 +135,6 @@ export const SingleStudyGuidanceGroupPanels = ({
|
||||
content: (
|
||||
// @ts-expect-error FIX typing
|
||||
<PopulationStudents
|
||||
curriculum={curriculum}
|
||||
filteredCourses={filteredCourses}
|
||||
filteredStudents={filteredStudents}
|
||||
generalTabColumnFunction={() => columnsGeneralTab({ group })}
|
||||
@@ -165,7 +183,7 @@ export const SingleStudyGuidanceGroupPanels = ({
|
||||
curriculumList={curriculumList}
|
||||
setCurriculum={setCurriculum}
|
||||
/>
|
||||
<InfoBox content={'Valitsee tarkasteltavan populaation opetussuunnitelman.'} mini />
|
||||
<InfoBox content={getTextIn(curriculumInfoBoxContent) ?? ''} mini />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</FormGroup>
|
||||
|
||||
+1
-3
@@ -6,7 +6,7 @@ import { PopulationCourseStats } from '@/components/PopulationCourseStats'
|
||||
import { PopulationCourseStatsFlat } from '@/components/PopulationCourseStats/PopulationCourseStatsFlat'
|
||||
import { useDebouncedState } from '@/hooks/debouncedState'
|
||||
|
||||
export const StudyGuidanceGroupPopulationCourses = ({ filteredCourses, studyProgramme, year, curriculum }) => {
|
||||
export const StudyGuidanceGroupPopulationCourses = ({ filteredCourses, studyProgramme, year }) => {
|
||||
const [studentAmountLimit, setStudentAmountLimit] = useDebouncedState(0, 1000)
|
||||
const curriculumsAvailable = studyProgramme && year
|
||||
const [courseTableMode, setCourseTableMode] = useState<'all' | 'curriculum'>(
|
||||
@@ -23,7 +23,6 @@ export const StudyGuidanceGroupPopulationCourses = ({ filteredCourses, studyProg
|
||||
{curriculumsAvailable ? (
|
||||
<CourseTableModeSelector
|
||||
courseTableMode={courseTableMode}
|
||||
curriculum={curriculum}
|
||||
onStudentAmountLimitChange={onStudentAmountLimitChange}
|
||||
setCourseTableMode={setCourseTableMode}
|
||||
studentAmountLimit={studentAmountLimit}
|
||||
@@ -37,7 +36,6 @@ export const StudyGuidanceGroupPopulationCourses = ({ filteredCourses, studyProg
|
||||
{courseTableMode === 'curriculum' ? (
|
||||
<PopulationCourseStats
|
||||
courseTableMode={courseTableMode}
|
||||
curriculum={curriculum}
|
||||
filteredCourses={filteredCourses}
|
||||
onlyIamRights={onlyIamRights}
|
||||
pending={false}
|
||||
|
||||
@@ -19,6 +19,7 @@ export const StudentNameVisibilityToggle = () => {
|
||||
|
||||
return (
|
||||
<FormControlLabel
|
||||
sx={{ m: 0 }}
|
||||
control={<Switch checked={visible} data-cy="toggleStudentNames" onChange={() => toggle()} />}
|
||||
label="Show student names"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createContext, Dispatch, ReactNode, SetStateAction, useContext, useMemo } from 'react'
|
||||
|
||||
import { ExtendedCurriculumDetails, useCurriculumState } from '@/hooks/useCurriculums'
|
||||
import { CurriculumOption } from '@oodikone/shared/types'
|
||||
|
||||
type CurriculumContextType = {
|
||||
curriculum: ExtendedCurriculumDetails | null
|
||||
curriculumList: CurriculumOption[]
|
||||
setSelectedCurriculum: Dispatch<SetStateAction<CurriculumOption | null>>
|
||||
}
|
||||
|
||||
const CurriculumContext = createContext<CurriculumContextType | null>(null)
|
||||
CurriculumContext.displayName = 'Curriculum'
|
||||
|
||||
export const CurriculumProvider = ({
|
||||
programmeCode,
|
||||
year,
|
||||
children,
|
||||
}: {
|
||||
programmeCode: string
|
||||
year: string | number | undefined
|
||||
children: ReactNode
|
||||
}) => {
|
||||
const [curriculum, curriculumList, setSelectedCurriculum] = useCurriculumState(programmeCode, year)
|
||||
const value = useMemo(
|
||||
() => ({ curriculum, curriculumList, setSelectedCurriculum }),
|
||||
[curriculum, curriculumList, setSelectedCurriculum]
|
||||
)
|
||||
|
||||
return <CurriculumContext.Provider value={value}>{children}</CurriculumContext.Provider>
|
||||
}
|
||||
|
||||
/** Context-aware variant of the curriculum hook. Use this in population statistics views. */
|
||||
export const useCurriculum = () => {
|
||||
const context = useContext(CurriculumContext)
|
||||
if (!context) {
|
||||
throw new Error('useCurriculum must be used within a CurriculumProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, Dispatch, SetStateAction } from 'react'
|
||||
import { useMemo, useState, Dispatch, SetStateAction } from 'react'
|
||||
|
||||
import { useGetCurriculumsQuery, useGetCurriculumOptionsQuery } from '@/redux/curriculum'
|
||||
import { CurriculumOption, CurriculumDetails } from '@oodikone/shared/types'
|
||||
@@ -7,10 +7,9 @@ export type ExtendedCurriculumDetails = CurriculumDetails & CurriculumOption
|
||||
|
||||
export const useCurriculumState = (
|
||||
programmeCode: string,
|
||||
year: string | number
|
||||
year: string | number | undefined
|
||||
): [ExtendedCurriculumDetails | null, CurriculumOption[], Dispatch<SetStateAction<CurriculumOption | null>>] => {
|
||||
const [selectedCurriculum, setSelectedCurriculum] = useState<CurriculumOption | null>(null)
|
||||
const [curriculum, setCurriculum] = useState<ExtendedCurriculumDetails | null>(null)
|
||||
|
||||
const { data: curriculumList = [], isFetching: curriculumsLoading } = useGetCurriculumOptionsQuery(
|
||||
{ code: programmeCode },
|
||||
@@ -19,7 +18,7 @@ export const useCurriculumState = (
|
||||
|
||||
const chosenCurriculum: CurriculumOption | null =
|
||||
selectedCurriculum ??
|
||||
curriculumList.find(curriculum => new Date(curriculum.validFrom) <= new Date(`${year}-08-01`)) ??
|
||||
(year ? curriculumList.find(curriculum => new Date(curriculum.validFrom) <= new Date(`${year}-08-01`)) : null) ??
|
||||
curriculumList[0] ??
|
||||
null
|
||||
|
||||
@@ -31,11 +30,10 @@ export const useCurriculumState = (
|
||||
{ skip: curriculumsLoading || !chosenCurriculum?.periodIds }
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (curriculumData) {
|
||||
setCurriculum({ ...curriculumData, ...chosenCurriculum })
|
||||
}
|
||||
}, [curriculumData, chosenCurriculum])
|
||||
const curriculum = useMemo<ExtendedCurriculumDetails | null>(
|
||||
() => (curriculumData ? { ...curriculumData, ...chosenCurriculum } : null),
|
||||
[curriculumData, chosenCurriculum]
|
||||
)
|
||||
|
||||
return [curriculum, curriculumList, setSelectedCurriculum]
|
||||
}
|
||||
|
||||
+7
-39
@@ -12,7 +12,7 @@ import { Section } from '@/components/Section'
|
||||
import { useStatusNotification } from '@/components/StatusNotification/Context'
|
||||
import { useAddProgressCriteriaCreditsMutation } from '@/redux/progressCriteria'
|
||||
import { SaveIcon } from '@/theme'
|
||||
import { isMedicalProgramme } from '@/util/studyProgramme'
|
||||
import { getCriteriaYears } from '@/pages/StudyProgramme/DegreeCoursesTab/criteriaYears'
|
||||
import { ProgressCriteria } from '@oodikone/shared/types'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
|
||||
@@ -68,43 +68,11 @@ export const CreditCriteriaSection = ({
|
||||
}
|
||||
}, [isError])
|
||||
|
||||
const inputs = [
|
||||
{
|
||||
key: 'year1',
|
||||
label: 'First',
|
||||
previousValue: criteria.credits.yearOne,
|
||||
},
|
||||
{
|
||||
key: 'year2',
|
||||
label: 'Second',
|
||||
previousValue: criteria.credits.yearTwo,
|
||||
},
|
||||
{
|
||||
key: 'year3',
|
||||
label: 'Third',
|
||||
previousValue: criteria.credits.yearThree,
|
||||
},
|
||||
]
|
||||
|
||||
if (isMedicalProgramme(degreeProgramme)) {
|
||||
inputs.push(
|
||||
{
|
||||
key: 'year4',
|
||||
label: 'Fourth',
|
||||
previousValue: criteria.credits.yearFour,
|
||||
},
|
||||
{
|
||||
key: 'year5',
|
||||
label: 'Fifth',
|
||||
previousValue: criteria.credits.yearFive,
|
||||
},
|
||||
{
|
||||
key: 'year6',
|
||||
label: 'Sixth',
|
||||
previousValue: criteria.credits.yearSix,
|
||||
}
|
||||
)
|
||||
}
|
||||
const inputs = getCriteriaYears(degreeProgramme).map(({ key, label, year }) => ({
|
||||
key: `year${year}`,
|
||||
label,
|
||||
previousValue: criteria.credits[key],
|
||||
}))
|
||||
|
||||
const disabled = Object.values(creditLimits).every(value => value === null || value < 0)
|
||||
|
||||
@@ -112,7 +80,7 @@ export const CreditCriteriaSection = ({
|
||||
<Section
|
||||
cypress="credit-criteria"
|
||||
infoBoxContent={getTextIn(studyProgrammeToolTips.degreeCoursesTab.creditCriteria) ?? ''}
|
||||
title="Credit criteria"
|
||||
title="Credit targets"
|
||||
>
|
||||
<Stack gap={2}>
|
||||
<Grid container spacing={1}>
|
||||
|
||||
-114
@@ -1,114 +0,0 @@
|
||||
import Checkbox from '@mui/material/Checkbox'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import ListItemText from '@mui/material/ListItemText'
|
||||
import Menu from '@mui/material/Menu'
|
||||
import MenuItem from '@mui/material/MenuItem'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useAddProgressCriteriaCourseMutation } from '@/redux/progressCriteria'
|
||||
import { FormatListNumberedIcon } from '@/theme'
|
||||
import { isMedicalProgramme } from '@/util/studyProgramme'
|
||||
import { ProgrammeCourse, ProgressCriteria } from '@oodikone/shared/types'
|
||||
|
||||
export const CriterionLabelSelectButton = ({
|
||||
course,
|
||||
criteria,
|
||||
studyProgramme,
|
||||
}: {
|
||||
course: ProgrammeCourse
|
||||
criteria: ProgressCriteria
|
||||
studyProgramme: string
|
||||
}) => {
|
||||
const [addProgressCriteriaCourse] = useAddProgressCriteriaCourseMutation()
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
|
||||
const [selectedOptions, setSelectedOptions] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const selected = Object.keys(criteria.courses).filter(key => criteria.courses[key].includes(course.group_id))
|
||||
setSelectedOptions(selected)
|
||||
}, [criteria, course.group_id])
|
||||
|
||||
const options = [
|
||||
{
|
||||
key: 'yearOne',
|
||||
label: 'First',
|
||||
year: 1,
|
||||
},
|
||||
{
|
||||
key: 'yearTwo',
|
||||
label: 'Second',
|
||||
year: 2,
|
||||
},
|
||||
{
|
||||
key: 'yearThree',
|
||||
label: 'Third',
|
||||
year: 3,
|
||||
},
|
||||
]
|
||||
|
||||
if (isMedicalProgramme(studyProgramme)) {
|
||||
options.push(
|
||||
{
|
||||
key: 'yearFour',
|
||||
label: 'Fourth',
|
||||
year: 4,
|
||||
},
|
||||
{
|
||||
key: 'yearFive',
|
||||
year: 5,
|
||||
label: 'Fifth',
|
||||
},
|
||||
{
|
||||
key: 'yearSix',
|
||||
year: 6,
|
||||
label: 'Sixth',
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const handleCheckboxChange = (key: string, year: number) => {
|
||||
let courseGroupIds: string[]
|
||||
|
||||
if (selectedOptions.includes(key)) {
|
||||
courseGroupIds = criteria.courses[key]?.filter((groupId: string) => groupId !== course.group_id)
|
||||
void addProgressCriteriaCourse({ programmeCode: studyProgramme, courseGroupIds, year })
|
||||
} else {
|
||||
courseGroupIds = criteria.courses ? [...criteria.courses[key], course.group_id] : [course.group_id]
|
||||
}
|
||||
|
||||
void addProgressCriteriaCourse({ programmeCode: studyProgramme, courseGroupIds, year })
|
||||
|
||||
setSelectedOptions(prevSelected =>
|
||||
prevSelected.includes(key) ? prevSelected.filter(item => item !== key) : [...prevSelected, key]
|
||||
)
|
||||
}
|
||||
|
||||
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget)
|
||||
}
|
||||
|
||||
const handleMenuClose = () => {
|
||||
setAnchorEl(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip arrow placement="top" title={'Modify criterion labels'}>
|
||||
<IconButton data-cy="toggle-visibility-button" onClick={handleMenuOpen}>
|
||||
<FormatListNumberedIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Menu anchorEl={anchorEl} onClose={handleMenuClose} open={Boolean(anchorEl)}>
|
||||
{options.map(option => (
|
||||
<MenuItem key={option.key} onClick={() => handleCheckboxChange(option.key, option.year)}>
|
||||
<Checkbox checked={selectedOptions.includes(option.key)} />
|
||||
<ListItemText primary={`${option.label} year`} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
|
||||
import { VisibilityIcon, VisibilityOffIcon } from '@/theme'
|
||||
|
||||
export const ToggleVisibilityButton = ({ onClick, visible }: { onClick: any; visible: boolean }) => {
|
||||
return (
|
||||
<Tooltip arrow placement="top" title={visible ? 'Set hidden' : 'Set visible'}>
|
||||
<IconButton data-cy="toggle-visibility-button" onClick={onClick}>
|
||||
{visible ? <VisibilityIcon /> : <VisibilityOffIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
import Chip from '@mui/material/Chip'
|
||||
|
||||
import { CourseVisibility } from '@/types/courseVisibility'
|
||||
|
||||
export const VisibilityChip = ({ visibility }: { visibility: CourseVisibility }) => {
|
||||
return (
|
||||
<Chip
|
||||
label={visibility}
|
||||
size="small"
|
||||
sx={{
|
||||
color: theme => theme.palette.courseVisibility[visibility],
|
||||
borderColor: theme => theme.palette.courseVisibility[visibility],
|
||||
}}
|
||||
variant="outlined"
|
||||
/>
|
||||
)
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
import Switch from '@mui/material/Switch'
|
||||
|
||||
import { CourseVisibility } from '@/types/courseVisibility'
|
||||
|
||||
const labels: Record<CourseVisibility, string> = {
|
||||
[CourseVisibility.NO_COURSES]: 'No courses',
|
||||
[CourseVisibility.HIDDEN]: 'Hidden',
|
||||
[CourseVisibility.PARTIAL]: 'Partly shown',
|
||||
[CourseVisibility.VISIBLE]: 'Shown',
|
||||
}
|
||||
|
||||
const colors = {
|
||||
[CourseVisibility.NO_COURSES]: 'noCourses',
|
||||
[CourseVisibility.HIDDEN]: 'hidden',
|
||||
[CourseVisibility.PARTIAL]: 'partial',
|
||||
[CourseVisibility.VISIBLE]: 'visible',
|
||||
} as const
|
||||
|
||||
export const VisibilitySwitch = ({
|
||||
ariaLabel,
|
||||
cypress,
|
||||
onChange,
|
||||
visibility,
|
||||
}: {
|
||||
ariaLabel: string
|
||||
cypress: string
|
||||
onChange: () => void
|
||||
visibility: CourseVisibility
|
||||
}) => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={visibility === CourseVisibility.VISIBLE || visibility === CourseVisibility.PARTIAL}
|
||||
disabled={visibility === CourseVisibility.NO_COURSES}
|
||||
onChange={onChange}
|
||||
size="small"
|
||||
inputProps={{ 'aria-label': ariaLabel }}
|
||||
/>
|
||||
}
|
||||
data-cy={cypress}
|
||||
label={labels[visibility]}
|
||||
onClick={event => event.stopPropagation()}
|
||||
slotProps={{
|
||||
typography: {
|
||||
variant: 'body2',
|
||||
sx: { color: theme => theme.palette.courseVisibility[colors[visibility]], whiteSpace: 'nowrap' },
|
||||
},
|
||||
}}
|
||||
sx={{ mr: 0 }}
|
||||
/>
|
||||
)
|
||||
+297
-178
@@ -1,7 +1,12 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import Chip from '@mui/material/Chip'
|
||||
import Button from '@mui/material/Button'
|
||||
import Checkbox from '@mui/material/Checkbox'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogContent from '@mui/material/DialogContent'
|
||||
import DialogContentText from '@mui/material/DialogContentText'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import TableBody from '@mui/material/TableBody'
|
||||
import TableCell from '@mui/material/TableCell'
|
||||
import TableHead from '@mui/material/TableHead'
|
||||
@@ -11,136 +16,190 @@ import Typography from '@mui/material/Typography'
|
||||
import { Fragment, useEffect, useState } from 'react'
|
||||
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { CriterionLabelSelectButton } from '@/pages/StudyProgramme/DegreeCoursesTab/DegreeCourseTable/CriterionLabelSelectButton'
|
||||
import { ToggleVisibilityButton } from '@/pages/StudyProgramme/DegreeCoursesTab/DegreeCourseTable/ToggleVisibilityButton'
|
||||
import { VisibilityChip } from '@/pages/StudyProgramme/DegreeCoursesTab/DegreeCourseTable/VisibilityChip'
|
||||
import { useStatusNotification } from '@/components/StatusNotification/Context'
|
||||
import { CriteriaYearOption, getCriteriaYears } from '@/pages/StudyProgramme/DegreeCoursesTab/criteriaYears'
|
||||
import { VisibilitySwitch } from '@/pages/StudyProgramme/DegreeCoursesTab/DegreeCourseTable/VisibilitySwitch'
|
||||
import { useRemoveCourseExclusionMutation, useSetCourseExclusionMutation } from '@/redux/courseExclusions'
|
||||
import { FormatListNumberedIcon, KeyboardArrowDownIcon, KeyboardArrowRightIcon } from '@/theme'
|
||||
import { useAddProgressCriteriaCourseMutation } from '@/redux/progressCriteria'
|
||||
import { KeyboardArrowDownIcon, KeyboardArrowRightIcon } from '@/theme'
|
||||
import { CourseVisibility } from '@/types/courseVisibility'
|
||||
import { isBachelorOrLicentiateProgramme } from '@/util/studyProgramme'
|
||||
import { Module, ProgrammeCourse, ProgressCriteria } from '@oodikone/shared/types'
|
||||
import { StyledTable } from '@/components/common/StyledTable'
|
||||
|
||||
const reloadNote = 'Reload the page to see the change in other views.'
|
||||
|
||||
export type DegreeModule = Module & {
|
||||
/** Programme whose curriculum the module is from. Course visibility is saved separately for each programme. */
|
||||
programmeCode: string
|
||||
}
|
||||
|
||||
const getModuleKey = (module: DegreeModule) => `${module.programmeCode}/${module.code}`
|
||||
|
||||
export const DegreeCourseTable = ({
|
||||
combinedProgramme,
|
||||
criteria,
|
||||
curriculumVersion,
|
||||
modules: initialModules,
|
||||
programmeNames,
|
||||
showCriteria,
|
||||
showVisibility,
|
||||
studyProgramme,
|
||||
}: {
|
||||
combinedProgramme: string
|
||||
criteria: ProgressCriteria
|
||||
curriculumVersion: string[]
|
||||
modules: Module[]
|
||||
/** Modules of the first programme first, then those of the second programme of a combined programme */
|
||||
modules: DegreeModule[]
|
||||
/** Shown above each programme's modules when the modules are from more than one programme */
|
||||
programmeNames: Record<string, string>
|
||||
showCriteria: boolean
|
||||
showVisibility: boolean
|
||||
/** Programme whose progress criteria are edited */
|
||||
studyProgramme: string
|
||||
}) => {
|
||||
const [moduleCoursesVisible, setModuleCoursesVisible] = useState<Record<string, Record<string, boolean>>>({})
|
||||
const [expandedModules, setExpandedModules] = useState<Record<string, Record<string, boolean>>>({})
|
||||
const { getTextIn } = useLanguage()
|
||||
const { setStatusNotification } = useStatusNotification()
|
||||
const [modules, setModules] = useState(initialModules)
|
||||
// Unsaved criteria changes, discarded when updated criteria arrive from the server
|
||||
const [pendingCriteria, setPendingCriteria] = useState<{
|
||||
courses: ProgressCriteria['courses']
|
||||
source: ProgressCriteria
|
||||
} | null>(null)
|
||||
const [moduleToHide, setModuleToHide] = useState<string | null>(null)
|
||||
const [setExclusion] = useSetCourseExclusionMutation()
|
||||
const [removeExclusion] = useRemoveCourseExclusionMutation()
|
||||
const [addProgressCriteriaCourse] = useAddProgressCriteriaCourseMutation()
|
||||
|
||||
const version = curriculumVersion.join(',')
|
||||
const criteriaYears = showCriteria ? getCriteriaYears(studyProgramme) : []
|
||||
const criteriaCourses = pendingCriteria?.source === criteria ? pendingCriteria.courses : criteria.courses
|
||||
|
||||
useEffect(() => {
|
||||
setModules(initialModules)
|
||||
}, [initialModules])
|
||||
|
||||
const getModule = (moduleCode: string) => modules.find(module => module.code === moduleCode)!
|
||||
const getModule = (moduleKey: string) => modules.find(module => getModuleKey(module) === moduleKey)!
|
||||
|
||||
const setModuleVisibility = (moduleCode: string, newVisibility: boolean) => {
|
||||
// Same course can be in many modules, so change them all
|
||||
const courseCodes = getModule(moduleCode).courses.map(course => course.code)
|
||||
setModules(
|
||||
modules.map(module => ({
|
||||
...module,
|
||||
courses: module.courses.map(course => {
|
||||
if (courseCodes.includes(course.code)) {
|
||||
return {
|
||||
...course,
|
||||
visible: { ...course.visible, visibility: newVisibility },
|
||||
const getModuleName = (module: DegreeModule) => {
|
||||
const firstCourse = module.courses[0]
|
||||
return firstCourse?.parent_name ? getTextIn(firstCourse.parent_name)! : module.code
|
||||
}
|
||||
|
||||
const setCoursesVisibility = (programmeCode: string, courseCodes: string[], newVisibility: boolean) => {
|
||||
// Same course can be in many modules of the programme, so change them all
|
||||
setModules(prevModules =>
|
||||
prevModules.map(module =>
|
||||
module.programmeCode !== programmeCode
|
||||
? module
|
||||
: {
|
||||
...module,
|
||||
courses: module.courses.map(course =>
|
||||
courseCodes.includes(course.code)
|
||||
? { ...course, visible: { ...course.visible, visibility: newVisibility } }
|
||||
: course
|
||||
),
|
||||
}
|
||||
}
|
||||
return course
|
||||
}),
|
||||
}))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const setCourseVisibility = (moduleCode: string, newVisibility: boolean) => {
|
||||
setModules(
|
||||
modules.map(module => ({
|
||||
...module,
|
||||
courses: module.courses.map(course => {
|
||||
if (course.code !== moduleCode) {
|
||||
return course
|
||||
}
|
||||
return {
|
||||
...course,
|
||||
visible: { ...course.visible, visibility: newVisibility },
|
||||
}
|
||||
}),
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
const areModuleCoursesVisible = (moduleCode: string) => {
|
||||
if (!moduleCoursesVisible[version]) {
|
||||
setModuleCoursesVisible(prev => ({ ...prev, [version]: {} }))
|
||||
return false
|
||||
const changeVisibility = (
|
||||
programmeCode: string,
|
||||
courses: ProgrammeCourse[],
|
||||
newVisibility: boolean,
|
||||
successMessage: string
|
||||
) => {
|
||||
const request = {
|
||||
courseGroupIds: courses.map(course => course.group_id),
|
||||
curriculumVersion: version,
|
||||
programmeCode,
|
||||
}
|
||||
return moduleCoursesVisible[version][moduleCode]
|
||||
const mutation = newVisibility ? removeExclusion(request) : setExclusion(request)
|
||||
const courseCodes = courses.map(course => course.code)
|
||||
setCoursesVisibility(programmeCode, courseCodes, newVisibility)
|
||||
mutation
|
||||
.unwrap()
|
||||
.then(() => setStatusNotification(`${successMessage} ${reloadNote}`, 'success'))
|
||||
.catch(() => {
|
||||
setCoursesVisibility(programmeCode, courseCodes, !newVisibility)
|
||||
setStatusNotification('Failed to change visibility', 'error')
|
||||
})
|
||||
}
|
||||
|
||||
const excludeOne = (course: ProgrammeCourse) => {
|
||||
void setExclusion({
|
||||
courseGroupIds: [course.group_id],
|
||||
curriculumVersion: version,
|
||||
programmeCode: combinedProgramme === '' ? studyProgramme : combinedProgramme,
|
||||
})
|
||||
setCourseVisibility(course.code, false)
|
||||
const toggleCourseVisibility = (module: DegreeModule, course: ProgrammeCourse) => {
|
||||
const newVisibility = !course.visible.visibility
|
||||
const name = getTextIn(course.name)
|
||||
changeVisibility(
|
||||
module.programmeCode,
|
||||
[course],
|
||||
newVisibility,
|
||||
`${name} is now ${newVisibility ? 'shown' : 'hidden'}.`
|
||||
)
|
||||
}
|
||||
|
||||
const excludeAll = (moduleCode: string) => {
|
||||
const module = getModule(moduleCode)
|
||||
const excludeFromProgramme = combinedProgramme === '' ? studyProgramme : combinedProgramme
|
||||
void setExclusion({
|
||||
courseGroupIds: module.courses.filter(course => course.visible.visibility).map(course => course.group_id),
|
||||
curriculumVersion: version,
|
||||
programmeCode: excludeFromProgramme,
|
||||
})
|
||||
setModuleVisibility(moduleCode, false)
|
||||
const toggleModuleVisibility = (module: DegreeModule) => {
|
||||
const visibility = getVisibility(module)
|
||||
|
||||
if (visibility === CourseVisibility.HIDDEN) {
|
||||
changeVisibility(
|
||||
module.programmeCode,
|
||||
module.courses,
|
||||
true,
|
||||
`All courses in ${getModuleName(module)} are now shown.`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (visibility === CourseVisibility.PARTIAL) {
|
||||
setModuleToHide(getModuleKey(module))
|
||||
return
|
||||
}
|
||||
hideModule(module)
|
||||
}
|
||||
|
||||
const removeOne = (course: ProgrammeCourse) => {
|
||||
void removeExclusion({
|
||||
courseGroupIds: [course.group_id],
|
||||
curriculumVersion: version,
|
||||
programmeCode: studyProgramme,
|
||||
})
|
||||
setCourseVisibility(course.code, true)
|
||||
const hideModule = (module: DegreeModule) => {
|
||||
const visibleCourses = module.courses.filter(course => course.visible.visibility)
|
||||
changeVisibility(
|
||||
module.programmeCode,
|
||||
visibleCourses,
|
||||
false,
|
||||
`All courses in ${getModuleName(module)} are now hidden.`
|
||||
)
|
||||
}
|
||||
|
||||
const deleteAll = (moduleCode: string) => {
|
||||
const module = getModule(moduleCode)
|
||||
void removeExclusion({
|
||||
courseGroupIds: module.courses.map(course => course.group_id),
|
||||
curriculumVersion: version,
|
||||
programmeCode: studyProgramme,
|
||||
})
|
||||
setModuleVisibility(moduleCode, true)
|
||||
const toggleCriteriaYear = (course: ProgrammeCourse, { key, year }: CriteriaYearOption) => {
|
||||
const current = criteriaCourses[key] ?? []
|
||||
const isRequired = current.includes(course.group_id)
|
||||
const courseGroupIds = isRequired
|
||||
? current.filter(groupId => groupId !== course.group_id)
|
||||
: [...current, course.group_id]
|
||||
const name = getTextIn(course.name)
|
||||
|
||||
setPendingCriteria({ courses: { ...criteriaCourses, [key]: courseGroupIds }, source: criteria })
|
||||
addProgressCriteriaCourse({ programmeCode: studyProgramme, courseGroupIds, year })
|
||||
.unwrap()
|
||||
.then(() =>
|
||||
setStatusNotification(
|
||||
isRequired ? `${name} removed from year ${year} criteria` : `${name} added to year ${year} criteria`,
|
||||
'success'
|
||||
)
|
||||
)
|
||||
.catch(() => {
|
||||
setPendingCriteria(null)
|
||||
setStatusNotification('Failed to update criteria', 'error')
|
||||
})
|
||||
}
|
||||
|
||||
const toggleVisible = (moduleCode: string) => {
|
||||
const newState = !areModuleCoursesVisible(moduleCode)
|
||||
setModuleCoursesVisible({
|
||||
...moduleCoursesVisible,
|
||||
[version]: { ...moduleCoursesVisible[version], [moduleCode]: newState },
|
||||
const isModuleExpanded = (moduleKey: string) => expandedModules[version]?.[moduleKey] ?? false
|
||||
|
||||
const toggleExpanded = (moduleKey: string) => {
|
||||
setExpandedModules({
|
||||
...expandedModules,
|
||||
[version]: { ...expandedModules[version], [moduleKey]: !isModuleExpanded(moduleKey) },
|
||||
})
|
||||
}
|
||||
|
||||
const getVisibility = (moduleCode: string) => {
|
||||
const module = getModule(moduleCode)
|
||||
if (!module.courses) {
|
||||
const getVisibility = (module: DegreeModule) => {
|
||||
if (!module.courses?.length) {
|
||||
return CourseVisibility.NO_COURSES
|
||||
}
|
||||
if (module.courses.every(course => course.visible.visibility)) {
|
||||
@@ -152,104 +211,164 @@ export const DegreeCourseTable = ({
|
||||
return CourseVisibility.PARTIAL
|
||||
}
|
||||
|
||||
const countRequired = (courses: ProgrammeCourse[], { key }: CriteriaYearOption) =>
|
||||
courses.filter(course => criteriaCourses[key]?.includes(course.group_id)).length
|
||||
|
||||
const headerRowSpan = showCriteria ? 2 : 1
|
||||
const columnCount = 2 + Number(showVisibility) + criteriaYears.length
|
||||
const showProgrammeRows = new Set(modules.map(module => module.programmeCode)).size > 1
|
||||
const pendingHideModule = moduleToHide ? getModule(moduleToHide) : null
|
||||
const remainingToHide = pendingHideModule?.courses.filter(course => course.visible.visibility).length ?? 0
|
||||
|
||||
return (
|
||||
<StyledTable showCellBorders data-cy="degree-course-table" size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Code</TableCell>
|
||||
<TableCell>Visibility</TableCell>
|
||||
{isBachelorOrLicentiateProgramme(studyProgramme) && <TableCell>Criterion labels</TableCell>}
|
||||
<TableCell colSpan={2}>Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{modules.map(({ code: moduleCode, courses }) => (
|
||||
<Fragment key={`${moduleCode}-fragment`}>
|
||||
<TableRow key={moduleCode}>
|
||||
<TableCell>
|
||||
<Box alignItems="center" display="flex" justifyContent="left">
|
||||
<IconButton onClick={() => toggleVisible(moduleCode)} size="small">
|
||||
{areModuleCoursesVisible(moduleCode) ? <KeyboardArrowDownIcon /> : <KeyboardArrowRightIcon />}
|
||||
</IconButton>
|
||||
<Typography fontWeight="bold" variant="body2">
|
||||
{courses[0] && courses[0].parent_name ? getTextIn(courses[0].parent_name) : moduleCode}
|
||||
</Typography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>{moduleCode}</TableCell>
|
||||
<TableCell>
|
||||
<VisibilityChip visibility={getVisibility(moduleCode)} />
|
||||
</TableCell>
|
||||
{isBachelorOrLicentiateProgramme(studyProgramme) && <TableCell />}
|
||||
<TableCell>
|
||||
<Stack direction="row" gap={1}>
|
||||
<ToggleVisibilityButton
|
||||
onClick={() =>
|
||||
getVisibility(moduleCode) === CourseVisibility.HIDDEN
|
||||
? deleteAll(moduleCode)
|
||||
: excludeAll(moduleCode)
|
||||
}
|
||||
visible={getVisibility(moduleCode) !== CourseVisibility.HIDDEN}
|
||||
/>
|
||||
{isBachelorOrLicentiateProgramme(studyProgramme) && (
|
||||
<IconButton disabled sx={{ visibility: 'hidden' }}>
|
||||
<FormatListNumberedIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</Stack>
|
||||
<>
|
||||
<StyledTable showCellBorders data-cy="degree-course-table" size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell rowSpan={headerRowSpan}>Name</TableCell>
|
||||
<TableCell rowSpan={headerRowSpan}>Code</TableCell>
|
||||
{showVisibility && <TableCell rowSpan={headerRowSpan}>Visibility</TableCell>}
|
||||
{showCriteria && (
|
||||
<TableCell align="center" colSpan={criteriaYears.length}>
|
||||
Required for year criteria
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
{showCriteria && (
|
||||
<TableRow>
|
||||
{criteriaYears.map(({ year }) => (
|
||||
<TableCell align="center" key={year}>
|
||||
Year {year}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
{areModuleCoursesVisible(moduleCode) &&
|
||||
courses
|
||||
.sort((a, b) => a.code.localeCompare(b.code))
|
||||
.map(course => (
|
||||
<TableRow key={`${moduleCode}/${course.code}`} sx={{ bgcolor: 'grey.50' }}>
|
||||
<TableCell>{getTextIn(course.name)}</TableCell>
|
||||
<TableCell>{course.code}</TableCell>
|
||||
)}
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{modules.map((module, index) => {
|
||||
const { code: moduleCode, courses, programmeCode } = module
|
||||
const moduleKey = getModuleKey(module)
|
||||
const moduleName = getModuleName(module)
|
||||
const expanded = isModuleExpanded(moduleKey)
|
||||
const startsProgramme = index === 0 || modules[index - 1].programmeCode !== programmeCode
|
||||
return (
|
||||
<Fragment key={moduleKey}>
|
||||
{showProgrammeRows && startsProgramme && (
|
||||
<TableRow data-cy={`programme-row-${programmeCode}`}>
|
||||
<TableCell colSpan={columnCount} sx={{ bgcolor: 'grey.100' }}>
|
||||
<Typography fontWeight="bold" variant="body2">
|
||||
{programmeNames[programmeCode] ?? programmeCode} ({programmeCode})
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
<TableRow hover onClick={() => toggleExpanded(moduleKey)} sx={{ cursor: 'pointer' }}>
|
||||
<TableCell>
|
||||
<Box alignItems="center" display="flex" justifyContent="left">
|
||||
<IconButton
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? `Hide courses of ${moduleName}` : `Show courses of ${moduleName}`}
|
||||
size="small"
|
||||
>
|
||||
{expanded ? <KeyboardArrowDownIcon /> : <KeyboardArrowRightIcon />}
|
||||
</IconButton>
|
||||
<Typography fontWeight="bold" variant="body2">
|
||||
{moduleName}
|
||||
</Typography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>{moduleCode}</TableCell>
|
||||
{showVisibility && (
|
||||
<TableCell>
|
||||
<VisibilityChip
|
||||
visibility={course.visible.visibility ? CourseVisibility.VISIBLE : CourseVisibility.HIDDEN}
|
||||
<VisibilitySwitch
|
||||
ariaLabel={`Show all courses of ${moduleName}`}
|
||||
cypress={`visibility-switch-${moduleCode}`}
|
||||
onChange={() => toggleModuleVisibility(module)}
|
||||
visibility={getVisibility(module)}
|
||||
/>
|
||||
</TableCell>
|
||||
{isBachelorOrLicentiateProgramme(studyProgramme) && (
|
||||
<TableCell>
|
||||
{Object.values(criteria.courses).length > 0 ? (
|
||||
<Stack direction="row" gap={1}>
|
||||
{Object.keys(criteria.courses).map(
|
||||
(year, index) =>
|
||||
criteria.courses[year].includes(course.group_id) && (
|
||||
<Chip color="primary" key={year} label={`year ${index + 1}`} size="small" />
|
||||
)
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
No labels set
|
||||
)}
|
||||
{criteriaYears.map(option => {
|
||||
const requiredCount = countRequired(courses, option)
|
||||
return (
|
||||
<TableCell align="center" key={option.year}>
|
||||
{requiredCount > 0 && (
|
||||
<Typography
|
||||
color="text.secondary"
|
||||
title={`${requiredCount} of ${courses.length} courses required for year ${option.year}`}
|
||||
variant="body2"
|
||||
>
|
||||
{requiredCount} / {courses.length}
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
<Stack direction="row" gap={1}>
|
||||
<ToggleVisibilityButton
|
||||
onClick={() => (course.visible.visibility ? excludeOne(course) : removeOne(course))}
|
||||
visible={course.visible.visibility}
|
||||
/>
|
||||
{isBachelorOrLicentiateProgramme(studyProgramme) && (
|
||||
<CriterionLabelSelectButton
|
||||
course={course}
|
||||
criteria={criteria}
|
||||
studyProgramme={studyProgramme}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</TableBody>
|
||||
</StyledTable>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
{expanded &&
|
||||
[...courses]
|
||||
.sort((a, b) => a.code.localeCompare(b.code))
|
||||
.map(course => {
|
||||
const courseName = getTextIn(course.name)
|
||||
return (
|
||||
<TableRow key={`${moduleKey}/${course.code}`} sx={{ bgcolor: 'grey.50' }}>
|
||||
<TableCell sx={{ pl: 6 }}>{courseName}</TableCell>
|
||||
<TableCell>{course.code}</TableCell>
|
||||
{showVisibility && (
|
||||
<TableCell>
|
||||
<VisibilitySwitch
|
||||
ariaLabel={`Show ${courseName}`}
|
||||
cypress={`visibility-switch-${course.code}`}
|
||||
onChange={() => toggleCourseVisibility(module, course)}
|
||||
visibility={
|
||||
course.visible.visibility ? CourseVisibility.VISIBLE : CourseVisibility.HIDDEN
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
{criteriaYears.map(option => (
|
||||
<TableCell align="center" key={option.year} padding="checkbox">
|
||||
<Checkbox
|
||||
checked={criteriaCourses[option.key]?.includes(course.group_id) ?? false}
|
||||
data-cy={`criteria-checkbox-${option.year}-${course.code}`}
|
||||
inputProps={{ 'aria-label': `${courseName} required for year ${option.year}` }}
|
||||
onChange={() => toggleCriteriaYear(course, option)}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</StyledTable>
|
||||
<Dialog onClose={() => setModuleToHide(null)} open={moduleToHide !== null}>
|
||||
<DialogTitle>Hide the whole module?</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
{pendingHideModule &&
|
||||
`Some courses in ${getModuleName(pendingHideModule)} are already hidden. ` +
|
||||
`Hiding the module hides its remaining ${remainingToHide} ` +
|
||||
`${remainingToHide === 1 ? 'course' : 'courses'} for all Oodikone users.`}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setModuleToHide(null)}>Cancel</Button>
|
||||
<Button
|
||||
data-cy="confirm-hide-module-button"
|
||||
onClick={() => {
|
||||
hideModule(pendingHideModule!)
|
||||
setModuleToHide(null)
|
||||
}}
|
||||
variant="contained"
|
||||
>
|
||||
Hide module
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ const textContent = {
|
||||
export const ManageCoursesShownInfo = () => {
|
||||
const { getTextIn } = useLanguage()
|
||||
return (
|
||||
<StyledMessage showIcon variant="outlined" severity="warning" sx={{ mb: 2, maxWidth: '100%' }}>
|
||||
<StyledMessage showIcon variant="outlined" severity="warning" sx={{ maxWidth: '100%' }}>
|
||||
{getTextIn(textContent)}
|
||||
</StyledMessage>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { isMedicalProgramme } from '@/util/studyProgramme'
|
||||
import { ProgressCriteria } from '@oodikone/shared/types'
|
||||
|
||||
export type CriteriaYearKey = keyof ProgressCriteria['courses']
|
||||
|
||||
export type CriteriaYearOption = {
|
||||
key: CriteriaYearKey
|
||||
label: string
|
||||
year: number
|
||||
}
|
||||
|
||||
const allCriteriaYears: CriteriaYearOption[] = [
|
||||
{ key: 'yearOne', label: 'First', year: 1 },
|
||||
{ key: 'yearTwo', label: 'Second', year: 2 },
|
||||
{ key: 'yearThree', label: 'Third', year: 3 },
|
||||
{ key: 'yearFour', label: 'Fourth', year: 4 },
|
||||
{ key: 'yearFive', label: 'Fifth', year: 5 },
|
||||
{ key: 'yearSix', label: 'Sixth', year: 6 },
|
||||
]
|
||||
|
||||
/** Academic years that can have progress criteria in the given programme (six for medicine, otherwise three) */
|
||||
export const getCriteriaYears = (programmeCode: string) =>
|
||||
isMedicalProgramme(programmeCode) ? allCriteriaYears : allCriteriaYears.slice(0, 3)
|
||||
@@ -1,93 +1,148 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Tab from '@mui/material/Tab'
|
||||
import Tabs from '@mui/material/Tabs'
|
||||
|
||||
import { orderBy } from 'lodash-es'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { useCurriculumState } from '@/hooks/useCurriculums'
|
||||
import { useCurriculum } from '@/hooks/useCurriculum'
|
||||
import { CreditCriteriaSection } from '@/pages/StudyProgramme/DegreeCoursesTab/CreditCriteriaSection'
|
||||
import { CurriculumSection } from '@/pages/StudyProgramme/DegreeCoursesTab/CurriculumSection'
|
||||
import { DegreeCourseTable } from '@/pages/StudyProgramme/DegreeCoursesTab/DegreeCourseTable'
|
||||
import { DegreeCourseTable, DegreeModule } from '@/pages/StudyProgramme/DegreeCoursesTab/DegreeCourseTable'
|
||||
import { ManageCoursesShownInfo } from '@/pages/StudyProgramme/DegreeCoursesTab/InfoBox'
|
||||
import { useGetProgrammesQuery } from '@/redux/populations'
|
||||
import { useGetProgressCriteriaQuery } from '@/redux/progressCriteria'
|
||||
import { isBachelorOrLicentiateProgramme } from '@/util/studyProgramme'
|
||||
import { Module, ProgrammeCourse } from '@oodikone/shared/types'
|
||||
import { ProgrammeCourse } from '@oodikone/shared/types'
|
||||
import { getCombinedProgramme } from '@oodikone/shared/util/studyProgramme'
|
||||
import { Section } from '@/components/Section'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { studyProgrammeToolTips } from '@/common/InfoToolTips'
|
||||
|
||||
export type DegreeSettingsTab = 'criteria' | 'visibility'
|
||||
|
||||
const getModules = (courses: ProgrammeCourse[], programmeCode: string): DegreeModule[] => {
|
||||
const modules: Record<string, ProgrammeCourse[]> = {}
|
||||
courses.forEach(course => {
|
||||
const code = course.parent_code!
|
||||
modules[code] ??= []
|
||||
modules[code].push(course)
|
||||
})
|
||||
|
||||
return orderBy(
|
||||
Object.entries(modules).map(([code, courses]) => ({
|
||||
code,
|
||||
courses,
|
||||
order: courses[0].module_order,
|
||||
programmeCode,
|
||||
})),
|
||||
module => module.code
|
||||
)
|
||||
}
|
||||
|
||||
const sectionTexts = {
|
||||
criteria: {
|
||||
title: 'Course completion academic year targets',
|
||||
toolTip: studyProgrammeToolTips.degreeCoursesTab.requiredCourses,
|
||||
},
|
||||
visibility: {
|
||||
title: 'Alter course and module visibility',
|
||||
toolTip: studyProgrammeToolTips.degreeCoursesTab.courseVisibility,
|
||||
},
|
||||
}
|
||||
|
||||
export const DegreeCoursesTab = ({
|
||||
combinedProgramme,
|
||||
degreeProgramme,
|
||||
initialTab = 'criteria',
|
||||
}: {
|
||||
combinedProgramme: string
|
||||
degreeProgramme: string
|
||||
initialTab?: DegreeSettingsTab
|
||||
}) => {
|
||||
const [defaultProgrammeModules, setDefaultProgrammeModules] = useState<Module[]>([])
|
||||
const [secondProgrammeModules, setSecondProgrammeModules] = useState<Module[]>([])
|
||||
const [curriculum, curriculumList, setCurriculum] = useCurriculumState(degreeProgramme, new Date().getFullYear())
|
||||
const [currentTab, setCurrentTab] = useState<DegreeSettingsTab>(initialTab)
|
||||
const { data: criteria } = useGetProgressCriteriaQuery({ programmeCode: degreeProgramme })
|
||||
const { data: programmes } = useGetProgrammesQuery()
|
||||
const { getTextIn } = useLanguage()
|
||||
const { curriculum, curriculumList, setSelectedCurriculum: setCurriculum } = useCurriculum()
|
||||
const secondProgramme = getCombinedProgramme(degreeProgramme)
|
||||
|
||||
const getModules = (courses: ProgrammeCourse[]): Module[] => {
|
||||
const modules: Record<string, ProgrammeCourse[]> = {}
|
||||
courses.forEach(course => {
|
||||
const code = course.parent_code!
|
||||
modules[code] ??= []
|
||||
modules[code].push(course)
|
||||
})
|
||||
// A combined programme has courses from two curriculums. Show them in one table, first programme's modules first.
|
||||
const modules = useMemo(
|
||||
() =>
|
||||
curriculum
|
||||
? [
|
||||
...getModules(curriculum.defaultProgrammeCourses ?? [], degreeProgramme),
|
||||
...(secondProgramme ? getModules(curriculum.secondProgrammeCourses ?? [], secondProgramme) : []),
|
||||
]
|
||||
: [],
|
||||
[curriculum, degreeProgramme, secondProgramme]
|
||||
)
|
||||
|
||||
return orderBy(
|
||||
Object.entries(modules).map(([code, courses]) => ({
|
||||
code,
|
||||
courses,
|
||||
order: courses[0].module_order,
|
||||
})),
|
||||
module => module.code
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!curriculum?.defaultProgrammeCourses?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
setDefaultProgrammeModules(getModules(curriculum.defaultProgrammeCourses))
|
||||
if (combinedProgramme) {
|
||||
setSecondProgrammeModules(getModules(curriculum.secondProgrammeCourses))
|
||||
}
|
||||
}, [combinedProgramme, curriculum])
|
||||
const programmeNames = Object.fromEntries(
|
||||
[degreeProgramme, secondProgramme]
|
||||
.filter(code => code !== undefined)
|
||||
.map(code => {
|
||||
const programme = programmes?.allProgrammes?.[code] ?? programmes?.filteredProgrammes?.[code]
|
||||
return [code, programme ? getTextIn(programme.name)! : code]
|
||||
})
|
||||
)
|
||||
|
||||
if (!curriculum || !criteria) return null
|
||||
|
||||
const hasCriteria = isBachelorOrLicentiateProgramme(degreeProgramme)
|
||||
const renderCourseTable = (tab: DegreeSettingsTab) =>
|
||||
!!modules.length && (
|
||||
<Section infoBoxContent={getTextIn(sectionTexts[tab].toolTip) ?? ''} title={sectionTexts[tab].title}>
|
||||
<DegreeCourseTable
|
||||
criteria={criteria}
|
||||
curriculumVersion={curriculum.periodIds}
|
||||
modules={modules}
|
||||
programmeNames={programmeNames}
|
||||
showCriteria={tab === 'criteria'}
|
||||
showVisibility={tab === 'visibility'}
|
||||
studyProgramme={degreeProgramme}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
|
||||
const curriculumSection = (
|
||||
<CurriculumSection curriculum={curriculum} curriculumList={curriculumList} setCurriculum={setCurriculum} />
|
||||
)
|
||||
|
||||
const visibilityPanel = (
|
||||
<Stack gap={2}>
|
||||
{curriculumSection}
|
||||
{renderCourseTable('visibility')}
|
||||
</Stack>
|
||||
)
|
||||
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<CurriculumSection curriculum={curriculum} curriculumList={curriculumList} setCurriculum={setCurriculum} />
|
||||
{isBachelorOrLicentiateProgramme(degreeProgramme) && (
|
||||
<CreditCriteriaSection criteria={criteria} degreeProgramme={degreeProgramme} />
|
||||
)}
|
||||
{curriculum && (!!defaultProgrammeModules.length || !!secondProgrammeModules.length) && (
|
||||
<Section
|
||||
title="Course & module criterion and visibility"
|
||||
infoBoxContent={getTextIn(studyProgrammeToolTips.degreeCoursesTab.programmeCriteriaAndVisibility) ?? ''}
|
||||
>
|
||||
{!!defaultProgrammeModules.length && (
|
||||
<DegreeCourseTable
|
||||
combinedProgramme=""
|
||||
criteria={criteria}
|
||||
curriculumVersion={curriculum.periodIds}
|
||||
modules={defaultProgrammeModules}
|
||||
studyProgramme={degreeProgramme}
|
||||
/>
|
||||
)}
|
||||
{!!secondProgrammeModules.length && (
|
||||
<DegreeCourseTable
|
||||
combinedProgramme={combinedProgramme}
|
||||
criteria={criteria}
|
||||
curriculumVersion={curriculum.periodIds}
|
||||
modules={secondProgrammeModules}
|
||||
studyProgramme={degreeProgramme}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
<ManageCoursesShownInfo />
|
||||
{hasCriteria ? (
|
||||
<>
|
||||
<Tabs
|
||||
data-cy="degree-settings-tabs"
|
||||
onChange={(_event, newTab: DegreeSettingsTab) => setCurrentTab(newTab)}
|
||||
value={currentTab}
|
||||
>
|
||||
<Tab data-cy="criteria-tab" label="Progress tab criteria" value="criteria" />
|
||||
<Tab data-cy="visibility-tab" label="Course & module visibility" value="visibility" />
|
||||
</Tabs>
|
||||
{/* Both panels stay mounted so unsaved table state (visibility, expanded modules) survives tab switches */}
|
||||
<Box data-cy="criteria-panel" hidden={currentTab !== 'criteria'}>
|
||||
<Stack gap={2}>
|
||||
{curriculumSection}
|
||||
<CreditCriteriaSection criteria={criteria} degreeProgramme={degreeProgramme} />
|
||||
{renderCourseTable('criteria')}
|
||||
</Stack>
|
||||
</Box>
|
||||
<Box data-cy="visibility-panel" hidden={currentTab !== 'visibility'}>
|
||||
{visibilityPanel}
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Box data-cy="visibility-panel">{visibilityPanel}</Box>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { PageTitle } from '@/components/common/PageTitle'
|
||||
import { GetTextIn, useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { useTabs } from '@/hooks/tabs'
|
||||
import { CurriculumProvider } from '@/hooks/useCurriculum'
|
||||
import { useTitle } from '@/hooks/title'
|
||||
import { BasicInformationTab } from '@/pages/StudyProgramme/BasicInformationTab'
|
||||
import { DegreeCoursesTab } from '@/pages/StudyProgramme/DegreeCoursesTab'
|
||||
@@ -134,13 +135,11 @@ export const StudyProgramme = () => {
|
||||
tabs.push(
|
||||
{
|
||||
key: 'DegreeCoursesTab',
|
||||
label: 'Degree courses',
|
||||
label: 'Degree programme settings',
|
||||
component: (
|
||||
<DegreeCoursesTab
|
||||
combinedProgramme={secondProgrammeId}
|
||||
degreeProgramme={programmeId}
|
||||
key="DegreeCoursesTabContent"
|
||||
/>
|
||||
<CurriculumProvider key="DegreeCoursesTabContent" programmeCode={programmeId} year={new Date().getFullYear()}>
|
||||
<DegreeCoursesTab degreeProgramme={programmeId} />
|
||||
</CurriculumProvider>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -32,7 +32,7 @@ export const UserPage = ({ userId }: { userId: string }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout maxWidth="lg">
|
||||
<PageLayout maxWidth="md" sx={{ width: '100%' }}>
|
||||
<Stack direction="column" gap={2}>
|
||||
<MissingIdAlert visible={!user.sisPersonId} />
|
||||
<InfoCard user={user} />
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import Button from '@mui/material/Button'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
|
||||
import { CopyAllIcon } from '@/theme'
|
||||
|
||||
export const CopyEmailAddressesButton = ({ userEmails }: { userEmails: string[] }) => {
|
||||
const copyEmailsToClipboard = () => {
|
||||
void navigator.clipboard.writeText(userEmails.join('; '))
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip arrow placement="right" title="Copy the email addresses of all users to clipboard">
|
||||
<Button
|
||||
data-cy="copy-email-addresses-button"
|
||||
onClick={copyEmailsToClipboard}
|
||||
startIcon={<CopyAllIcon />}
|
||||
variant="contained"
|
||||
>
|
||||
Copy email addresses
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import Button from '@mui/material/Button'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
|
||||
import { useStatusNotification } from '@/components/StatusNotification/Context'
|
||||
import { ContentCopyIcon } from '@/theme'
|
||||
import { User } from '@/types/api/users'
|
||||
|
||||
export const CopyEmailAddressesButton = ({ users }: { users: User[] }) => {
|
||||
const { setStatusNotification, closeNotification } = useStatusNotification()
|
||||
|
||||
const copyEmailsToClipboard = async () => {
|
||||
const emails = users.map(user => user.email).filter(Boolean)
|
||||
await navigator.clipboard.writeText(emails.join('; '))
|
||||
setStatusNotification(`Copied ${emails.length} email addresses`)
|
||||
setTimeout(() => closeNotification(), 5000)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip arrow placement="right" title="Copy the email addresses of the listed users to clipboard">
|
||||
<Button
|
||||
data-cy="copy-email-addresses-button"
|
||||
onClick={() => void copyEmailsToClipboard()}
|
||||
startIcon={<ContentCopyIcon />}
|
||||
variant="contained"
|
||||
>
|
||||
Copy email addresses
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { SearchIcon, theme } from '@/theme'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import { useState } from 'react'
|
||||
|
||||
export const NameSearch = ({ setFilter }: { setFilter: React.Dispatch<React.SetStateAction<string>> }) => {
|
||||
const [textField, setTextField] = useState('')
|
||||
|
||||
return (
|
||||
<TextField
|
||||
label="Search by name or username"
|
||||
onChange={event => {
|
||||
setTextField(event.target.value)
|
||||
setFilter(event.target.value)
|
||||
}}
|
||||
size="small"
|
||||
slotProps={{
|
||||
input: { endAdornment: <SearchIcon fontSize="small" htmlColor={theme.palette.grey[700]} sx={{ ml: 2 }} /> },
|
||||
}}
|
||||
value={textField}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import Typography from '@mui/material/Typography'
|
||||
|
||||
export const StatusMessage = ({ isError, isLoading }: { isError: boolean; isLoading: boolean }) => {
|
||||
if (isError) {
|
||||
return (
|
||||
<Typography color="error" variant="body1">
|
||||
Failed to load users, try again
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Typography color="info" variant="body1">
|
||||
Loading users...
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,40 +1,36 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import Chip from '@mui/material/Chip'
|
||||
import Stack from '@mui/material/Stack'
|
||||
|
||||
import { MaterialReactTable, MRT_ColumnDef, useMaterialReactTable } from 'material-react-table'
|
||||
import { createColumnHelper, getFilteredRowModel, type TableOptions } from '@tanstack/react-table'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
|
||||
import { Link } from '@/components/common/Link'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { OodiTable } from '@/components/OodiTable'
|
||||
import { LoadingSkeleton } from '@/components/Section/LoadingSkeleton'
|
||||
import { MockButton } from '@/components/Users/MockButton'
|
||||
import { RoleChip } from '@/components/Users/RoleChip'
|
||||
import { DateFormat } from '@/constants/date'
|
||||
import { CopyEmailAddressesButton } from '@/pages/Users/UsersTable/CopyEmailAddressesButton'
|
||||
import { StatusMessage } from '@/pages/Users/UsersTable/StatusMessage'
|
||||
import { useDebouncedState } from '@/hooks/debouncedState'
|
||||
import { CopyEmailAddressesButton } from '@/pages/Users/UsersTable/EmailCopy'
|
||||
import { NameSearch } from '@/pages/Users/UsersTable/NameSearch'
|
||||
import { useGetProgrammesQuery } from '@/redux/populations'
|
||||
import { useGetRolesQuery } from '@/redux/users'
|
||||
import { User } from '@/types/api/users'
|
||||
import { reformatDate } from '@/util/timeAndDate'
|
||||
import { DetailedProgrammeRights, Role } from '@oodikone/shared/types'
|
||||
import { DetailedProgrammeRights } from '@oodikone/shared/types'
|
||||
|
||||
export const UsersTable = ({
|
||||
getAllUsersQuery,
|
||||
isError,
|
||||
isLoading,
|
||||
users,
|
||||
}: {
|
||||
getAllUsersQuery: any // TODO: What is the type?
|
||||
isError: boolean
|
||||
isLoading: boolean
|
||||
users: User[]
|
||||
}) => {
|
||||
const columnHelper = createColumnHelper<User>()
|
||||
|
||||
const matchesSearch = (user: User, search: string) => {
|
||||
const lowerCaseSearch = search.toLowerCase()
|
||||
return user.name.toLowerCase().includes(lowerCaseSearch) || user.username.toLowerCase().includes(lowerCaseSearch)
|
||||
}
|
||||
|
||||
export const UsersTable = ({ isLoading, users }: { isLoading: boolean; users: User[] | undefined }) => {
|
||||
'use memo'
|
||||
const { getTextIn } = useLanguage()
|
||||
const { data: roles = [] } = useGetRolesQuery()
|
||||
const { data } = useGetProgrammesQuery()
|
||||
const studyProgrammes = data?.filteredProgrammes ?? {}
|
||||
|
||||
const iamGroups = [...new Set(users.map(user => user.iamGroups).flat())]
|
||||
const studyProgrammes = useMemo(() => data?.filteredProgrammes ?? {}, [data])
|
||||
|
||||
const formatProgrammeRights = useCallback(
|
||||
(programmeRights: DetailedProgrammeRights[]) => {
|
||||
@@ -57,29 +53,26 @@ export const UsersTable = ({
|
||||
[getTextIn, studyProgrammes]
|
||||
)
|
||||
|
||||
const columns = useMemo<MRT_ColumnDef<any>[]>(
|
||||
const ooditableColumns = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
columnHelper.accessor('name', {
|
||||
header: 'Name',
|
||||
Cell: ({ cell }) => cell.getValue<string>(),
|
||||
},
|
||||
{
|
||||
accessorKey: 'username',
|
||||
cell: cell => cell.getValue(),
|
||||
}),
|
||||
columnHelper.accessor('username', {
|
||||
header: 'Username',
|
||||
Cell: ({ cell, row }) => (
|
||||
<Link data-cy={`user-page-button-${row.original.username}`} to={`/users/${row.original.id}`}>
|
||||
{cell.getValue<string>()}
|
||||
cell: cell => (
|
||||
<Link data-cy={`user-page-button-${cell.row.original.username}`} to={`/users/${cell.row.original.id}`}>
|
||||
{cell.getValue()}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'roles',
|
||||
}),
|
||||
columnHelper.accessor('roles', {
|
||||
header: 'Roles',
|
||||
Cell: ({ cell }) => (
|
||||
<Box display="flex" flexWrap="wrap" gap={1}>
|
||||
cell: cell => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, my: 1 }}>
|
||||
{cell
|
||||
.getValue<Role[]>()
|
||||
.getValue()
|
||||
.toSorted((a, b) => a.localeCompare(b))
|
||||
.map(role => (
|
||||
<RoleChip key={role} role={role} />
|
||||
@@ -88,22 +81,18 @@ export const UsersTable = ({
|
||||
),
|
||||
enableSorting: false,
|
||||
size: 350,
|
||||
filterVariant: 'multi-select',
|
||||
filterSelectOptions: roles,
|
||||
},
|
||||
{
|
||||
accessorKey: 'programmeRights',
|
||||
}),
|
||||
columnHelper.accessor('programmeRights', {
|
||||
header: 'Programmes',
|
||||
Cell: ({ cell }) => formatProgrammeRights(cell.getValue<DetailedProgrammeRights[]>()),
|
||||
cell: cell => formatProgrammeRights(cell.getValue()),
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'iamGroups',
|
||||
}),
|
||||
columnHelper.accessor('iamGroups', {
|
||||
header: 'IAM groups',
|
||||
Cell: ({ cell }) => (
|
||||
<Box display="flex" flexWrap="wrap" gap={1}>
|
||||
cell: cell => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, my: 1 }}>
|
||||
{cell
|
||||
.getValue<string[]>()
|
||||
.getValue()
|
||||
.toSorted((a, b) => a.localeCompare(b))
|
||||
.map(iamGroup => (
|
||||
<Chip key={iamGroup} label={iamGroup} size="small" />
|
||||
@@ -112,63 +101,48 @@ export const UsersTable = ({
|
||||
),
|
||||
enableSorting: false,
|
||||
size: 350,
|
||||
filterVariant: 'multi-select',
|
||||
filterSelectOptions: iamGroups,
|
||||
},
|
||||
{
|
||||
accessorKey: 'lastLogin',
|
||||
}),
|
||||
columnHelper.accessor('lastLogin', {
|
||||
header: 'Last login',
|
||||
Cell: ({ cell }) => reformatDate(cell.getValue<string>(), DateFormat.DISPLAY_DATE),
|
||||
enableColumnFilter: false,
|
||||
},
|
||||
{
|
||||
cell: cell => reformatDate(cell.getValue(), DateFormat.DISPLAY_DATE),
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
Cell: ({ row }) => (
|
||||
cell: cell => (
|
||||
<Stack direction="row" gap={1}>
|
||||
<MockButton username={row.original.username} />
|
||||
<MockButton username={cell.row.original.username} />
|
||||
</Stack>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
[formatProgrammeRights, getAllUsersQuery, iamGroups, roles]
|
||||
[formatProgrammeRights]
|
||||
)
|
||||
|
||||
const table = useMaterialReactTable({
|
||||
columns,
|
||||
data: users,
|
||||
defaultColumn: { size: 0 },
|
||||
enableBottomToolbar: false,
|
||||
enableColumnOrdering: false,
|
||||
enableDensityToggle: false,
|
||||
enableHiding: false,
|
||||
enablePagination: false,
|
||||
initialState: {
|
||||
showGlobalFilter: true,
|
||||
},
|
||||
const [filter, setFilter] = useDebouncedState('', 250)
|
||||
|
||||
const ooditable: Partial<TableOptions<User>> = {
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
globalFilterFn: (row, _, filterValue) => matchesSearch(row.original, String(filterValue)),
|
||||
state: {
|
||||
columnOrder: ['name', 'username', 'roles', 'programmeRights', 'iamGroups', 'lastLogin', 'actions'],
|
||||
columnVisibility: {
|
||||
name: true,
|
||||
username: true,
|
||||
roles: true,
|
||||
programmeRights: true,
|
||||
iamGroups: true,
|
||||
lastLogin: true,
|
||||
},
|
||||
globalFilter: filter,
|
||||
},
|
||||
renderTopToolbarCustomActions: ({ table }) => {
|
||||
const visibleEmails = table.getPrePaginationRowModel().rows.map(row => row.original.email)
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack alignItems="center" direction="row" gap={1}>
|
||||
<CopyEmailAddressesButton userEmails={visibleEmails} />
|
||||
<StatusMessage isError={isError} isLoading={isLoading} />
|
||||
</Stack>
|
||||
)
|
||||
},
|
||||
})
|
||||
if (isLoading || !users) return <LoadingSkeleton />
|
||||
|
||||
return <MaterialReactTable table={table} />
|
||||
return (
|
||||
<OodiTable
|
||||
columns={ooditableColumns}
|
||||
data={users}
|
||||
options={ooditable}
|
||||
toolbarContent={table => (
|
||||
<>
|
||||
<CopyEmailAddressesButton users={table.getFilteredRowModel().rows.map(row => row.original)} />
|
||||
<NameSearch setFilter={setFilter} />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Stack from '@mui/material/Stack'
|
||||
import { useEffect } from 'react'
|
||||
import { useParams } from 'react-router'
|
||||
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
@@ -7,28 +6,18 @@ import { PageTitle } from '@/components/common/PageTitle'
|
||||
import { useTitle } from '@/hooks/title'
|
||||
import { UserPage } from '@/pages/Users/UserPage'
|
||||
import { UsersTable } from '@/pages/Users/UsersTable'
|
||||
import { useLazyGetAllUsersQuery } from '@/redux/users'
|
||||
import { useGetAllUsersQuery } from '@/redux/users'
|
||||
|
||||
export const Users = () => {
|
||||
useTitle('Users')
|
||||
const { userid } = useParams()
|
||||
const [getAllUsersQuery, { data: users = [], isLoading, isError }] = useLazyGetAllUsersQuery()
|
||||
|
||||
useEffect(() => {
|
||||
if (!userid) {
|
||||
void getAllUsersQuery()
|
||||
}
|
||||
}, [userid, getAllUsersQuery])
|
||||
const { data: users, isLoading } = useGetAllUsersQuery(undefined, { skip: !!userid })
|
||||
|
||||
return (
|
||||
<PageLayout maxWidth="lg">
|
||||
<PageTitle title="Users" />
|
||||
<Stack gap={2}>
|
||||
{userid ? (
|
||||
<UserPage userId={userid} />
|
||||
) : (
|
||||
<UsersTable getAllUsersQuery={getAllUsersQuery} isError={isError} isLoading={isLoading} users={users} />
|
||||
)}
|
||||
{userid ? <UserPage userId={userid} /> : <UsersTable isLoading={isLoading} users={users} />}
|
||||
</Stack>
|
||||
</PageLayout>
|
||||
)
|
||||
|
||||
@@ -7,9 +7,7 @@ import type { FormattedStudent, ProgressCriteria, Unarray } from '@oodikone/shar
|
||||
type PopulationCourseStatsEnrollment = Omit<Unarray<PopulationCourseStats['enrollments']>, 'studentnumber'>
|
||||
export type PopulationCourseStatsCredit = Omit<Unarray<PopulationCourseStats['credits']>, 'student_studentnumber'>
|
||||
|
||||
export interface ExpandedCourseStats extends PopulationCourseStats {
|
||||
dataByStudent: Map<string, [PopulationCourseStatsEnrollment[], PopulationCourseStatsCredit[]]>
|
||||
}
|
||||
type DataByStudent = Map<string, [PopulationCourseStatsEnrollment[], PopulationCourseStatsCredit[]]>
|
||||
|
||||
type RequiredFields = {
|
||||
students: Omit<FormattedStudent, 'criteriaProgress' | 'courses' | 'enrollments'>[]
|
||||
@@ -19,7 +17,7 @@ type RequiredFields = {
|
||||
|
||||
export type Output<T> = T & {
|
||||
students: FormattedStudent[]
|
||||
coursestatistics: ExpandedCourseStats
|
||||
coursestatistics: PopulationCourseStats
|
||||
}
|
||||
|
||||
export const formatPopulationData = <T extends RequiredFields>(
|
||||
@@ -38,9 +36,7 @@ export const formatPopulationData = <T extends RequiredFields>(
|
||||
)
|
||||
|
||||
const studentNumbers = students.map(({ studentNumber }) => studentNumber)
|
||||
const creditsAndEnrollmentsByStudent: ExpandedCourseStats['dataByStudent'] = new Map(
|
||||
studentNumbers.map(n => [n, [[], []]])
|
||||
)
|
||||
const creditsAndEnrollmentsByStudent: DataByStudent = new Map(studentNumbers.map(n => [n, [[], []]]))
|
||||
|
||||
for (const enrollment of enrollments) {
|
||||
const { studentnumber, ...rest } = enrollment
|
||||
@@ -71,6 +67,7 @@ export const formatPopulationData = <T extends RequiredFields>(
|
||||
return {
|
||||
course_id: credit.course_id,
|
||||
course_code: credit.course_code,
|
||||
courseGroupId: credit.courseGroupId,
|
||||
date: credit.attainment_date,
|
||||
passed,
|
||||
grade: passed ? credit.grade : 'Hyl.',
|
||||
@@ -89,7 +86,6 @@ export const formatPopulationData = <T extends RequiredFields>(
|
||||
students: formattedStudents,
|
||||
coursestatistics: {
|
||||
...coursestatistics,
|
||||
dataByStudent: creditsAndEnrollmentsByStudent,
|
||||
},
|
||||
...otherParams,
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ const usersApi = RTKApi.injectEndpoints({
|
||||
export const {
|
||||
useGetUserQuery,
|
||||
useGetRolesQuery,
|
||||
useLazyGetAllUsersQuery,
|
||||
useGetAllUsersQuery,
|
||||
useModifyRolesMutation,
|
||||
useAddUserUnitsMutation,
|
||||
useRemoveUserUnitsMutation,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ExpandedCourseStats } from '@/redux/populations/util'
|
||||
import { CourseStats } from '@oodikone/shared/routes/populations'
|
||||
import type { CourseStats, PopulationCourseStats } from '@oodikone/shared/routes/populations'
|
||||
import {
|
||||
CreditTypeCode,
|
||||
EnrollmentState,
|
||||
@@ -230,7 +229,7 @@ const courseBaseStats = {
|
||||
}
|
||||
|
||||
export const filterCourses = (
|
||||
courseStatistics: ExpandedCourseStats | undefined,
|
||||
courseStatistics: PopulationCourseStats | undefined,
|
||||
filteredStudents: FormattedStudent[]
|
||||
): FilteredCourse[] => {
|
||||
if (!courseStatistics) return []
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { test, expect } from '@playwright/experimental-ct-react'
|
||||
|
||||
import { DegreeCourseTableWrapper } from './DegreeCourseTableWrapper'
|
||||
|
||||
test.describe('DegreeCourseTable', () => {
|
||||
let criteriaRequests: unknown[]
|
||||
let exclusionRequests: { method: string; body: unknown }[]
|
||||
|
||||
test.beforeEach(async ({ router }) => {
|
||||
criteriaRequests = []
|
||||
exclusionRequests = []
|
||||
await router.route('**/api/login', route => route.fulfill({ json: { user: { roles: [] } } }))
|
||||
await router.route('**/api/banners', route => route.fulfill({ json: [] }))
|
||||
await router.route('**/api/programme-criteria/courses', route => {
|
||||
criteriaRequests.push(route.request().postDataJSON())
|
||||
return route.fulfill({ json: {} })
|
||||
})
|
||||
await router.route('**/api/course-exclusions/', route => {
|
||||
exclusionRequests.push({ method: route.request().method(), body: route.request().postDataJSON() })
|
||||
return route.fulfill({ json: [] })
|
||||
})
|
||||
})
|
||||
|
||||
test('shows year columns and required course counts instead of criterion labels', async ({ mount }) => {
|
||||
const component = await mount(<DegreeCourseTableWrapper />)
|
||||
|
||||
await expect(component.getByRole('columnheader')).toHaveText([
|
||||
'Name',
|
||||
'Code',
|
||||
'Visibility',
|
||||
'Required for year criteria',
|
||||
'Year 1',
|
||||
'Year 2',
|
||||
'Year 3',
|
||||
])
|
||||
await expect(component.getByText('1 / 2')).toBeVisible()
|
||||
await expect(component.locator('[data-cy^="programme-row-"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('combined programme shows both curriculums in one table, first programme first', async ({ mount, page }) => {
|
||||
const component = await mount(<DegreeCourseTableWrapper combined studyProgramme="KH90_001" />)
|
||||
|
||||
await expect(page.locator('[data-cy="degree-course-table"]')).toHaveCount(1)
|
||||
await expect(component.getByRole('columnheader', { name: 'Year 6' })).toBeVisible()
|
||||
await expect(component.locator('tbody tr')).toHaveText([
|
||||
'First programme (KH90_001)',
|
||||
/^Basic studies/,
|
||||
'Second programme (MH90_001)',
|
||||
/^Clinical studies/,
|
||||
])
|
||||
})
|
||||
|
||||
test('hiding a course of the second programme only hides it there', async ({ mount }) => {
|
||||
const component = await mount(<DegreeCourseTableWrapper combined showCriteria={false} studyProgramme="KH90_001" />)
|
||||
|
||||
await component.getByText('Clinical studies').click()
|
||||
await component.getByRole('checkbox', { name: 'Show Calculus I' }).uncheck()
|
||||
await component.getByText('Basic studies').click()
|
||||
|
||||
const calculusSwitches = component.getByRole('checkbox', { name: 'Show Calculus I' })
|
||||
await expect(calculusSwitches.first()).toBeChecked()
|
||||
await expect(calculusSwitches.last()).not.toBeChecked()
|
||||
await expect
|
||||
.poll(() => exclusionRequests)
|
||||
.toEqual([
|
||||
{
|
||||
method: 'POST',
|
||||
body: { courseGroupIds: ['group-MAT101'], curriculumVersion: 'period-1', programmeCode: 'MH90_001' },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('shows six years for medicine', async ({ mount }) => {
|
||||
const component = await mount(<DegreeCourseTableWrapper studyProgramme="KH90_001" />)
|
||||
|
||||
await expect(component.getByRole('columnheader', { name: 'Year 6' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('checking a year saves the criteria once', async ({ mount }) => {
|
||||
const component = await mount(<DegreeCourseTableWrapper />)
|
||||
await component.getByText('Basic studies').click()
|
||||
|
||||
const checkbox = component.getByRole('checkbox', { name: 'Linear algebra required for year 1' })
|
||||
await checkbox.check()
|
||||
|
||||
await expect(checkbox).toBeChecked()
|
||||
await expect(component.getByText('2 / 2')).toBeVisible()
|
||||
expect(criteriaRequests).toEqual([{ code: 'KH50_001', courseGroupIds: ['group-MAT101', 'group-MAT102'], year: 1 }])
|
||||
})
|
||||
|
||||
test('unchecking a year saves the criteria once', async ({ mount }) => {
|
||||
const component = await mount(<DegreeCourseTableWrapper />)
|
||||
await component.getByText('Basic studies').click()
|
||||
|
||||
await component.getByRole('checkbox', { name: 'Calculus I required for year 1' }).uncheck()
|
||||
|
||||
await expect.poll(() => criteriaRequests).toEqual([{ code: 'KH50_001', courseGroupIds: [], year: 1 }])
|
||||
})
|
||||
|
||||
test('visibility-only table hides criteria columns and toggles course visibility', async ({ mount }) => {
|
||||
const component = await mount(<DegreeCourseTableWrapper showCriteria={false} />)
|
||||
|
||||
await expect(component.getByRole('columnheader')).toHaveText(['Name', 'Code', 'Visibility'])
|
||||
|
||||
await component.getByText('Basic studies').click()
|
||||
await component.getByRole('checkbox', { name: 'Show Calculus I' }).uncheck()
|
||||
|
||||
await expect(component.getByText('Partly shown')).toBeVisible()
|
||||
await expect
|
||||
.poll(() => exclusionRequests)
|
||||
.toEqual([
|
||||
{
|
||||
method: 'POST',
|
||||
body: { courseGroupIds: ['group-MAT101'], curriculumVersion: 'period-1', programmeCode: 'KH50_001' },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('hiding a partly hidden module asks for confirmation', async ({ mount, page }) => {
|
||||
const component = await mount(<DegreeCourseTableWrapper partlyHidden />)
|
||||
|
||||
// The switch stays on until the user confirms
|
||||
await component.getByRole('checkbox', { name: 'Show all courses of Basic studies' }).click()
|
||||
await expect(page.getByRole('dialog')).toContainText('remaining 1 course for all')
|
||||
expect(exclusionRequests).toEqual([])
|
||||
|
||||
await page.getByRole('button', { name: 'Hide module' }).click()
|
||||
|
||||
await expect(component.getByText('Hidden', { exact: true })).toBeVisible()
|
||||
await expect
|
||||
.poll(() => exclusionRequests)
|
||||
.toEqual([
|
||||
{
|
||||
method: 'POST',
|
||||
body: { courseGroupIds: ['group-MAT101'], curriculumVersion: 'period-1', programmeCode: 'KH50_001' },
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { DegreeCourseTable, DegreeModule } from '@/pages/StudyProgramme/DegreeCoursesTab/DegreeCourseTable'
|
||||
import { ProgrammeCourse, ProgressCriteria } from '@oodikone/shared/types'
|
||||
import { ReduxWrapper } from '../../ReduxWrapper'
|
||||
|
||||
const course = (code: string, name: string, visible = true, moduleName = 'Basic studies') =>
|
||||
({
|
||||
code,
|
||||
group_id: `group-${code}`,
|
||||
module_order: 0,
|
||||
name: { fi: name, en: name },
|
||||
parent_code: 'MOD1',
|
||||
parent_name: { fi: moduleName, en: moduleName },
|
||||
visible: { id: null, visibility: visible },
|
||||
}) as unknown as ProgrammeCourse
|
||||
|
||||
const criteria: ProgressCriteria = {
|
||||
allCourseGroups: {},
|
||||
courses: {
|
||||
yearOne: ['group-MAT101'],
|
||||
yearTwo: [],
|
||||
yearThree: [],
|
||||
yearFour: [],
|
||||
yearFive: [],
|
||||
yearSix: [],
|
||||
},
|
||||
credits: { yearOne: 0, yearTwo: 0, yearThree: 0, yearFour: 0, yearFive: 0, yearSix: 0 },
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts `DegreeCourseTable` with one module of two courses. MAT101 is required for year 1.
|
||||
* With `partlyHidden`, MAT102 is hidden.
|
||||
* With `combined`, a second programme (MH90_001) adds a module with the same code that also contains MAT101.
|
||||
*/
|
||||
export const DegreeCourseTableWrapper = ({
|
||||
combined = false,
|
||||
partlyHidden = false,
|
||||
showCriteria = true,
|
||||
showVisibility = true,
|
||||
studyProgramme = 'KH50_001',
|
||||
}: {
|
||||
combined?: boolean
|
||||
partlyHidden?: boolean
|
||||
showCriteria?: boolean
|
||||
showVisibility?: boolean
|
||||
studyProgramme?: string
|
||||
}) => {
|
||||
const modules: DegreeModule[] = [
|
||||
{
|
||||
code: 'MOD1',
|
||||
courses: [course('MAT101', 'Calculus I'), course('MAT102', 'Linear algebra', !partlyHidden)],
|
||||
order: 0,
|
||||
programmeCode: studyProgramme,
|
||||
},
|
||||
]
|
||||
if (combined) {
|
||||
modules.push({
|
||||
code: 'MOD1',
|
||||
courses: [course('MAT101', 'Calculus I', true, 'Clinical studies')],
|
||||
order: 0,
|
||||
programmeCode: 'MH90_001',
|
||||
})
|
||||
}
|
||||
return (
|
||||
<ReduxWrapper
|
||||
component={
|
||||
<DegreeCourseTable
|
||||
criteria={criteria}
|
||||
curriculumVersion={['period-1']}
|
||||
modules={modules}
|
||||
programmeNames={{ [studyProgramme]: 'First programme', MH90_001: 'Second programme' }}
|
||||
showCriteria={showCriteria}
|
||||
showVisibility={showVisibility}
|
||||
studyProgramme={studyProgramme}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { test, expect } from '@playwright/experimental-ct-react'
|
||||
|
||||
import { DegreeCoursesTabWrapper } from './DegreeCoursesTabWrapper'
|
||||
|
||||
const course = {
|
||||
code: 'MAT101',
|
||||
group_id: 'group-MAT101',
|
||||
module_order: 0,
|
||||
name: { fi: 'Calculus I', en: 'Calculus I' },
|
||||
parent_code: 'MOD1',
|
||||
parent_name: { fi: 'Basic studies', en: 'Basic studies' },
|
||||
visible: { id: null, visibility: true },
|
||||
}
|
||||
|
||||
const emptyYears = { yearOne: [], yearTwo: [], yearThree: [], yearFour: [], yearFive: [], yearSix: [] }
|
||||
|
||||
test.describe('DegreeCoursesTab', () => {
|
||||
test.beforeEach(async ({ router }) => {
|
||||
await router.route('**/api/login', route => route.fulfill({ json: { user: { roles: [] } } }))
|
||||
await router.route('**/api/banners', route => route.fulfill({ json: [] }))
|
||||
await router.route('**/api/populationstatistics/studyprogrammes', route =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
allProgrammes: { KH90_001: { name: { fi: 'Lääketieteen kandiohjelma', en: 'Medicine (bachelor)' } } },
|
||||
filteredProgrammes: {},
|
||||
},
|
||||
})
|
||||
)
|
||||
await router.route('**/api/curriculum-options/*', route =>
|
||||
route.fulfill({ json: [{ id: 'cur-1', name: '2023–2026', periodIds: ['period-1'], validFrom: '2023-08-01' }] })
|
||||
)
|
||||
await router.route('**/api/curriculum/*/*', route =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
defaultProgrammeCourses: [course],
|
||||
defaultProgrammeModules: [],
|
||||
secondProgrammeCourses: [],
|
||||
secondProgrammeModules: [],
|
||||
},
|
||||
})
|
||||
)
|
||||
await router.route('**/api/programme-criteria?*', route =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
allCourseGroups: {},
|
||||
courses: emptyYears,
|
||||
credits: { yearOne: 0, yearTwo: 0, yearThree: 0, yearFour: 0, yearFive: 0, yearSix: 0 },
|
||||
},
|
||||
})
|
||||
)
|
||||
await router.route('**/api/course-exclusions/', route => route.fulfill({ json: [] }))
|
||||
})
|
||||
|
||||
test('shows the global settings warning and opens the criteria tab by default', async ({ mount }) => {
|
||||
const component = await mount(<DegreeCoursesTabWrapper programme="KH50_001" />)
|
||||
|
||||
await expect(component.getByRole('alert')).toContainText(/non-personal|eivät ole henkilökohtaisia/)
|
||||
await expect(component.locator('[data-cy="criteria-panel"] [data-cy="curriculum-section"]')).toBeVisible()
|
||||
await expect(component.getByRole('tab')).toHaveText(['Progress tab criteria', 'Course & module visibility'])
|
||||
await expect(component.getByRole('tab', { name: 'Progress tab criteria' })).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(component.locator('[data-cy="credit-criteria-section"]')).toBeVisible()
|
||||
await expect(component.locator('[data-cy="visibility-panel"]')).toBeHidden()
|
||||
})
|
||||
|
||||
test('opens the given tab and still allows switching to the other one', async ({ mount }) => {
|
||||
const component = await mount(<DegreeCoursesTabWrapper initialTab="visibility" programme="KH50_001" />)
|
||||
|
||||
await expect(component.locator('[data-cy="visibility-panel"]')).toBeVisible()
|
||||
await expect(component.locator('[data-cy="criteria-panel"]')).toBeHidden()
|
||||
await expect(component.locator('[data-cy="visibility-panel"] [data-cy="curriculum-section"]')).toBeVisible()
|
||||
|
||||
await component.getByRole('tab', { name: 'Progress tab criteria' }).click()
|
||||
|
||||
await expect(component.locator('[data-cy="criteria-panel"]')).toBeVisible()
|
||||
await expect(component.locator('[data-cy="visibility-panel"]')).toBeHidden()
|
||||
})
|
||||
|
||||
test('keeps visibility changes when switching tabs', async ({ mount }) => {
|
||||
const component = await mount(<DegreeCoursesTabWrapper initialTab="visibility" programme="KH50_001" />)
|
||||
const visibilityPanel = component.locator('[data-cy="visibility-panel"]')
|
||||
|
||||
await visibilityPanel.getByText('Basic studies').click()
|
||||
await visibilityPanel.getByRole('checkbox', { name: 'Show Calculus I' }).uncheck()
|
||||
await component.getByRole('tab', { name: 'Progress tab criteria' }).click()
|
||||
await component.getByRole('tab', { name: 'Course & module visibility' }).click()
|
||||
|
||||
await expect(visibilityPanel.getByRole('checkbox', { name: 'Show Calculus I' })).not.toBeChecked()
|
||||
})
|
||||
|
||||
test('has no tabs for a programme without progress criteria', async ({ mount }) => {
|
||||
const component = await mount(<DegreeCoursesTabWrapper programme="MH50_001" />)
|
||||
|
||||
await expect(component.locator('[data-cy="visibility-panel"]')).toBeVisible()
|
||||
await expect(component.getByRole('tab')).toHaveCount(0)
|
||||
await expect(component.locator('[data-cy="credit-criteria-section"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('combined programme shows both curriculums in one table', async ({ mount, router }) => {
|
||||
await router.route('**/api/curriculum/*/*', route =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
defaultProgrammeCourses: [course],
|
||||
defaultProgrammeModules: [],
|
||||
secondProgrammeCourses: [
|
||||
{
|
||||
...course,
|
||||
code: 'LK101',
|
||||
group_id: 'group-LK101',
|
||||
name: { fi: 'Anatomy', en: 'Anatomy' },
|
||||
parent_code: 'MOD2',
|
||||
parent_name: { fi: 'Clinical studies', en: 'Clinical studies' },
|
||||
},
|
||||
],
|
||||
secondProgrammeModules: [],
|
||||
},
|
||||
})
|
||||
)
|
||||
const component = await mount(<DegreeCoursesTabWrapper programme="KH90_001" />)
|
||||
const criteriaPanel = component.locator('[data-cy="criteria-panel"]')
|
||||
|
||||
await expect(criteriaPanel.locator('[data-cy="degree-course-table"]')).toHaveCount(1)
|
||||
await expect(criteriaPanel.locator('[data-cy="programme-row-KH90_001"]')).toContainText(
|
||||
/Lääketieteen kandiohjelma|Medicine \(bachelor\)/
|
||||
)
|
||||
// Programme without a known name falls back to its code
|
||||
await expect(criteriaPanel.locator('[data-cy="programme-row-MH90_001"]')).toHaveText('MH90_001 (MH90_001)')
|
||||
await expect(criteriaPanel.getByText('Basic studies')).toBeVisible()
|
||||
await expect(criteriaPanel.getByText('Clinical studies')).toBeVisible()
|
||||
await expect(criteriaPanel.getByRole('columnheader', { name: 'Year 6' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { CurriculumProvider } from '@/hooks/useCurriculum'
|
||||
import { DegreeCoursesTab, DegreeSettingsTab } from '@/pages/StudyProgramme/DegreeCoursesTab'
|
||||
import { ReduxWrapper } from '../../ReduxWrapper'
|
||||
|
||||
/**
|
||||
* Mounts `DegreeCoursesTab` with a curriculum provider. Mock the curriculum and criteria APIs before mounting.
|
||||
*/
|
||||
export const DegreeCoursesTabWrapper = ({
|
||||
initialTab,
|
||||
programme,
|
||||
}: {
|
||||
initialTab?: DegreeSettingsTab
|
||||
programme: string
|
||||
}) => (
|
||||
<ReduxWrapper
|
||||
component={
|
||||
<CurriculumProvider programmeCode={programme} year={2024}>
|
||||
<DegreeCoursesTab degreeProgramme={programme} initialTab={initialTab} />
|
||||
</CurriculumProvider>
|
||||
}
|
||||
/>
|
||||
)
|
||||
@@ -2,16 +2,28 @@ import { assert, describe, it } from 'vitest'
|
||||
|
||||
import { calculateExcelData, getHopsCourses } from '@/components/CustomPopulation/courseMatrix'
|
||||
import { CreditTypeCode } from '@oodikone/shared/types'
|
||||
import type { StudentCourse } from '@oodikone/shared/types/studentData'
|
||||
|
||||
import { createCourse as createBaseCourse, createStudent, createStudyPlan } from '@oodikone/shared/test/utils'
|
||||
|
||||
// Each course id is its own course group unless overridden
|
||||
const createCourse = (
|
||||
course_id: string,
|
||||
credits: number,
|
||||
passed: boolean,
|
||||
date: Date,
|
||||
credittypecode: CreditTypeCode = passed ? CreditTypeCode.PASSED : CreditTypeCode.FAILED
|
||||
) => createBaseCourse({ course_id, date, passed, grade: passed ? '5' : 'Hyl.', credits, credittypecode })
|
||||
overrides: Partial<StudentCourse> = {}
|
||||
) =>
|
||||
createBaseCourse({
|
||||
course_id,
|
||||
courseGroupId: course_id,
|
||||
date,
|
||||
passed,
|
||||
grade: passed ? '5' : 'Hyl.',
|
||||
credits,
|
||||
credittypecode: passed ? CreditTypeCode.PASSED : CreditTypeCode.FAILED,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createHops = (includedCourses: string[]) => createStudyPlan({ included_courses: includedCourses })
|
||||
|
||||
@@ -52,6 +64,21 @@ void describe('getHopsCourses', () => {
|
||||
assert.equal(result.length, 1)
|
||||
assert.equal(result[0].credits, 6)
|
||||
})
|
||||
|
||||
void it('dedupes completions of different versions of the same course group keeping the latest', () => {
|
||||
const student = createStudent({
|
||||
courses: [
|
||||
createCourse('A-2023', 5, true, new Date('2024-09-01'), { courseGroupId: 'group-a' }),
|
||||
createCourse('A-2025', 6, true, new Date('2025-01-15'), { courseGroupId: 'group-a' }),
|
||||
],
|
||||
studyplans: [createHops(['A-2023', 'A-2025'])],
|
||||
})
|
||||
|
||||
const result = getHopsCourses(student)
|
||||
|
||||
assert.equal(result.length, 1)
|
||||
assert.equal(result[0].course_id, 'A-2025')
|
||||
})
|
||||
})
|
||||
|
||||
void describe('calculateExcelData', () => {
|
||||
@@ -71,12 +98,12 @@ void describe('calculateExcelData', () => {
|
||||
studyplans: [createHops(['course-a', 'course-b'])],
|
||||
}),
|
||||
]
|
||||
const courseInfoById = new Map([
|
||||
const courseInfoByGroupId = new Map([
|
||||
['course-a', { code: 'A', name: 'Course A' }],
|
||||
['course-b', { code: 'B', name: 'Course B' }],
|
||||
])
|
||||
|
||||
const data = calculateExcelData(students, courseInfoById)
|
||||
const data = calculateExcelData(students, courseInfoByGroupId)
|
||||
|
||||
assert.deepEqual(data.completedCoursesRows, [
|
||||
['1', 'Testi Opiskelija', 'Course A (A)'],
|
||||
@@ -88,18 +115,42 @@ void describe('calculateExcelData', () => {
|
||||
])
|
||||
})
|
||||
|
||||
void it('falls back to id as the code for courses missing from the course map', () => {
|
||||
void it('displays the course group code for other versions and aggregates versions of the same group', () => {
|
||||
const students = [
|
||||
createStudent({
|
||||
studentNumber: '1',
|
||||
courses: [createCourse('unknown-course-id', 5, true, new Date('2024-09-01'))],
|
||||
courses: [createCourse('course-a-v1', 5, true, new Date('2024-09-01'), { courseGroupId: 'group-a' })],
|
||||
studyplans: [createHops(['course-a-v1'])],
|
||||
}),
|
||||
createStudent({
|
||||
studentNumber: '2',
|
||||
courses: [createCourse('course-a-v2', 5, true, new Date('2025-09-01'), { courseGroupId: 'group-a' })],
|
||||
studyplans: [createHops(['course-a-v2'])],
|
||||
}),
|
||||
]
|
||||
const courseInfoByGroupId = new Map([['group-a', { code: 'A', name: 'Course A' }]])
|
||||
|
||||
const data = calculateExcelData(students, courseInfoByGroupId)
|
||||
|
||||
assert.deepEqual(data.completedCoursesRows, [
|
||||
['1', 'Testi Opiskelija', 'Course A (A)'],
|
||||
['2', 'Testi Opiskelija', 'Course A (A)'],
|
||||
])
|
||||
assert.deepEqual(data.courseCounterRows, [['A', 'Course A', '2', '10']])
|
||||
})
|
||||
|
||||
void it('falls back to the course code of the credit for courses missing from the course map', () => {
|
||||
const students = [
|
||||
createStudent({
|
||||
studentNumber: '1',
|
||||
courses: [createCourse('unknown-course-id', 5, true, new Date('2024-09-01'), { course_code: 'UNKNOWN01' })],
|
||||
studyplans: [createHops(['unknown-course-id'])],
|
||||
}),
|
||||
]
|
||||
|
||||
const data = calculateExcelData(students, new Map())
|
||||
|
||||
assert.deepEqual(data.completedCoursesRows, [['1', 'Testi Opiskelija', ' (unknown-course-id)']])
|
||||
assert.deepEqual(data.courseCounterRows, [['unknown-course-id', '', '1', '5']])
|
||||
assert.deepEqual(data.completedCoursesRows, [['1', 'Testi Opiskelija', ' (UNKNOWN01)']])
|
||||
assert.deepEqual(data.courseCounterRows, [['UNKNOWN01', '', '1', '5']])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { assert, describe, it } from 'vitest'
|
||||
|
||||
import {
|
||||
getGroupIdToExportKey,
|
||||
renameKeys,
|
||||
} from '@/components/PopulationComponents/Students/Table/CoursesTab/excelExport'
|
||||
|
||||
const createModules = (modules: Record<string, [code: string, groupId: string][]>) =>
|
||||
new Map(
|
||||
Object.entries(modules).map(([parentCode, courses]) => [
|
||||
parentCode,
|
||||
{ courses: courses.map(([code, groupId]) => ({ code, groupId })) },
|
||||
])
|
||||
)
|
||||
|
||||
void describe('getGroupIdToExportKey', () => {
|
||||
void it('maps each course groupId to its course code', () => {
|
||||
const modules = createModules({
|
||||
MOD1: [
|
||||
['TKT10001', 'otm-1'],
|
||||
['TKT10002', 'otm-2'],
|
||||
],
|
||||
MOD2: [['MAT11001', 'otm-3']],
|
||||
})
|
||||
|
||||
assert.deepEqual(getGroupIdToExportKey(modules), {
|
||||
'otm-1': 'TKT10001',
|
||||
'otm-2': 'TKT10002',
|
||||
'otm-3': 'MAT11001',
|
||||
})
|
||||
})
|
||||
|
||||
void it('uses the first code when the same groupId appears under several modules', () => {
|
||||
const modules = createModules({
|
||||
MOD1: [['TKT10001', 'otm-1']],
|
||||
MOD2: [['TKT10001-OLD', 'otm-1']],
|
||||
})
|
||||
|
||||
assert.deepEqual(getGroupIdToExportKey(modules), { 'otm-1': 'TKT10001' })
|
||||
})
|
||||
|
||||
void it('keeps keys unique when separate groupIds share a code', () => {
|
||||
const modules = createModules({
|
||||
MOD1: [['TKT10001', 'otm-1']],
|
||||
MOD2: [['TKT10001', 'otm-2']],
|
||||
})
|
||||
|
||||
assert.deepEqual(getGroupIdToExportKey(modules), {
|
||||
'otm-1': 'TKT10001',
|
||||
'otm-2': 'TKT10001 (otm-2)',
|
||||
})
|
||||
})
|
||||
|
||||
void it('returns an empty map when there are no modules', () => {
|
||||
assert.deepEqual(getGroupIdToExportKey(new Map()), {})
|
||||
})
|
||||
})
|
||||
|
||||
void describe('renameKeys', () => {
|
||||
void it('renames mapped keys and keeps other keys and all values unchanged', () => {
|
||||
const course = { grade: '5', exportValue: '5' }
|
||||
const row = { studentNumber: '123456789', totalPassed: 1, 'otm-1': course }
|
||||
|
||||
assert.deepEqual(renameKeys(row, { 'otm-1': 'TKT10001' }), {
|
||||
studentNumber: '123456789',
|
||||
totalPassed: 1,
|
||||
TKT10001: course,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -48,8 +48,10 @@ const createCredit = (
|
||||
credits: number,
|
||||
credittypecode: CreditTypeCode,
|
||||
date: Date
|
||||
): PopulationCourseStatsCredit =>
|
||||
createBaseCredit({ grade: '', credits, credittypecode, attainment_date: date, course_id: courseId })
|
||||
): PopulationCourseStatsCredit => ({
|
||||
...createBaseCredit({ grade: '', credits, credittypecode, attainment_date: date, course_id: courseId }),
|
||||
courseGroupId: idToGroupId[courseId],
|
||||
})
|
||||
|
||||
const STUDY_RIGHT_START = new Date('2024-08-01')
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { assert, describe, it } from 'vitest'
|
||||
|
||||
import { getHiddenCourseCodes, getHiddenModuleCodes } from '@/components/HiddenCourseAlert/util'
|
||||
import type { ProgrammeCourse } from '@oodikone/shared/types'
|
||||
|
||||
const createCourse = (code: string, parentCode: string | null, visibility: boolean) =>
|
||||
({ code, parent_code: parentCode, visible: { id: null, visibility } }) as ProgrammeCourse
|
||||
|
||||
void describe('getHiddenCourseCodes', () => {
|
||||
void it('returns codes of hidden courses', () => {
|
||||
const courses = [createCourse('TKT1', 'MOD1', false), createCourse('TKT2', 'MOD1', true)]
|
||||
|
||||
assert.deepEqual(getHiddenCourseCodes(courses), ['TKT1'])
|
||||
})
|
||||
|
||||
void it('returns nothing when all courses are visible', () => {
|
||||
const courses = [createCourse('TKT1', 'MOD1', true), createCourse('TKT2', 'MOD2', true)]
|
||||
|
||||
assert.deepEqual(getHiddenCourseCodes(courses), [])
|
||||
})
|
||||
|
||||
void it('returns nothing when there are no courses', () => {
|
||||
assert.deepEqual(getHiddenCourseCodes([]), [])
|
||||
})
|
||||
|
||||
void it('returns hidden courses even if their module still has visible courses', () => {
|
||||
const courses = [
|
||||
createCourse('TKT1', 'MOD1', false),
|
||||
createCourse('TKT2', 'MOD1', true),
|
||||
createCourse('TKT3', 'MOD2', false),
|
||||
createCourse('TKT4', 'MOD2', false),
|
||||
]
|
||||
|
||||
assert.deepEqual(getHiddenCourseCodes(courses), ['TKT1', 'TKT3', 'TKT4'])
|
||||
})
|
||||
|
||||
void it('includes hidden courses without a parent', () => {
|
||||
const courses = [createCourse('TKT1', null, false), createCourse('TKT2', null, true)]
|
||||
|
||||
assert.deepEqual(getHiddenCourseCodes(courses), ['TKT1'])
|
||||
})
|
||||
|
||||
void it('keeps the original course order', () => {
|
||||
const courses = [
|
||||
createCourse('TKT3', 'MOD1', false),
|
||||
createCourse('TKT1', 'MOD2', false),
|
||||
createCourse('TKT2', 'MOD1', false),
|
||||
]
|
||||
|
||||
assert.deepEqual(getHiddenCourseCodes(courses), ['TKT3', 'TKT1', 'TKT2'])
|
||||
})
|
||||
})
|
||||
|
||||
void describe('getHiddenModuleCodes', () => {
|
||||
void it('returns modules whose courses are all hidden', () => {
|
||||
const courses = [
|
||||
createCourse('TKT1', 'MOD1', false),
|
||||
createCourse('TKT2', 'MOD1', false),
|
||||
createCourse('TKT3', 'MOD2', false),
|
||||
createCourse('TKT4', 'MOD2', true),
|
||||
createCourse('TKT5', 'MOD3', true),
|
||||
]
|
||||
|
||||
assert.deepEqual(getHiddenModuleCodes(courses), ['MOD1'])
|
||||
})
|
||||
|
||||
void it('does not hide a module if a visible course comes after hidden ones', () => {
|
||||
const courses = [
|
||||
createCourse('TKT1', 'MOD1', false),
|
||||
createCourse('TKT2', 'MOD1', true),
|
||||
createCourse('TKT3', 'MOD1', false),
|
||||
]
|
||||
|
||||
assert.deepEqual(getHiddenModuleCodes(courses), [])
|
||||
})
|
||||
|
||||
void it('ignores courses without a parent', () => {
|
||||
const courses = [createCourse('TKT1', null, false)]
|
||||
|
||||
assert.deepEqual(getHiddenModuleCodes(courses), [])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,140 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { assert, describe, it } from 'vitest'
|
||||
|
||||
import {
|
||||
getCellText,
|
||||
getCourseGroups,
|
||||
hasFailed,
|
||||
hasPassedDuringAcademicYear,
|
||||
hasPassedOutsideAcademicYear,
|
||||
Label,
|
||||
} from '@/components/PopulationComponents/Students/Table/ProgressTab/util'
|
||||
import { CreditTypeCode, EnrollmentState, FormattedStudent, ProgressCriteria } from '@oodikone/shared/types'
|
||||
import { StudentCourse } from '@oodikone/shared/types/studentData'
|
||||
|
||||
const COURSES = {
|
||||
CS001: { id: 'id-CS001', groupId: 'group-CS001', code: 'CS001' },
|
||||
CS002: { id: 'id-CS002', groupId: 'group-CS002', code: 'CS002' },
|
||||
CS002A: { id: 'id-CS002A', groupId: 'group-CS002A', code: 'CS002A' },
|
||||
CS002B: { id: 'id-CS002B', groupId: 'group-CS002B', code: 'CS002B' },
|
||||
} as const
|
||||
|
||||
const idToGroupId = Object.fromEntries(Object.values(COURSES).map(({ id, groupId }) => [id, groupId]))
|
||||
|
||||
const criteria = {
|
||||
allCourseGroups: {
|
||||
[COURSES.CS001.groupId]: [],
|
||||
[COURSES.CS002.groupId]: [[COURSES.CS002A.groupId, COURSES.CS002B.groupId]],
|
||||
},
|
||||
} as unknown as ProgressCriteria
|
||||
|
||||
const createCourse = (
|
||||
course: keyof typeof COURSES,
|
||||
date: string,
|
||||
passed = true,
|
||||
credittypecode = CreditTypeCode.PASSED
|
||||
) =>
|
||||
({
|
||||
course_id: COURSES[course].id,
|
||||
course_code: COURSES[course].code,
|
||||
date: new Date(date),
|
||||
passed,
|
||||
credittypecode,
|
||||
}) as StudentCourse
|
||||
|
||||
const createStudent = (courses: StudentCourse[], enrollments: FormattedStudent['enrollments'] = []) =>
|
||||
({ courses, enrollments, criteriaProgress: {} }) as unknown as FormattedStudent
|
||||
|
||||
const label = (course: keyof typeof COURSES): Label => ({
|
||||
code: COURSES[course].code,
|
||||
groupId: COURSES[course].groupId,
|
||||
name: COURSES[course].code,
|
||||
})
|
||||
|
||||
const getGroups = (student: FormattedStudent, course: keyof typeof COURSES) =>
|
||||
getCourseGroups(COURSES[course].groupId, criteria, student, idToGroupId)
|
||||
|
||||
void describe('getCourseGroups', () => {
|
||||
void it('returns one group per substitution group', () => {
|
||||
const student = createStudent([createCourse('CS002A', '2024-01-01'), createCourse('CS002B', '2024-02-01')])
|
||||
const groups = getGroups(student, 'CS002')
|
||||
|
||||
assert.equal(groups.length, 1)
|
||||
assert.deepEqual(
|
||||
groups[0].map(course => course.course_code),
|
||||
['CS002B', 'CS002A']
|
||||
)
|
||||
})
|
||||
|
||||
void it('omits substitution groups the student has not attempted fully', () => {
|
||||
const student = createStudent([createCourse('CS002A', '2024-01-01')])
|
||||
|
||||
assert.deepEqual(getGroups(student, 'CS002'), [])
|
||||
})
|
||||
})
|
||||
|
||||
void describe('passed checks', () => {
|
||||
const start = dayjs('2023-08-01')
|
||||
const end = dayjs('2024-07-31')
|
||||
|
||||
void it('does not count a substitution group as passed when one course is failed', () => {
|
||||
const student = createStudent([createCourse('CS002A', '2024-01-01'), createCourse('CS002B', '2024-02-01', false)])
|
||||
const groups = getGroups(student, 'CS002')
|
||||
|
||||
assert.isFalse(hasPassedOutsideAcademicYear(groups))
|
||||
assert.isFalse(hasPassedDuringAcademicYear(groups, start, end))
|
||||
assert.isTrue(hasFailed(groups))
|
||||
})
|
||||
|
||||
void it('counts a course passed after a failed attempt', () => {
|
||||
const student = createStudent([createCourse('CS001', '2023-10-01', false), createCourse('CS001', '2024-01-01')])
|
||||
const groups = getGroups(student, 'CS001')
|
||||
|
||||
assert.isTrue(hasPassedDuringAcademicYear(groups, start, end))
|
||||
assert.isFalse(hasFailed(groups))
|
||||
})
|
||||
|
||||
void it('does not count a course passed after the academic year as passed during it', () => {
|
||||
const student = createStudent([createCourse('CS001', '2024-09-01')])
|
||||
const groups = getGroups(student, 'CS001')
|
||||
|
||||
assert.isFalse(hasPassedDuringAcademicYear(groups, start, end))
|
||||
assert.isTrue(hasPassedOutsideAcademicYear(groups))
|
||||
})
|
||||
})
|
||||
|
||||
void describe('getCellText', () => {
|
||||
const text = (student: FormattedStudent, course: keyof typeof COURSES) =>
|
||||
getCellText(label(course), criteria, student, 'year1', idToGroupId)
|
||||
|
||||
void it('shows the date of the latest passed attempt', () => {
|
||||
const student = createStudent([createCourse('CS001', '2023-10-01', false), createCourse('CS001', '2024-01-02')])
|
||||
|
||||
assert.equal(text(student, 'CS001'), 'Passed 2024-01-02')
|
||||
})
|
||||
|
||||
void it('marks substitutions', () => {
|
||||
const student = createStudent([createCourse('CS002A', '2024-01-01'), createCourse('CS002B', '2024-02-01')])
|
||||
|
||||
assert.equal(text(student, 'CS002'), 'Passed (substituted)')
|
||||
})
|
||||
|
||||
void it('shows the failed date', () => {
|
||||
const student = createStudent([createCourse('CS001', '2023-10-01', false)])
|
||||
|
||||
assert.equal(text(student, 'CS001'), 'Failed 2023-10-01')
|
||||
})
|
||||
|
||||
void it('shows enrollments, ignoring rejected ones', () => {
|
||||
const enrollment = (state: EnrollmentState, date: string) => ({
|
||||
course_id: COURSES.CS001.id,
|
||||
state,
|
||||
enrollment_date_time: date,
|
||||
})
|
||||
const enrolled = createStudent([], [enrollment(EnrollmentState.ENROLLED, '2023-09-01')] as never)
|
||||
const rejected = createStudent([], [enrollment(EnrollmentState.REJECTED, '2023-09-01')] as never)
|
||||
|
||||
assert.equal(text(enrolled, 'CS001'), 'Enrollment 2023-09-01')
|
||||
assert.equal(text(rejected, 'CS001'), '')
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import path from 'path'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
const inStaging = process.env.REACT_APP_STAGING === 'true'
|
||||
const viteCacheDir = process.env.VITE_CACHE_DIR ?? 'node_modules/.vite'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
// eslint-disable-next-line import-x/no-unused-modules, import-x/no-default-export
|
||||
@@ -38,6 +39,8 @@ export default defineConfig({
|
||||
}),
|
||||
],
|
||||
base: inStaging ? '/oodikone' : '/',
|
||||
// Docker runs as DOCKER_UID, which may not have write access to the node_modules volume
|
||||
cacheDir: viteCacheDir,
|
||||
server: {
|
||||
proxy: {
|
||||
'/api/': {
|
||||
|
||||
@@ -5,9 +5,7 @@ export type ExcludedCourseCreation = Optional<ExcludedCourse, 'id' | 'createdAt'
|
||||
export type ExcludedCourse = {
|
||||
id: number
|
||||
programme_code: string
|
||||
// TODO: Remove course_code once all rows have been migrated to course_group_id
|
||||
course_code: string | null
|
||||
course_group_id: string | null
|
||||
course_group_id: string
|
||||
curriculum_version: string
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
|
||||
@@ -5,19 +5,12 @@ export type ProgressCriteriaCreation = Optional<ProgressCriteria, 'curriculumVer
|
||||
export type ProgressCriteria = {
|
||||
code: string
|
||||
curriculumVersion: string
|
||||
// TODO: Remove coursesYear* once all rows have been migrated to courseGroupIdsYear*
|
||||
coursesYearOne: string[]
|
||||
coursesYearTwo: string[]
|
||||
coursesYearThree: string[]
|
||||
coursesYearFour: string[]
|
||||
coursesYearFive: string[]
|
||||
coursesYearSix: string[]
|
||||
courseGroupIdsYearOne: string[] | null
|
||||
courseGroupIdsYearTwo: string[] | null
|
||||
courseGroupIdsYearThree: string[] | null
|
||||
courseGroupIdsYearFour: string[] | null
|
||||
courseGroupIdsYearFive: string[] | null
|
||||
courseGroupIdsYearSix: string[] | null
|
||||
courseGroupIdsYearOne: string[]
|
||||
courseGroupIdsYearTwo: string[]
|
||||
courseGroupIdsYearThree: string[]
|
||||
courseGroupIdsYearFour: string[]
|
||||
courseGroupIdsYearFive: string[]
|
||||
courseGroupIdsYearSix: string[]
|
||||
creditsYearOne: number
|
||||
creditsYearTwo: number
|
||||
creditsYearThree: number
|
||||
|
||||
@@ -71,6 +71,7 @@ export const createStudyRight = (overrides: Partial<StudentStudyRight> = {}): St
|
||||
export const createCourse = (overrides: Partial<StudentCourse> = {}): StudentCourse => ({
|
||||
course_id: 'hy-CU-000000001-2024-08-01',
|
||||
course_code: 'MAT001',
|
||||
courseGroupId: 'hy-CU-000000001',
|
||||
date: new Date('2024-08-01'),
|
||||
passed: true,
|
||||
grade: '5',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Credit, Enrollment, SISStudyRight, SISStudyRightElement, Student, Studyplan } from '../models'
|
||||
import type { Course, Credit, Enrollment, SISStudyRight, SISStudyRightElement, Student, Studyplan } from '../models'
|
||||
import type { Tag, TagStudent } from '../models/kone'
|
||||
|
||||
import type { CriteriaYear, CreditTypeCode, Name, ProgrammeModule } from '../types'
|
||||
@@ -115,6 +115,7 @@ export type StudentCourse = {
|
||||
credittypecode: CreditTypeCode
|
||||
language: string
|
||||
studyright_id: string
|
||||
courseGroupId: Course['groupId']
|
||||
}
|
||||
|
||||
export type StudentCredit = Pick<
|
||||
@@ -129,7 +130,7 @@ export type StudentCredit = Pick<
|
||||
| 'course_code'
|
||||
| 'language'
|
||||
| 'studyright_id'
|
||||
>
|
||||
> & { courseGroupId: Course['groupId'] }
|
||||
|
||||
export type StudentEnrollment = Pick<
|
||||
Enrollment,
|
||||
|
||||
@@ -2,6 +2,13 @@ import { StudyProgrammeCourse } from '../types'
|
||||
|
||||
export type YearStats = StudyProgrammeCourse['years'][number]
|
||||
|
||||
// In the object programmes should be {bachelorCode: masterCode}
|
||||
export const combinedStudyProgrammes = { KH90_001: 'MH90_001' } as const
|
||||
|
||||
/** Second programme of a combined programme, eg. the licentiate programme of a combined bachelor programme */
|
||||
export const getCombinedProgramme = (programmeCode: string): string | undefined =>
|
||||
combinedStudyProgrammes[programmeCode as keyof typeof combinedStudyProgrammes]
|
||||
|
||||
export const createEmptyStats = (isStudyModule = false): YearStats => ({
|
||||
allCredits: 0,
|
||||
allStudents: 0,
|
||||
|
||||
@@ -108,7 +108,10 @@ cypress/
|
||||
# additional inclusions here
|
||||
|
||||
# exclusions here
|
||||
*/.eslintrc.json
|
||||
**/.dockerignore
|
||||
**/.gitignore
|
||||
**/.eslintrc.json
|
||||
**/*Dockerfile
|
||||
**/build
|
||||
**/dist
|
||||
**/.npm
|
||||
|
||||
@@ -7,7 +7,7 @@ ENV TZ="Europe/Helsinki"
|
||||
ENV NODE_ENV=development
|
||||
|
||||
COPY . .
|
||||
RUN npm ci --include=dev
|
||||
RUN npm ci
|
||||
|
||||
EXPOSE 8082
|
||||
|
||||
|
||||
@@ -109,7 +109,10 @@ cypress/
|
||||
!./src/updater/updateStudents/excludedPartialAttainments.csv
|
||||
|
||||
# exclusions here
|
||||
*/.eslintrc.json
|
||||
**/.dockerignore
|
||||
**/.gitignore
|
||||
**/.eslintrc.json
|
||||
**/*Dockerfile
|
||||
**/build
|
||||
**/dist
|
||||
**/.npm
|
||||
|
||||
@@ -7,6 +7,6 @@ ENV TZ="Europe/Helsinki"
|
||||
ENV NODE_ENV=development
|
||||
|
||||
COPY . .
|
||||
RUN npm ci --include=dev
|
||||
RUN npm ci
|
||||
|
||||
CMD ["node_modules/.bin/nodemon", "--exec", "node src/index.js", "--watch", "src", "--watch", "--ext", "ts, js, json", "--legacy-watch"]
|
||||
|
||||
@@ -8,7 +8,7 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
test: {
|
||||
exclude: ['node_modules'],
|
||||
exclude: ['node_modules', 'dist'],
|
||||
testTimeout: 10_000,
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user