diff --git a/services/backend/src/services/populations/closeToGraduation.ts b/services/backend/src/services/populations/closeToGraduation.ts index 9f0ab0f14d..e1d3f889c2 100644 --- a/services/backend/src/services/populations/closeToGraduation.ts +++ b/services/backend/src/services/populations/closeToGraduation.ts @@ -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, facultyMap: Record) => { 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 = {} + 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) { diff --git a/services/frontend/package-lock.json b/services/frontend/package-lock.json index 01fd293860..151147eb16 100644 --- a/services/frontend/package-lock.json +++ b/services/frontend/package-lock.json @@ -10,12 +10,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", @@ -1060,6 +1062,101 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, + "node_modules/@mui/x-date-pickers": { + "version": "7.22.3", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.22.3.tgz", + "integrity": "sha512-shNp92IrST5BiVy2f4jbrmRaD32QhyUthjh1Oexvpcn0v6INyuWgxfodoTi5ZCnE5Ue5UVFSs4R9Xre0UbJ5DQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0", + "@mui/x-internals": "7.21.0", + "@types/react-transition-group": "^4.4.11", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.15.14 || ^6.0.0", + "@mui/system": "^5.15.14 || ^6.0.0", + "date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0", + "date-fns-jalali": "^2.13.0-0 || ^3.2.0-0", + "dayjs": "^1.10.7", + "luxon": "^3.0.2", + "moment": "^2.29.4", + "moment-hijri": "^2.1.2 || ^3.0.0", + "moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "date-fns": { + "optional": true + }, + "date-fns-jalali": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + }, + "moment-hijri": { + "optional": true + }, + "moment-jalaali": { + "optional": true + } + } + }, + "node_modules/@mui/x-date-pickers/node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@mui/x-internals": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.21.0.tgz", + "integrity": "sha512-94YNyZ0BhK5Z+Tkr90RKf47IVCW8R/1MvdUhh6MCQg6sZa74jsX+x+gEZ4kzuCqOsuyTyxikeQ8vVuCIQiP7UQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -1694,6 +1791,82 @@ "@swc/counter": "^0.1.3" } }, + "node_modules/@tanstack/match-sorter-utils": { + "version": "8.19.4", + "resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.19.4.tgz", + "integrity": "sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==", + "license": "MIT", + "dependencies": { + "remove-accents": "0.5.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-table": { + "version": "8.20.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.20.5.tgz", + "integrity": "sha512-WEHopKw3znbUZ61s9i0+i9g8drmDo6asTWbrQh8Us63DAk/M0FkmIqERew6P71HI75ksZ2Pxyuf4vvKh9rAkiA==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.20.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.10.6", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.10.6.tgz", + "integrity": "sha512-xaSy6uUxB92O8mngHZ6CvbhGuqxQ5lIZWCBy+FjhrbHmOwc6BnOnKkYm2FsB1/BpKw/+FVctlMbEtI+F6I1aJg==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.10.6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.20.5", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.20.5.tgz", + "integrity": "sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.10.6", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.10.6.tgz", + "integrity": "sha512-1giLc4dzgEKLMx5pgKjL6HlG5fjZMgCjzlKAlpr7yoUtetVPELgER1NtephAI910nMwfPTHNyWKSFmJdHkz2Cw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -2327,6 +2500,16 @@ "integrity": "sha512-5Tke9LuzZszC4osaFisxLIcw7xgNGz4Sy3Jc9pRMV+ydm6sYqsPYdU8ELOgpzGNrbrRNDRBtveoR5xS3SzneEA==", "license": "https://www.highcharts.com/license" }, + "node_modules/highlight-words": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/highlight-words/-/highlight-words-1.2.2.tgz", + "integrity": "sha512-Mf4xfPXYm8Ay1wTibCrHpNWeR2nUMynMVFkXCi4mbl+TEgmNOe+I4hV7W3OCZcSvzGL6kupaqpfHOemliMTGxQ==", + "license": "MIT", + "engines": { + "node": ">= 16", + "npm": ">= 8" + } + }, "node_modules/history": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", @@ -2542,6 +2725,34 @@ "loose-envify": "cli.js" } }, + "node_modules/material-react-table": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/material-react-table/-/material-react-table-3.0.1.tgz", + "integrity": "sha512-RP+bnpsOAH5j6zwP04u9HB37fyqbd6mVv9mkT4IUJC3e3gEqixZmkNdJMVM1ZVHoq7yIaM381xf22mpBVe0IaA==", + "license": "MIT", + "dependencies": { + "@tanstack/match-sorter-utils": "8.19.4", + "@tanstack/react-table": "8.20.5", + "@tanstack/react-virtual": "3.10.6", + "highlight-words": "1.2.2" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kevinvandy" + }, + "peerDependencies": { + "@emotion/react": ">=11.13", + "@emotion/styled": ">=11.13", + "@mui/icons-material": ">=6", + "@mui/material": ">=6", + "@mui/x-date-pickers": ">=7.15", + "react": ">=18.0", + "react-dom": ">=18.0" + } + }, "node_modules/mdast-util-from-markdown": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.1.tgz", @@ -3591,6 +3802,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remove-accents": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz", + "integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==", + "license": "MIT" + }, "node_modules/reselect": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.0.tgz", diff --git a/services/frontend/package.json b/services/frontend/package.json index 54ce81ca8e..fc8181e47a 100644 --- a/services/frontend/package.json +++ b/services/frontend/package.json @@ -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", diff --git a/services/frontend/src/components/PopulationStudents/StudentTable/GeneralTab/columnHelpers/semestersPresent.jsx b/services/frontend/src/components/PopulationStudents/StudentTable/GeneralTab/columnHelpers/semestersPresent.jsx index 8ec9c76898..5e3186db49 100644 --- a/services/frontend/src/components/PopulationStudents/StudentTable/GeneralTab/columnHelpers/semestersPresent.jsx +++ b/services/frontend/src/components/PopulationStudents/StudentTable/GeneralTab/columnHelpers/semestersPresent.jsx @@ -16,7 +16,7 @@ export const getSemestersPresentFunctions = ({ }) => { if (allSemesters?.length === 0 || !filteredStudents) return { - getSemesterEnrollmentsContent: () => {}, + getSemesterEnrollmentsContent: () => null, getSemesterEnrollmentsVal: () => {}, getFirstSemester: () => {}, getLastSemester: () => {}, diff --git a/services/frontend/src/components/Routes/index.jsx b/services/frontend/src/components/Routes/index.jsx index 56c126f71e..d8f9138b6c 100644 --- a/services/frontend/src/components/Routes/index.jsx +++ b/services/frontend/src/components/Routes/index.jsx @@ -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 = () => ( /> )} + ( + + {visible ? : } + +) diff --git a/services/frontend/src/components/material/ExportToExcelDialog.tsx b/services/frontend/src/components/material/ExportToExcelDialog.tsx new file mode 100644 index 0000000000..5868938518 --- /dev/null +++ b/services/frontend/src/components/material/ExportToExcelDialog.tsx @@ -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[] + exportData: Record[] + featureName: string +}) => { + const columns = useMemo< + MRT_ColumnDef<{ + header: string + sample: React.ReactNode[] + }>[] + >( + () => [ + { + accessorKey: 'header', + header: 'Column', + }, + { + accessorKey: 'sample', + header: 'Sample values', + Cell: ({ cell }) => ( + {cell.getValue()} + ), + }, + ], + [] + ) + 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( + ({ + backgroundColor: theme.palette.grey[50], + border: `1px solid ${theme.palette.grey[300]}`, + borderRadius: 1, + color: theme.palette.text.secondary, + paddingX: 0.5, + })} + > + {displayValue} + + ) + } + 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 ( + + Export to Excel + + + 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 ? ( + + + Please select at least one column to export. You can select all columns by clicking the checkbox in the + table header. + + + ) : ( + + + You have selected {selectedColumns.length} column{selectedColumns.length === 1 ? '' : 's'}. + + + )} + + + + + + + + + ) +} diff --git a/services/frontend/src/components/material/StudentInfoItem.tsx b/services/frontend/src/components/material/StudentInfoItem.tsx new file mode 100644 index 0000000000..ffd909801b --- /dev/null +++ b/services/frontend/src/components/material/StudentInfoItem.tsx @@ -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 }) => ( + + {studentNumber} + + + + + + + +) diff --git a/services/frontend/src/pages/CloseToGraduation/index.tsx b/services/frontend/src/pages/CloseToGraduation/index.tsx new file mode 100644 index 0000000000..f8ad87b190 --- /dev/null +++ b/services/frontend/src/pages/CloseToGraduation/index.tsx @@ -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[]>([]) + const { getTextIn, language } = useLanguage() + const allSemesters = Object.values(semesterData?.semesters ?? {}) + const allSemestersMap = allSemesters.reduce>((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[]>( + () => [ + { + accessorKey: 'student.studentNumber', + header: 'Student number', + enableColumnFilter: false, + Cell: ({ cell }) => ( + ()} + /> + ), + }, + { + 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(), ISO_DATE_FORMAT), + header: 'Start of study right', + }, + { + accessorFn: row => new Date(row.programme.startedAt), + id: 'startedInProgramme', + Cell: ({ cell }) => reformatDate(cell.getValue(), ISO_DATE_FORMAT), + header: 'Started in programme', + Header: ( + + ), + }, + { + 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 }) => ()} />, + Header: ( + + ), + }, + { + header: 'Curriculum period', + accessorKey: 'curriculumPeriod', + Header: ( + + ), + }, + { + 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: ( + + ), + }, + { + header: 'Thesis completed', + accessorFn: row => row.thesisInfo != null, + id: 'thesisCompleted', + filterVariant: 'checkbox', + Cell: ({ cell, row }) => ( + ()} + /> + ), + + Header: ( + + ), + }, + { + header: 'Latest attainment date – HOPS', + accessorFn: row => new Date(row.attainmentDates.latestHops), + id: 'latestAttainmentDateHops', + Cell: ({ cell }) => reformatDate(cell.getValue(), ISO_DATE_FORMAT), + Header: ( + + ), + }, + { + header: 'Latest attainment date – Total', + accessorFn: row => new Date(row.attainmentDates.latestTotal), + id: 'latestAttainmentDateTotal', + Cell: ({ cell }) => reformatDate(cell.getValue(), ISO_DATE_FORMAT), + Header: ( + + ), + }, + { + header: 'Earliest attainment date – HOPS', + accessorFn: row => new Date(row.attainmentDates.earliestHops), + id: 'earlistAttainmentDateHops', + Cell: ({ cell }) => reformatDate(cell.getValue(), ISO_DATE_FORMAT), + Header: ( + + ), + }, + ], + [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 ( + + setExportModalOpen(false)} + open={exportModalOpen} + /> + + + + + setSelectedTab(value)} value={selectedTab}> + + + + + {students?.lastUpdated ? ( + + Last updated: {reformatDate(students.lastUpdated, LONG_DATE_TIME_FORMAT)} + + ) : null} + + + + ) +} diff --git a/services/frontend/src/redux/closeToGraduation.js b/services/frontend/src/redux/closeToGraduation.ts similarity index 58% rename from services/frontend/src/redux/closeToGraduation.js rename to services/frontend/src/redux/closeToGraduation.ts index 6a8540082d..394b9974e5 100644 --- a/services/frontend/src/redux/closeToGraduation.js +++ b/services/frontend/src/redux/closeToGraduation.ts @@ -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({ query: () => 'close-to-graduation', }), }), diff --git a/services/frontend/src/redux/semesters.js b/services/frontend/src/redux/semesters.js deleted file mode 100644 index 3978504712..0000000000 --- a/services/frontend/src/redux/semesters.js +++ /dev/null @@ -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 diff --git a/services/frontend/src/redux/semesters.ts b/services/frontend/src/redux/semesters.ts new file mode 100644 index 0000000000..2a3bce547e --- /dev/null +++ b/services/frontend/src/redux/semesters.ts @@ -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({ + query: () => '/semesters/codes', + providesTags: ['Semester'], + }), + }), +}) + +export const { useGetSemestersQuery } = semestersApi diff --git a/services/frontend/src/util/getDefaultMRTOptions.tsx b/services/frontend/src/util/getDefaultMRTOptions.tsx new file mode 100644 index 0000000000..3a19370354 --- /dev/null +++ b/services/frontend/src/util/getDefaultMRTOptions.tsx @@ -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 = ( + setExportData: (data: Record[]) => void, + setExportModalOpen: (value: boolean) => void, + language = DEFAULT_LANG +): Partial> => { + const handleExportRows = (rows: MRT_Row[]) => { + const exportedData = rows.map(row => { + const rowData: Record = {} + + 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 }) => ( + + ), + } +} diff --git a/services/frontend/src/util/timeAndDate.ts b/services/frontend/src/util/timeAndDate.ts index e78d250447..3a5ed0e5ae 100644 --- a/services/frontend/src/util/timeAndDate.ts +++ b/services/frontend/src/util/timeAndDate.ts @@ -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' }