From 48e2b8256c42b30356331e1c0a3df33e8804c66f Mon Sep 17 00:00:00 2001 From: Aaro Koivukangas Date: Mon, 12 Oct 2026 12:38:13 +0300 Subject: [PATCH] [ProgrammeOverview][StudyTracks][Progress] Add study tracks to the table --- e2e/Degree_programme_overview.spec.ts | 12 +++ .../faculty/facultyStudentProgress.ts | 9 +- .../studyProgramme/studyTrackStats.ts | 4 +- .../Faculties/ProgrammeProgressChart.tsx | 6 +- .../ProgressOfStudents.tsx | 26 +++++- .../ProgressTable.tsx | 84 ++++++++++++++++--- .../index.tsx | 39 +++++++++ 7 files changed, 158 insertions(+), 22 deletions(-) diff --git a/e2e/Degree_programme_overview.spec.ts b/e2e/Degree_programme_overview.spec.ts index ad9a97bfd..99d97500d 100644 --- a/e2e/Degree_programme_overview.spec.ts +++ b/e2e/Degree_programme_overview.spec.ts @@ -380,6 +380,18 @@ test.describe('Degree programme overview', () => { await checkTableStats(page, tableContents, 'study-programme-progress') }) + test('Years in the student progress table can be expanded and study track data will be shown', async ({ page }) => { + const table = page.getByTestId('study-programme-progress-data-table').locator('tbody') + const row = table.locator('tr').filter({ hasText: '2020 - 2021' }) + const studyTracks = ['MAT-EKO', 'MAT-MAT', 'MAT-TIE', 'MAT-TIL'] + + await row.getByRole('button').click() + await Promise.all(studyTracks.map(studyTrack => expect(table.getByText(studyTrack)).toBeVisible())) + + await row.getByRole('button').click() + await Promise.all(studyTracks.map(studyTrack => expect(table.getByText(studyTrack)).not.toBeVisible())) + }) + test('Progress section', async ({ page }) => { const barChart = page.getByTestId('programme-progress-bar-chart-section') await expect(barChart.getByText('Less than 30 credits')).toBeVisible() diff --git a/services/backend/src/services/faculty/facultyStudentProgress.ts b/services/backend/src/services/faculty/facultyStudentProgress.ts index da559c381..41d5258b6 100644 --- a/services/backend/src/services/faculty/facultyStudentProgress.ts +++ b/services/backend/src/services/faculty/facultyStudentProgress.ts @@ -54,6 +54,7 @@ const createYearlyTitles = (limitList: CreditLimits) => { const calculateProgressStats = ( level: 'bachelor' | 'master' | 'bcMs' | 'doctor', creditCounts: Record, + graduatedCount: Record, yearlyTitles: Record, progressStats: Record>, progId: string @@ -76,7 +77,7 @@ const calculateProgressStats = ( if (level !== 'doctor' && !(level in yearlyTitles)) { yearlyTitles[level] = [] for (const year of Object.values(limitsForYears)) { - yearlyTitles[level].unshift(createYearlyTitles(year)) + yearlyTitles[level].unshift([...createYearlyTitles(year), 'Graduated']) } } @@ -93,7 +94,7 @@ const calculateProgressStats = ( const correctIndex = limit.length - 1 - limit.findIndex(element => credits >= (element[0] ?? 0)) statsForYear[correctIndex] += 1 } - progressStats[level][progId].unshift(statsForYear) + progressStats[level][progId].unshift([...statsForYear, graduatedCount[year] ?? 0]) } } @@ -165,11 +166,11 @@ export const combineFacultyStudentProgress = async ( const level = programmeTypes[degreeProgrammeType] updateCreditAndGraduatedCount(level, stats.creditCounts, stats.graduatedCount) - calculateProgressStats(level, stats.creditCounts, yearlyTitles, progressStats, progId) + calculateProgressStats(level, stats.creditCounts, stats.graduatedCount, yearlyTitles, progressStats, progId) if (level === 'master' && Object.keys(stats.creditCountsCombo).length > 0) { updateCreditAndGraduatedCount('bcMs', stats.creditCountsCombo, stats.graduatedCount) - calculateProgressStats('bcMs', stats.creditCountsCombo, yearlyTitles, progressStats, progId) + calculateProgressStats('bcMs', stats.creditCountsCombo, stats.graduatedCount, yearlyTitles, progressStats, progId) } } diff --git a/services/backend/src/services/studyProgramme/studyTrackStats.ts b/services/backend/src/services/studyProgramme/studyTrackStats.ts index 17b5430bb..2f2237d4f 100644 --- a/services/backend/src/services/studyProgramme/studyTrackStats.ts +++ b/services/backend/src/services/studyProgramme/studyTrackStats.ts @@ -453,8 +453,8 @@ const getMainStatsByTrackAndYear = async ( ? getYearsObject({ years: years.filter(year => year !== 'Total'), emptyArrays: true }) : {} - creditCountsComboByTrack[studyTrack][startYear].push( - getCreditCount(studyRight.student.credits, startedInProgramme) + creditCountsComboByTrack[studyTrack][bachelorsStartYear]?.push( + getCreditCount(studyRight.student.credits, startedInBachelor) ) } } else { diff --git a/services/frontend/src/components/Faculties/ProgrammeProgressChart.tsx b/services/frontend/src/components/Faculties/ProgrammeProgressChart.tsx index bf7fbc2e9..e5a5ab8ea 100644 --- a/services/frontend/src/components/Faculties/ProgrammeProgressChart.tsx +++ b/services/frontend/src/components/Faculties/ProgrammeProgressChart.tsx @@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from 'react' import { useLanguage } from '@/components/LanguagePicker/useLanguage' import { generateGradientColors } from '@/util/color' -import { NameWithCode } from '@oodikone/shared/types' +import { Name } from '@oodikone/shared/types' export const ProgrammeProgressChart = ({ data, @@ -14,7 +14,7 @@ export const ProgrammeProgressChart = ({ }: { data: number[][] labels: string[] - longLabels: Record + longLabels: Record names: string[] }) => { const { getTextIn } = useLanguage() @@ -75,7 +75,7 @@ export const ProgrammeProgressChart = ({ type: 'bar', stack: 'total', itemStyle: { - color: colors[index], + color: seriesItem.name === 'Graduated' ? '#ddd' : colors[index], }, label: { show: true, diff --git a/services/frontend/src/pages/StudyProgramme/StudyTracksAndClassStatisticsTab/ProgressOfStudents.tsx b/services/frontend/src/pages/StudyProgramme/StudyTracksAndClassStatisticsTab/ProgressOfStudents.tsx index ad315734f..f47a16d6d 100644 --- a/services/frontend/src/pages/StudyProgramme/StudyTracksAndClassStatisticsTab/ProgressOfStudents.tsx +++ b/services/frontend/src/pages/StudyProgramme/StudyTracksAndClassStatisticsTab/ProgressOfStudents.tsx @@ -2,7 +2,11 @@ import Stack from '@mui/material/Stack' import Typography from '@mui/material/Typography' import { ProgressBarChart } from '@/components/Charts/ProgressBarChart' -import { ProgressTable } from '@/pages/StudyProgramme/StudyTracksAndClassStatisticsTab/ProgressTable' +import { + ProgressTable, + StudyTrackProgressStats, +} from '@/pages/StudyProgramme/StudyTracksAndClassStatisticsTab/ProgressTable' +import { Name } from '@oodikone/shared/types' type ProgressStats = { chartStats: { @@ -17,11 +21,17 @@ export const ProgressOfStudents = ({ progressComboStats, progressStats, studyProgramme, + studyTrackComboStats, + studyTrackStats, + studyTracks, years, }: { progressComboStats: ProgressStats | null progressStats: ProgressStats | null studyProgramme: string + studyTrackComboStats?: StudyTrackProgressStats | null + studyTrackStats?: StudyTrackProgressStats | null + studyTracks?: Record years: string[] }) => { return ( @@ -37,7 +47,12 @@ export const ProgressOfStudents = ({ years, }} /> - + )} {progressStats !== null && ( @@ -51,7 +66,12 @@ export const ProgressOfStudents = ({ years, }} /> - + )} diff --git a/services/frontend/src/pages/StudyProgramme/StudyTracksAndClassStatisticsTab/ProgressTable.tsx b/services/frontend/src/pages/StudyProgramme/StudyTracksAndClassStatisticsTab/ProgressTable.tsx index 434754c0e..104e2330e 100644 --- a/services/frontend/src/pages/StudyProgramme/StudyTracksAndClassStatisticsTab/ProgressTable.tsx +++ b/services/frontend/src/pages/StudyProgramme/StudyTracksAndClassStatisticsTab/ProgressTable.tsx @@ -1,14 +1,35 @@ import Box from '@mui/material/Box' +import Collapse from '@mui/material/Collapse' import TableBody from '@mui/material/TableBody' import TableCell from '@mui/material/TableCell' import TableContainer from '@mui/material/TableContainer' import TableHead from '@mui/material/TableHead' import TableRow from '@mui/material/TableRow' +import { Fragment, useState } from 'react' + +import { ExpandableRow } from '@/components/common/ExpandableRow' import { StyledTable } from '@/components/common/StyledTable' +import { ProgrammeProgressChart } from '@/components/Faculties/ProgrammeProgressChart' import { getCellKey, getRowKey } from '@/pages/StudyProgramme/StudyTracksAndClassStatisticsTab/StudyTrackDataTable/util' +import { Name } from '@oodikone/shared/types' + +/** Progress category counts of each study track by starting year, e.g. `{ 'MAT-MAT': { '2020 - 2021': [2, 1, 0] } }` */ +export type StudyTrackProgressStats = Record> + +export const ProgressTable = ({ + data, + studyTrackStats, + studyTracks, + titles, +}: { + data: (string | number)[][] + studyTrackStats?: StudyTrackProgressStats | null + studyTracks?: Record + titles: string[] +}) => { + const [visibleYears, setVisibleYears] = useState>({}) -export const ProgressTable = ({ data, titles }: { data: (string | number)[][]; titles: string[] }) => { if (!data.length || !titles) { return null } @@ -19,6 +40,11 @@ export const ProgressTable = ({ data, titles }: { data: (string | number)[][]; t return parseInt(String(b[0]).split(' - ')[0], 10) - parseInt(String(a[0]).split(' - ')[0], 10) }) + const studyTrackCodes = Object.keys(studyTrackStats ?? {}).toSorted() + const isExpandable = !!studyTrackStats && studyTrackCodes.length > 0 + + const toggleVisibility = (year: string) => setVisibleYears(prev => ({ ...prev, [year]: !prev[year] })) + return ( @@ -32,15 +58,53 @@ export const ProgressTable = ({ data, titles }: { data: (string | number)[][]; t - {sortedData.map((row, index) => ( - - {row.map((value, index) => ( - - {value} - - ))} - - ))} + {sortedData.map((row, index) => { + const year = row[0].toString() + + if (isExpandable && year !== 'Total') { + return ( + + toggleVisibility(year)} + visible={!!visibleYears[year]} + yearArray={row} + yearIndex={index} + /> + {visibleYears[year] ? ( + + + + studyTrackStats[code][year] ?? new Array(titles.length - 2).fill(0) + )} + labels={studyTrackCodes} + longLabels={studyTracks ?? {}} + names={titles.slice(2)} + /> + + + + ) : null} + + ) + } + + return ( + + {row.map((value, index) => ( + + {value} + + ))} + + ) + })} diff --git a/services/frontend/src/pages/StudyProgramme/StudyTracksAndClassStatisticsTab/index.tsx b/services/frontend/src/pages/StudyProgramme/StudyTracksAndClassStatisticsTab/index.tsx index 13dcebef8..360d796fa 100644 --- a/services/frontend/src/pages/StudyProgramme/StudyTracksAndClassStatisticsTab/index.tsx +++ b/services/frontend/src/pages/StudyProgramme/StudyTracksAndClassStatisticsTab/index.tsx @@ -15,6 +15,7 @@ import { useLanguage } from '@/components/LanguagePicker/useLanguage' import { Section } from '@/components/Section' import { exportStudentTable } from '@/pages/StudyProgramme/StudyTracksAndClassStatisticsTab/exportStudentTable' import { ProgressOfStudents } from '@/pages/StudyProgramme/StudyTracksAndClassStatisticsTab/ProgressOfStudents' +import { StudyTrackProgressStats } from '@/pages/StudyProgramme/StudyTracksAndClassStatisticsTab/ProgressTable' import { StudentProgressPercentiles } from '@/pages/StudyProgramme/StudyTracksAndClassStatisticsTab/StudentProgressPercentiles' import { StudyTrackDataTable } from '@/pages/StudyProgramme/StudyTracksAndClassStatisticsTab/StudyTrackDataTable' import { StudyTrackSelector } from '@/pages/StudyProgramme/StudyTracksAndClassStatisticsTab/StudyTrackSelector' @@ -123,6 +124,41 @@ export const StudyTracksAndClassStatisticsTab = ({ } const hasStudyTracks = Object.keys(studyTrackStats?.studyTracks ?? {}).length > 1 && studyTrack === studyProgramme + + const getStudyTrackProgressStats = (combo = false): StudyTrackProgressStats | null => { + if (!hasStudyTracks || !studyTrackStats) return null + + const creditCountsByTrack = combo ? studyTrackStats.creditCountsComboByTrack : studyTrackStats.creditCountsByTrack + const maximumAmountOfCredits = combo ? targetCredits + 180 : targetCredits + // console.log('Study track stats:', studyTrackStats, maximumAmountOfCredits) + + // Tracks whose students have all graduated have no credit counts, but should still be shown + const tracks = new Set([ + ...Object.keys(creditCountsByTrack ?? {}), + ...Object.keys(studyTrackStats.graduatedCountByTrack ?? {}), + ]) + tracks.delete(studyProgramme) + const emptyCreditCounts = Object.fromEntries( + studyTrackStats.years.filter(year => year !== 'Total').map(year => [year, [] as number[]]) + ) + + return Object.fromEntries( + [...tracks].map(track => { + const stats = calculateStats( + { ...emptyCreditCounts, ...creditCountsByTrack?.[track] }, + studyTrackStats.graduatedCountByTrack[track], + maximumAmountOfCredits + ) + const statsByYear = Object.fromEntries( + (stats?.tableStats ?? []) + .filter(([year]) => year !== 'Total') + .map(([year, , ...counts]) => [year, counts as number[]]) + ) + return [track, statsByYear] + }) + ) + } + const isStudyProgrammeMode = studyTrack === '' || studyTrack === studyProgramme const calculateStudyTrackStats = (combo = false) => { @@ -284,6 +320,9 @@ export const StudyTracksAndClassStatisticsTab = ({ progressComboStats={getProgressComboStats()} progressStats={getProgressStats()} studyProgramme={studyProgramme} + studyTrackComboStats={getStudyTrackProgressStats(true)} + studyTrackStats={getStudyTrackProgressStats(false)} + studyTracks={studyTrackStats.studyTracks} years={studyTrackStats.years} /> )}