Compare commits

...
5 Commits
Author SHA1 Message Date
Valtteri Kantanen bf6d6af2ec [Close to graduation] Implement page with material-react-table 2024-11-27 18:48:34 +02:00
Valtteri Kantanen 264aaa25f2 [Frontend] Create TableHeaderWithTooltip component 2024-11-27 18:13:51 +02:00
Valtteri Kantanen 98eaddca5b [Frontend] Upgrade vite from v5 to v6 2024-11-27 14:01:32 +02:00
Valtteri Kantanen e8881fb8be Refactor useLanguage and language imports and types 2024-11-27 14:01:31 +02:00
Riku Rauhala 2824ede5f2 [Access rights] Remove katselmusViewer
This access right only granted access to evaluation overview which has been removed
2024-11-27 13:56:35 +02:00
29 changed files with 1286 additions and 392 deletions
-7
View File
@@ -8,7 +8,6 @@ Roles are defined [here](../services/backend/src/config/roles.ts). Criteria for
- courseStatistics
- facultyStatistics
- fullSisuAccess
- katselmusViewer
- openUniSearch
- studyGuidanceGroups
- teachers
@@ -23,10 +22,6 @@ The roles `openUniSearch` and `studyGuidanceGroups` simply enable certain views
- Can see statistics of every course and year
### katselmusViewer
- Enables the _Evaluation overview_ view (deprecated)
### teachers
- By default, the role only allows seeing the statistics of course providers (study programmes) they have full rights to (see [Programmes](#programmes))
@@ -52,7 +47,6 @@ The roles `openUniSearch` and `studyGuidanceGroups` simply enable certain views
| Completed courses | ✅ | ✅ | ✅ |
| Close to graduation | | ✅ | ✅ |
| Open uni population | | ✅ | |
| Evaluation overview | | ✅ | |
| Courses | | ✅ | ✅ |
\* = anyone who can log in to Oodikone, check the [IAM groups](#iam-groups) section for more information
@@ -72,7 +66,6 @@ A user must be a member of either the `grp-oodikone-basic-users` or `grp-oodikon
| grp-oodikone-basic-users | courseStatistics |
| grp-oodikone-users | facultyStatistics |
| grp-toska | admin |
| grp-katselmus-\* | katselmusViewer |
| hy-ypa-opa-dojo | openUniSearch |
`grp-oodikone-basic-users`: basically the whole staff (hy-\*-allstaff)
-1
View File
@@ -3,7 +3,6 @@ export const roles = [
'courseStatistics',
'facultyStatistics',
'fullSisuAccess',
'katselmusViewer',
'openUniSearch',
'studyGuidanceGroups',
'teachers',
+2 -1
View File
@@ -1,7 +1,8 @@
import { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'
import { AutoIncrement, Column, DataType, Default, Model, PrimaryKey, Table, Unique } from 'sequelize-typescript'
import { Language, Role } from '../shared/types'
import { Language } from '../shared/language'
import { Role } from '../shared/types'
@Table({
underscored: true,
+71 -87
View File
@@ -43,35 +43,31 @@ interface GetStatsRequest extends Request {
}
}
router.get(
'/:id/basicstats',
auth.roles(['facultyStatistics', 'katselmusViewer']),
async (req: GetStatsRequest, res: Response) => {
const facultyId = req.params.id
const code = await getFacultyCodeById(facultyId)
if (!code) {
return res.status(422).end()
}
const { year_type: yearType, programme_filter: programmeFilter, special_groups: specialGroups } = req.query
const data = await getBasicStats(code, yearType, programmeFilter, specialGroups)
if (data) {
return res.json(data)
}
const programmes = await getDegreeProgrammesOfFaculty(code, programmeFilter === 'NEW_STUDY_PROGRAMMES')
if (!programmes.length) {
return res.status(422).end()
}
// TODO: The types could be handled better. Possible null value is causing issues with TS.
let updatedStats: any = await combineFacultyBasics(code, programmes, yearType, specialGroups)
if (updatedStats) {
updatedStats = await setBasicStats(updatedStats, yearType, programmeFilter, specialGroups)
}
return res.json(updatedStats)
router.get('/:id/basicstats', auth.roles(['facultyStatistics']), async (req: GetStatsRequest, res: Response) => {
const facultyId = req.params.id
const code = await getFacultyCodeById(facultyId)
if (!code) {
return res.status(422).end()
}
)
const { year_type: yearType, programme_filter: programmeFilter, special_groups: specialGroups } = req.query
const data = await getBasicStats(code, yearType, programmeFilter, specialGroups)
if (data) {
return res.json(data)
}
const programmes = await getDegreeProgrammesOfFaculty(code, programmeFilter === 'NEW_STUDY_PROGRAMMES')
if (!programmes.length) {
return res.status(422).end()
}
// TODO: The types could be handled better. Possible null value is causing issues with TS.
let updatedStats: any = await combineFacultyBasics(code, programmes, yearType, specialGroups)
if (updatedStats) {
updatedStats = await setBasicStats(updatedStats, yearType, programmeFilter, specialGroups)
}
return res.json(updatedStats)
})
interface GetCreditStatsRequest extends Request {
query: {
@@ -79,46 +75,38 @@ interface GetCreditStatsRequest extends Request {
}
}
router.get(
'/:id/creditstats',
auth.roles(['facultyStatistics', 'katselmusViewer']),
async (req: GetCreditStatsRequest, res: Response) => {
const facultyId = req.params.id
const code = await getFacultyCodeById(facultyId)
const { year_type: yearType } = req.query
const stats = await getFacultyCredits(code, yearType === 'ACADEMIC_YEAR')
return res.json(stats)
router.get('/:id/creditstats', auth.roles(['facultyStatistics']), async (req: GetCreditStatsRequest, res: Response) => {
const facultyId = req.params.id
const code = await getFacultyCodeById(facultyId)
const { year_type: yearType } = req.query
const stats = await getFacultyCredits(code, yearType === 'ACADEMIC_YEAR')
return res.json(stats)
})
router.get('/:id/thesisstats', auth.roles(['facultyStatistics']), async (req: GetStatsRequest, res: Response) => {
const facultyId = req.params.id
const code = await getFacultyCodeById(facultyId)
if (!code) {
return res.status(422).end()
}
)
router.get(
'/:id/thesisstats',
auth.roles(['facultyStatistics', 'katselmusViewer']),
async (req: GetStatsRequest, res: Response) => {
const facultyId = req.params.id
const code = await getFacultyCodeById(facultyId)
if (!code) {
return res.status(422).end()
}
const { year_type: yearType, programme_filter: programmeFilter, special_groups: specialGroups } = req.query
const data = await getThesisWritersStats(code, yearType, programmeFilter, specialGroups)
if (data) {
return res.json(data)
}
const programmes = await getDegreeProgrammesOfFaculty(code, programmeFilter === 'NEW_STUDY_PROGRAMMES')
if (!programmes.length) {
return res.status(422).end()
}
let updatedStats: any = await combineFacultyThesisWriters(code, programmes, yearType, specialGroups)
if (updatedStats) {
updatedStats = await setThesisWritersStats(updatedStats, yearType, programmeFilter, specialGroups)
}
return res.json(updatedStats)
const { year_type: yearType, programme_filter: programmeFilter, special_groups: specialGroups } = req.query
const data = await getThesisWritersStats(code, yearType, programmeFilter, specialGroups)
if (data) {
return res.json(data)
}
)
const programmes = await getDegreeProgrammesOfFaculty(code, programmeFilter === 'NEW_STUDY_PROGRAMMES')
if (!programmes.length) {
return res.status(422).end()
}
let updatedStats: any = await combineFacultyThesisWriters(code, programmes, yearType, specialGroups)
if (updatedStats) {
updatedStats = await setThesisWritersStats(updatedStats, yearType, programmeFilter, specialGroups)
}
return res.json(updatedStats)
})
interface GetGraduationStatsRequest extends Request {
query: {
@@ -128,7 +116,7 @@ interface GetGraduationStatsRequest extends Request {
router.get(
'/:id/graduationtimes',
auth.roles(['facultyStatistics', 'katselmusViewer']),
auth.roles(['facultyStatistics']),
async (req: GetGraduationStatsRequest, res: Response) => {
const facultyId = req.params.id
const code = await getFacultyCodeById(facultyId)
@@ -164,7 +152,7 @@ interface GetProgressStatsRequest extends Request {
router.get(
'/:id/progressstats',
auth.roles(['facultyStatistics', 'katselmusViewer']),
auth.roles(['facultyStatistics']),
async (req: GetProgressStatsRequest, res: Response) => {
const facultyId = req.params.id
const code = await getFacultyCodeById(facultyId)
@@ -195,7 +183,7 @@ interface GetStudentStatsRequest extends GetProgressStatsRequest {}
router.get(
'/:id/studentstats',
auth.roles(['facultyStatistics', 'katselmusViewer']),
auth.roles(['facultyStatistics']),
async (req: GetStudentStatsRequest, res: Response) => {
const facultyId = req.params.id
const code = await getFacultyCodeById(facultyId)
@@ -231,7 +219,7 @@ interface GetUpdateBasicViewRequest extends Request {
router.get(
'/:id/update_basicview',
auth.roles(['facultyStatistics', 'katselmusViewer']),
auth.roles(['facultyStatistics']),
async (req: GetUpdateBasicViewRequest, res: Response) => {
const facultyId = req.params.id
const code = await getFacultyCodeById(facultyId)
@@ -250,24 +238,20 @@ router.get(
}
)
router.get(
'/:id/update_progressview',
auth.roles(['facultyStatistics', 'katselmusViewer']),
async (req: Request, res: Response) => {
const facultyId = req.params.id
const code = await getFacultyCodeById(facultyId)
if (!code) {
return res.status(422).end()
}
try {
const result = await updateFacultyProgressOverview(code)
return res.json(result)
} catch (error) {
const message = `Failed to update progress tab stats for faculty ${code}`
logger.error(message, { error })
return res.status(500).json({ error: message })
}
router.get('/:id/update_progressview', auth.roles(['facultyStatistics']), async (req: Request, res: Response) => {
const facultyId = req.params.id
const code = await getFacultyCodeById(facultyId)
if (!code) {
return res.status(422).end()
}
)
try {
const result = await updateFacultyProgressOverview(code)
return res.json(result)
} catch (error) {
const message = `Failed to update progress tab stats for faculty ${code}`
logger.error(message, { error })
return res.status(500).json({ error: message })
}
})
export default router
+1 -2
View File
@@ -4,8 +4,7 @@ import { roles } from '../config/roles'
import * as auth from '../middleware/auth'
import { sendNotificationAboutAccessToUser, previewNotificationAboutAccessToUser } from '../services/mailService'
import * as userService from '../services/userService'
import { LANGUAGE_CODES } from '../shared/language'
import { Language } from '../shared/types'
import { LANGUAGE_CODES, Language } from '../shared/language'
import logger from '../util/logger'
const router = Router()
@@ -1,6 +1,15 @@
import { col, Op, where } from 'sequelize'
import { col, InferAttributes, Op, where } from 'sequelize'
import { Course, Credit, Student, Studyplan, SISStudyRight, SISStudyRightElement } from '../../models'
import {
Course,
Credit,
Organization,
ProgrammeModule,
Student,
Studyplan,
SISStudyRight,
SISStudyRightElement,
} from '../../models'
import { Name } from '../../shared/types'
import { CreditTypeCode, DegreeProgrammeType, EnrollmentType, ExtentCode, SemesterEnrollment } from '../../types'
import { redisClient } from '../redis'
@@ -40,6 +49,7 @@ type AccumulatorType = {
startedAt: Date
degreeProgrammeType: DegreeProgrammeType
}
faculty: Name
attainmentDates: AttainmentDates
numberOfAbsentSemesters: number
curriculumPeriod: string | null
@@ -88,7 +98,7 @@ const findThesisAndLatestAndEarliestAttainments = (
return { attainmentDates, thesisData }
}
const formatStudent = (student: Student) => {
const formatStudent = (student: InferAttributes<Student>, facultyMap: Record<string, Name>) => {
const {
studentnumber: studentNumber,
abbreviatedname: name,
@@ -147,6 +157,7 @@ const formatStudent = (student: Student) => {
startedAt: programmeStartDate,
degreeProgrammeType,
},
faculty: facultyMap[programmeCode],
attainmentDates,
numberOfAbsentSemesters,
curriculumPeriod: getCurriculumVersion(studyPlan.curriculum_period_id),
@@ -159,8 +170,8 @@ const formatStudent = (student: Student) => {
}, [])
}
export const findStudentsCloseToGraduation = async (studentNumbers?: string[]) =>
(
export const findStudentsCloseToGraduation = async (studentNumbers?: string[]) => {
const students = (
await Student.findAll({
attributes: [
'abbreviatedname',
@@ -261,8 +272,30 @@ export const findStudentsCloseToGraduation = async (studentNumbers?: string[]) =
],
order: [[{ model: Credit, as: 'credits' }, 'attainment_date', 'DESC']],
})
)
.flatMap(student => formatStudent(student.toJSON()))
).map(student => student.toJSON())
const programmeCodes = [...new Set(students.flatMap(student => student.studyplans.map(sp => sp.programme_code)))]
const programmesWithFaculties = await ProgrammeModule.findAll({
attributes: ['code'],
where: {
code: {
[Op.in]: programmeCodes,
},
},
include: {
model: Organization,
attributes: ['name'],
},
raw: true,
})
const facultyMap: Record<string, Name> = {}
for (const programme of programmesWithFaculties) {
facultyMap[programme.code] = programme['organization.name']
}
return students
.flatMap(student => formatStudent(student, facultyMap))
.reduce(
(acc, student) => {
if (student.programme.degreeProgrammeType === DegreeProgrammeType.BACHELOR) {
@@ -274,6 +307,7 @@ export const findStudentsCloseToGraduation = async (studentNumbers?: string[]) =
},
{ bachelor: [] as AccumulatorType[], masterAndLicentiate: [] as AccumulatorType[] }
)
}
export const getCloseToGraduationData = async (studentNumbers?: string[]) => {
if (!studentNumbers) {
+1 -2
View File
@@ -150,7 +150,7 @@ const updateAccessGroups = async (
specialGroup: Record<string, boolean>,
sisId: string
) => {
const { jory, superAdmin, openUni, katselmusViewer, fullSisuAccess } = specialGroup
const { jory, superAdmin, openUni, fullSisuAccess } = specialGroup
const userFromDb = await User.findOne({ where: { username } })
if (!userFromDb) {
throw new Error(`User ${username} not found`)
@@ -166,7 +166,6 @@ const updateAccessGroups = async (
...(currentAccessGroups.includes('teachers') ? ['teachers'] : []),
...(superAdmin || currentAccessGroups.includes('admin') ? ['admin'] : []),
...(openUni ? ['openUniSearch'] : []),
...(katselmusViewer ? ['katselmusViewer'] : []),
...(fullSisuAccess ? ['fullSisuAccess'] : []),
] as Role[]
+2 -1
View File
@@ -1,7 +1,8 @@
import { InferAttributes } from 'sequelize'
import { User } from '../models/user'
import { Language, Role } from '../shared/types'
import { Language } from '../shared/language'
import { Role } from '../shared/types'
import { DetailedProgrammeRights } from './detailedProgrammeRights'
export type ExpandedUser = InferAttributes<User> & {
+519 -223
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -13,12 +13,14 @@
"@emotion/styled": "^11.13.0",
"@mui/icons-material": "^6.1.6",
"@mui/material": "^6.1.6",
"@mui/x-date-pickers": "^7.22.3",
"@reduxjs/toolkit": "^2.3.0",
"@sentry/browser": "^8.37.1",
"axios": "^0.28.1",
"highcharts": "^11.4.8",
"immer": "^10.1.1",
"lodash": "^4.17.21",
"material-react-table": "^3.0.1",
"moment": "^2.30.1",
"prop-types": "^15.8.1",
"query-string": "^9.1.1",
@@ -42,9 +44,9 @@
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/react-highcharts": "^16.0.6",
"@vitejs/plugin-react-swc": "^3.7.1",
"@vitejs/plugin-react-swc": "^3.7.2",
"typescript": "^5.6.3",
"vite": "^5.4.11"
"vite": "^6.0.1"
},
"overrides": {
"react-highcharts": {
@@ -2,62 +2,55 @@ import { createContext, useCallback, useContext, useEffect, useState, ReactNode
import { useGetAuthorizedUserQuery } from '@/redux/auth'
import { useModifyLanguageMutation } from '@/redux/users'
import { DEFAULT_LANG, LANGUAGE_CODES } from '@/shared/language'
import { Language } from '@/shared/types'
import { DEFAULT_LANG, isLanguage, Language } from '@/shared/language'
import { Name } from '@/shared/types'
const LanguageContext = createContext<[Language, (language: Language) => void]>([DEFAULT_LANG, () => {}])
type LanguageContextType = [Language, React.Dispatch<React.SetStateAction<Language>>]
const LanguageContext = createContext<LanguageContextType>([DEFAULT_LANG, () => {}])
LanguageContext.displayName = 'Language'
export const LanguageProvider = ({ children }: { children: ReactNode }) => {
let language: Language = DEFAULT_LANG
const [state, setState] = useState<Language>(language)
const [state, setState] = useState<Language>(DEFAULT_LANG)
const user = useGetAuthorizedUserQuery()
if (user) {
language = user.language
}
useEffect(() => {
if (LANGUAGE_CODES.includes(user?.language)) {
setState(user.language)
const userLanguage = user?.language
if (isLanguage(userLanguage)) {
setState(userLanguage)
}
}, [language])
}, [user?.language])
return <LanguageContext.Provider value={[state, setState]}>{children}</LanguageContext.Provider>
}
const getTextInWithLanguage = (texts: Record<string, string>, language: Language) => {
if (texts) {
return texts[language] || texts.fi || texts.en || texts.sv || Object.values(texts)[0]
}
return null
}
export const useLanguage = () => {
const [state, setState] = useContext(LanguageContext)
const [changeLanguage] = useModifyLanguageMutation()
const getTextIn = useCallback((text: Record<string, string>) => getTextInWithLanguage(text, state), [state])
const getTextIn = useCallback(
(text: Name | null | undefined, lang?: string): string | null | undefined => {
if (text == null || Object.values(text).length === 0) {
return null
}
const languageToUse = lang ?? state
return text[languageToUse] ?? text.fi ?? text.en ?? text.sv ?? Object.values(text)[0]
},
[state]
)
const getTextInWrapped = (item: any, lang?: Language) => {
if (!lang) {
return getTextIn(item)
}
return getTextInWithLanguage(item, lang)
}
const setLanguage = (newLanguage: Language) => {
if (!LANGUAGE_CODES.includes(newLanguage)) {
throw new Error('Illegal language code passed to useLanguage hook!')
const setLanguage = async (newLanguage: unknown) => {
if (!isLanguage(newLanguage)) {
throw new Error(`Illegal language code ${newLanguage} passed to useLanguage hook!`)
}
setState(newLanguage)
void changeLanguage({ language: newLanguage })
await changeLanguage({ language: newLanguage })
}
return {
language: state,
setLanguage,
getTextIn: getTextInWrapped,
getTextIn,
}
}
@@ -16,7 +16,7 @@ export const getSemestersPresentFunctions = ({
}) => {
if (allSemesters?.length === 0 || !filteredStudents)
return {
getSemesterEnrollmentsContent: () => {},
getSemesterEnrollmentsContent: () => null,
getSemesterEnrollmentsVal: () => {},
getFirstSemester: () => {},
getLastSemester: () => {},
@@ -20,6 +20,7 @@ import { Updater } from '@/components/Updater'
import { Users } from '@/components/Users'
import { languageCenterViewEnabled } from '@/conf'
import { Changelog } from '@/pages/Changelog'
import { CloseToGraduation as NewCloseToGraduation } from '@/pages/CloseToGraduation'
import { Feedback } from '@/pages/Feedback'
import { FrontPage } from '@/pages/FrontPage'
import { University } from '@/pages/University'
@@ -130,6 +131,7 @@ export const Routes = () => (
/>
)}
<ProtectedRoute component={University} exact path={routes.university} />
<ProtectedRoute component={NewCloseToGraduation} exact path="/close-to-graduation/new" />
<ProtectedRoute
component={CloseToGraduation}
exact
@@ -23,10 +23,7 @@ export const StudyTrackSelector = ({ track, setTrack, studyTracks }: StudyTrackS
}
const getOptionName = (studyTrack: string | Name) => {
if (studyTrack !== 'All students of the programme') {
return getTextIn(studyTrack)
}
return studyTrack
return typeof studyTrack === 'string' ? studyTrack : getTextIn(studyTrack)
}
const studyTrackOptions = Object.entries(studyTracks)
@@ -0,0 +1,8 @@
import { Check as CheckIcon } from '@mui/icons-material'
import { Box } from '@mui/material'
export const CheckIconWithTitle = ({ visible, title }: { visible: boolean; title?: string }) => (
<Box sx={{ display: 'flex', justifyContent: 'center' }} title={title}>
{visible ? <CheckIcon color="success" fontSize="small" /> : <Box sx={{ width: '20px', height: '20px' }} />}
</Box>
)
@@ -0,0 +1,168 @@
import {
Alert,
Button,
Box,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
Typography,
} from '@mui/material'
import { pick, sampleSize } from 'lodash'
import { MaterialReactTable, MRT_ColumnDef, useMaterialReactTable } from 'material-react-table'
import { useCallback, useMemo } from 'react'
import { utils, writeFile } from 'xlsx'
import { ISO_DATE_FORMAT } from '@/constants/date'
import { getTimestamp, reformatDate } from '@/util/timeAndDate'
export const ExportToExcelDialog = ({
open,
onClose,
exportColumns,
exportData,
featureName,
}: {
open: boolean
onClose: () => void
exportColumns: MRT_ColumnDef<any>[]
exportData: Record<string, unknown>[]
featureName: string
}) => {
const columns = useMemo<
MRT_ColumnDef<{
header: string
sample: React.ReactNode[]
}>[]
>(
() => [
{
accessorKey: 'header',
header: 'Column',
},
{
accessorKey: 'sample',
header: 'Sample values',
Cell: ({ cell }) => (
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 1 }}>{cell.getValue<React.ReactNode>()}</Box>
),
},
],
[]
)
const sampleFromExportData = useMemo(() => sampleSize(exportData, 10), [exportData])
const getSampleDataForColumn = useCallback(
(column: string) => {
const sample: React.ReactNode[] = []
for (let i = 0; i < sampleFromExportData.length; i++) {
const row = sampleFromExportData[i]
const value = row[column]
if (
typeof value !== 'string' &&
typeof value !== 'number' &&
typeof value !== 'boolean' &&
!(value instanceof Date)
) {
continue
}
let displayValue: string | number
if (value instanceof Date) {
displayValue = reformatDate(value, ISO_DATE_FORMAT)
} else if (typeof value === 'boolean') {
displayValue = value ? 'TRUE' : 'FALSE'
} else {
displayValue = value
}
sample.push(
<Box
component="span"
key={i}
sx={theme => ({
backgroundColor: theme.palette.grey[50],
border: `1px solid ${theme.palette.grey[300]}`,
borderRadius: 1,
color: theme.palette.text.secondary,
paddingX: 0.5,
})}
>
{displayValue}
</Box>
)
}
return sample
},
[sampleFromExportData]
)
const data = useMemo(
() =>
exportColumns.map(column => ({
header: column.header,
sample: getSampleDataForColumn(column.header),
})),
[exportColumns, getSampleDataForColumn]
)
const table = useMaterialReactTable({
columns,
data,
enableBottomToolbar: false,
enableColumnActions: false,
enablePagination: false,
enableRowSelection: true,
enableSorting: false,
enableTopToolbar: false,
getRowId: row => row.header,
initialState: {
density: 'compact',
},
})
const selectedColumns = Object.keys(table.getState().rowSelection)
const handleExport = () => {
const worksheet = utils.json_to_sheet(exportData.map(row => pick(row, selectedColumns)))
const workbook = utils.book_new()
utils.book_append_sheet(workbook, worksheet)
writeFile(workbook, `oodikone_${featureName}_${getTimestamp()}.xlsx`)
}
return (
<Dialog fullWidth maxWidth="md" onClose={onClose} open={open}>
<DialogTitle>Export to Excel</DialogTitle>
<DialogContent>
<DialogContentText component="div" sx={{ marginBottom: 2 }}>
Exporting {exportData.length} rows into an Excel (.xlsx) file. Choose which columns you want to include in the
generated file from the list below.
{selectedColumns.length === 0 ? (
<Alert severity="warning" sx={{ mt: 2 }}>
<Typography variant="body2">
Please select at least one column to export. You can select all columns by clicking the checkbox in the
table header.
</Typography>
</Alert>
) : (
<Alert severity="info" sx={{ mt: 2 }}>
<Typography variant="body2">
You have selected {selectedColumns.length} column{selectedColumns.length === 1 ? '' : 's'}.
</Typography>
</Alert>
)}
</DialogContentText>
<MaterialReactTable table={table} />
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button color="primary" disabled={selectedColumns.length === 0} onClick={handleExport} variant="contained">
Export
</Button>
</DialogActions>
</Dialog>
)
}
@@ -23,7 +23,7 @@ export const InfoBox = ({
}
>
{mini ? (
<IconButton data-cy={`${cypress}InfoBoxButton`}>
<IconButton data-cy={`${cypress}InfoBoxButton`} sx={{ padding: 0 }}>
<HelpOutline fontSize="small" />
</IconButton>
) : (
@@ -0,0 +1,20 @@
import { Person as PersonIcon } from '@mui/icons-material'
import { IconButton, Stack } from '@mui/material'
import { Link } from 'react-router-dom'
import { ExternalLink } from './Footer/ExternalLink'
export const StudentInfoItem = ({ sisPersonId, studentNumber }: { sisPersonId: string; studentNumber: string }) => (
<Stack direction="row" justifyContent="space-between">
{studentNumber}
<Stack direction="row" gap={1}>
<IconButton component={Link} sx={{ padding: 0 }} target="_blank" to={`/students/${studentNumber}`}>
<PersonIcon color="primary" fontSize="small" />
</IconButton>
<ExternalLink
href={`https://sisu.helsinki.fi/tutor/role/staff/student/${sisPersonId}/basic/basic-info`}
text="Sisu"
/>
</Stack>
</Stack>
)
@@ -0,0 +1,10 @@
import { Stack } from '@mui/material'
import { InfoBox } from './InfoBox'
export const TableHeaderWithTooltip = ({ header, tooltipText }: { header: string; tooltipText: string }) => (
<Stack direction="row" gap={1}>
<span style={{ whiteSpace: 'wrap', minWidth: '80px' }}>{header}</span>
<InfoBox content={tooltipText} mini />
</Stack>
)
@@ -20,10 +20,12 @@ export const Toggle = ({
infoBoxContent?: string
}) => {
return (
<Stack alignItems="center" direction="row">
<Typography color={disabled ? 'text.secondary' : 'text.primary'}>{firstLabel}</Typography>
<Switch checked={value} data-cy={cypress} disabled={disabled} onChange={() => setValue(!value)} />
<Typography color={disabled ? 'text.secondary' : 'text.primary'}>{secondLabel}</Typography>
<Stack alignItems="center" direction="row" gap={1}>
<Stack alignItems="center" direction="row">
<Typography color={disabled ? 'text.secondary' : 'text.primary'}>{firstLabel}</Typography>
<Switch checked={value} data-cy={cypress} disabled={disabled} onChange={() => setValue(!value)} />
<Typography color={disabled ? 'text.secondary' : 'text.primary'}>{secondLabel}</Typography>
</Stack>
{infoBoxContent && <InfoBox content={infoBoxContent} mini />}
</Stack>
)
@@ -0,0 +1,275 @@
import { Box, Container, Tab, Tabs, Typography } from '@mui/material'
import { MaterialReactTable, MRT_ColumnDef, useMaterialReactTable } from 'material-react-table'
import { useMemo, useState } from 'react'
import { closeToGraduationToolTips } from '@/common/InfoToolTips'
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
import { CheckIconWithTitle } from '@/components/material/CheckIconWithTitle'
import { ExportToExcelDialog } from '@/components/material/ExportToExcelDialog'
import { InfoBox } from '@/components/material/InfoBox'
import { PageTitle } from '@/components/material/PageTitle'
import { StudentInfoItem } from '@/components/material/StudentInfoItem'
import { TableHeaderWithTooltip } from '@/components/material/TableHeaderWithTooltip'
import { getSemestersPresentFunctions } from '@/components/PopulationStudents/StudentTable/GeneralTab/columnHelpers/semestersPresent'
import { ISO_DATE_FORMAT, LONG_DATE_TIME_FORMAT } from '@/constants/date'
import { useGetStudentsCloseToGraduationQuery } from '@/redux/closeToGraduation'
import { useGetSemestersQuery } from '@/redux/semesters'
import { getDefaultMRTOptions } from '@/util/getDefaultMRTOptions'
import { reformatDate } from '@/util/timeAndDate'
const NUMBER_OF_DISPLAYED_SEMESTERS = 6
export const CloseToGraduation = () => {
const { data: students } = useGetStudentsCloseToGraduationQuery()
const { data: semesterData } = useGetSemestersQuery()
const [selectedTab, setSelectedTab] = useState(0)
const [exportModalOpen, setExportModalOpen] = useState(false)
const [exportData, setExportData] = useState<Record<string, unknown>[]>([])
const { getTextIn, language } = useLanguage()
const allSemesters = Object.values(semesterData?.semesters ?? {})
const allSemestersMap = allSemesters.reduce<Record<string, (typeof allSemesters)[number]>>((acc, cur) => {
acc[cur.semestercode] = cur
return acc
}, {})
const { getSemesterEnrollmentsContent, getSemesterEnrollmentsVal } = getSemestersPresentFunctions({
getTextIn,
allSemesters,
allSemestersMap,
filteredStudents: students,
year: `${new Date().getFullYear() - Math.floor(NUMBER_OF_DISPLAYED_SEMESTERS / 2)}`,
programmeCode: null,
studentToSecondStudyrightEndMap: null,
studentToStudyrightEndMap: null,
semestersToAddToStart: null,
})
const columns = useMemo<MRT_ColumnDef<any>[]>(
() => [
{
accessorKey: 'student.studentNumber',
header: 'Student number',
enableColumnFilter: false,
Cell: ({ cell }) => (
<StudentInfoItem
sisPersonId={cell.row.original.student.sis_person_id}
studentNumber={cell.getValue<string>()}
/>
),
},
{
accessorKey: 'student.name',
header: 'Name',
},
{
accessorKey: 'student.phoneNumber',
header: 'Phone number',
},
{
accessorKey: 'student.email',
header: 'Email',
},
{
accessorKey: 'student.secondaryEmail',
header: 'Secondary email',
},
{
accessorFn: row => getTextIn(row.faculty),
header: 'Faculty',
id: 'faculty',
filterVariant: 'multi-select',
},
{
accessorFn: row => getTextIn(row.programme.name),
header: 'Programme',
id: 'programme',
filterVariant: 'multi-select',
},
{
accessorFn: row => getTextIn(row.programme.studyTrack),
id: 'studyTrack',
header: 'Study track',
filterVariant: 'multi-select',
},
{
accessorFn: row => new Date(row.studyright.startDate),
id: 'startOfStudyRight',
Cell: ({ cell }) => reformatDate(cell.getValue<Date>(), ISO_DATE_FORMAT),
header: 'Start of study right',
},
{
accessorFn: row => new Date(row.programme.startedAt),
id: 'startedInProgramme',
Cell: ({ cell }) => reformatDate(cell.getValue<Date>(), ISO_DATE_FORMAT),
header: 'Started in programme',
Header: (
<TableHeaderWithTooltip
header="Started in programme"
tooltipText="For students with only a study right in the master’s programme, this date is the same as 'Start of study right'. For students with study rights in both the bachelor’s and master’s programmes, this date represents when they started in the master’s programme (i.e. one day after graduating from the bachelor’s programme)."
/>
),
},
{
header: 'Completed credits – HOPS',
accessorKey: 'credits.hops',
filterVariant: 'range',
},
{
header: 'Completed credits – Total',
accessorKey: 'credits.all',
filterVariant: 'range',
},
{
header: 'BSc & MSc study right',
accessorKey: 'studyright.isBaMa',
filterVariant: 'checkbox',
Cell: ({ cell }) => <CheckIconWithTitle visible={cell.getValue<boolean>()} />,
Header: (
<TableHeaderWithTooltip
header="BSc & MSc study right"
tooltipText="Indicates whether the student has been granted the study right to complete both a bachelor's and a master's degree."
/>
),
},
{
header: 'Curriculum period',
accessorKey: 'curriculumPeriod',
Header: (
<TableHeaderWithTooltip
header="Curriculum period"
tooltipText="The curriculum period the student has chosen for their primary study plan"
/>
),
},
{
header: 'Semester enrollments',
accessorFn: row => getSemesterEnrollmentsVal(row.student, row.studyright),
Cell: ({ row }) => getSemesterEnrollmentsContent(row.original.student, row.original.studyright),
id: 'semesterEnrollments',
},
{
header: 'Semesters absent',
accessorKey: 'numberOfAbsentSemesters',
filterVariant: 'range',
Header: (
<TableHeaderWithTooltip
header="Semesters absent"
tooltipText="The number of semesters the student has been absent (both statutory and non-statutory absences) during their study right"
/>
),
},
{
header: 'Thesis completed',
accessorFn: row => row.thesisInfo != null,
id: 'thesisCompleted',
filterVariant: 'checkbox',
Cell: ({ cell, row }) => (
<CheckIconWithTitle
title={
cell.getValue()
? [
`Attainment date: ${reformatDate(row.original.thesisInfo.attainmentDate, ISO_DATE_FORMAT)}`,
`Course code: ${row.original.thesisInfo.courseCode}`,
`Grade: ${row.original.thesisInfo.grade}`,
].join('\n')
: undefined
}
visible={cell.getValue<boolean>()}
/>
),
Header: (
<TableHeaderWithTooltip
header="Thesis completed"
tooltipText="The thesis attainment must be linked to the correct study right. You can see the attainment date, course code, and grade by hovering over the check mark."
/>
),
},
{
header: 'Latest attainment date – HOPS',
accessorFn: row => new Date(row.attainmentDates.latestHops),
id: 'latestAttainmentDateHops',
Cell: ({ cell }) => reformatDate(cell.getValue<Date>(), ISO_DATE_FORMAT),
Header: (
<TableHeaderWithTooltip
header="Latest attainment date – HOPS"
tooltipText="The date when the student last completed a course in their primary study plan"
/>
),
},
{
header: 'Latest attainment date – Total',
accessorFn: row => new Date(row.attainmentDates.latestTotal),
id: 'latestAttainmentDateTotal',
Cell: ({ cell }) => reformatDate(cell.getValue<Date>(), ISO_DATE_FORMAT),
Header: (
<TableHeaderWithTooltip
header="Latest attainment date – Total"
tooltipText="The date when the student last completed any course at the university"
/>
),
},
{
header: 'Earliest attainment date – HOPS',
accessorFn: row => new Date(row.attainmentDates.earliestHops),
id: 'earlistAttainmentDateHops',
Cell: ({ cell }) => reformatDate(cell.getValue<Date>(), ISO_DATE_FORMAT),
Header: (
<TableHeaderWithTooltip
header="Earliest attainment date – HOPS"
tooltipText="The date when the student first completed a course in their primary study plan"
/>
),
},
],
[getSemesterEnrollmentsContent, getSemesterEnrollmentsVal, getTextIn]
)
const displayedData = (selectedTab === 0 ? students?.bachelor : students?.masterAndLicentiate) ?? []
const defaultOptions = getDefaultMRTOptions(setExportData, setExportModalOpen, language)
const table = useMaterialReactTable({
...defaultOptions,
columns,
data: displayedData,
initialState: {
...defaultOptions.initialState,
sorting: [{ id: 'programme', desc: false }],
columnVisibility: {
'student.name': false,
'student.phoneNumber': false,
'student.email': false,
'student.secondaryEmail': false,
semesterEnrollments: false,
},
},
})
return (
<Container maxWidth="xl">
<ExportToExcelDialog
exportColumns={columns}
exportData={exportData}
featureName="students_close_to_graduation"
onClose={() => setExportModalOpen(false)}
open={exportModalOpen}
/>
<PageTitle title="Students close to graduation" />
<Box sx={{ my: 3, textAlign: 'center' }}>
<InfoBox content={closeToGraduationToolTips} />
</Box>
<Tabs centered onChange={(_event, value) => setSelectedTab(value)} value={selectedTab}>
<Tab label="Bachelor's programmes" />
<Tab label="Master's and licentiate's programmes" />
</Tabs>
<Box sx={{ minHeight: '1.25rem' }}>
{students?.lastUpdated ? (
<Typography color="textSecondary" variant="body2">
Last updated: {reformatDate(students.lastUpdated, LONG_DATE_TIME_FORMAT)}
</Typography>
) : null}
</Box>
<MaterialReactTable key={selectedTab} table={table} />
</Container>
)
}
@@ -1,8 +1,14 @@
import { RTKApi } from '@/apiConnection'
type CloseToGraduationData = {
bachelor: any[]
lastUpdated: string
masterAndLicentiate: any[]
}
const closeToGraduationApi = RTKApi.injectEndpoints({
endpoints: builder => ({
getStudentsCloseToGraduation: builder.query({
getStudentsCloseToGraduation: builder.query<CloseToGraduationData, void>({
query: () => 'close-to-graduation',
}),
}),
-13
View File
@@ -1,13 +0,0 @@
import { RTKApi } from '@/apiConnection'
const semestersApi = RTKApi.injectEndpoints({
endpoints: builder => ({
getSemesters: builder.query({
query: () => '/semesters/codes',
providesTags: ['Semester'],
}),
}),
overrideExisting: false,
})
export const { useGetSemestersQuery } = semestersApi
+35
View File
@@ -0,0 +1,35 @@
import { RTKApi } from '@/apiConnection'
import { Name } from '@/shared/types'
type SemestersData = {
semesters: Record<
string,
{
enddate: string
name: Name
semestercode: number
startdate: string
yearcode: number
}
>
years: Record<
string,
{
enddate: string
startdate: string
yearcode: number
yearname: string
}
>
}
const semestersApi = RTKApi.injectEndpoints({
endpoints: builder => ({
getSemesters: builder.query<SemestersData, void>({
query: () => '/semesters/codes',
providesTags: ['Semester'],
}),
}),
})
export const { useGetSemestersQuery } = semestersApi
@@ -0,0 +1,79 @@
import { Download as DownloadIcon } from '@mui/icons-material'
import { Button } from '@mui/material'
import { MRT_RowData, MRT_TableOptions, MRT_Row } from 'material-react-table'
import { MRT_Localization_EN } from 'material-react-table/locales/en'
import { MRT_Localization_FI } from 'material-react-table/locales/fi'
import { DEFAULT_LANG } from '@/shared/language'
export const getDefaultMRTOptions = <TData extends MRT_RowData>(
setExportData: (data: Record<string, unknown>[]) => void,
setExportModalOpen: (value: boolean) => void,
language = DEFAULT_LANG
): Partial<MRT_TableOptions<TData>> => {
const handleExportRows = (rows: MRT_Row<TData>[]) => {
const exportedData = rows.map(row => {
const rowData: Record<string, unknown> = {}
for (const cell of row.getAllCells()) {
const { header } = cell.column.columnDef
const value = cell.getValue()
rowData[header] = value
}
return rowData
})
setExportData(exportedData)
setExportModalOpen(true)
}
return {
enableColumnOrdering: true,
enableColumnPinning: true,
enableFacetedValues: true,
enableStickyHeader: true,
initialState: {
density: 'compact',
pagination: {
pageSize: 100,
pageIndex: 0,
},
},
localization: language === 'fi' ? MRT_Localization_FI : MRT_Localization_EN,
muiTableHeadCellProps: {
sx: theme => ({
verticalAlign: 'middle',
borderWidth: '1px 1px 1px 0',
borderStyle: 'solid',
borderColor: theme.palette.grey[300],
}),
},
muiTableHeadRowProps: {
sx: {
boxShadow: 'none',
},
},
muiTableBodyCellProps: {
sx: theme => ({
borderRight: `1px solid ${theme.palette.grey[300]}`,
}),
},
muiTableBodyProps: {
sx: theme => ({
'& tr:nth-of-type(odd) > td': {
backgroundColor: theme.palette.grey[100],
},
}),
},
renderTopToolbarCustomActions: ({ table }) => (
<Button
disabled={table.getPrePaginationRowModel().rows.length === 0}
onClick={() => handleExportRows(table.getPrePaginationRowModel().rows)}
startIcon={<DownloadIcon />}
variant="contained"
>
Export to Excel
</Button>
),
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ export const isWithinSixMonths = (date: string) => moment(date) > moment().subtr
export const momentFromFormat = (date: string, format: string) => moment(date, format)
export const reformatDate = (date: string, outputFormat: string) => {
export const reformatDate = (date: string | Date | null | undefined, outputFormat: string) => {
if (!date) {
return 'Unavailable'
}
+6
View File
@@ -1,3 +1,9 @@
export const DEFAULT_LANG = 'fi'
export const LANGUAGE_CODES = ['fi', 'en', 'sv'] as const
export const LANGUAGE_TEXTS = { fi: 'suomi', en: 'English', sv: 'svenska' } as const
export type Language = (typeof LANGUAGE_CODES)[number]
export const isLanguage = (lang: unknown): lang is Language => {
return typeof lang === 'string' && LANGUAGE_CODES.includes(lang as Language)
}
-1
View File
@@ -1,5 +1,4 @@
export type { AllProgressStatsRequest, AllProgressStatsResponse } from './faculty'
export type { Language } from './language'
export type { Name, NameWithCode } from './name'
export type { Release } from './release'
export type { Role } from './role'
-1
View File
@@ -1 +0,0 @@
export type Language = 'en' | 'fi' | 'sv'