diff --git a/admin-frontend/package.json b/admin-frontend/package.json index 8dd58561..0948c65d 100644 --- a/admin-frontend/package.json +++ b/admin-frontend/package.json @@ -25,6 +25,7 @@ "@testing-library/user-event": "^14.5.2", "@types/jest": "^29.5.12", "@types/node": "^16.18.97", + "@types/papaparse": "^5.5.2", "@types/react": "18.3.17", "@types/react-dom": "^18.3.0", "@typescript-eslint/eslint-plugin": "^6.15.0", @@ -71,6 +72,7 @@ "i18next-browser-languagedetector": "^8.0.0", "jwt-decode": "^4.0.0", "notistack": "^3.0.1", + "papaparse": "^5.5.4", "react": "18.3.1", "react-dom": "~18.2.0", "react-i18next": "^14.1.2", diff --git a/admin-frontend/src/components/DataTable/DataTable.tsx b/admin-frontend/src/components/DataTable/DataTable.tsx index a7e825d4..ece1dabc 100644 --- a/admin-frontend/src/components/DataTable/DataTable.tsx +++ b/admin-frontend/src/components/DataTable/DataTable.tsx @@ -19,10 +19,12 @@ import { Popover, Divider, Pagination, + Button, } from "@mui/material"; import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; import FilterAltIcon from "@mui/icons-material/FilterAlt"; +import DownloadIcon from "@mui/icons-material/Download"; import { useTranslation } from "react-i18next"; import { useSortableData } from "src/hooks/useSortableData"; @@ -42,6 +44,13 @@ export interface SearchConfig { onChange: (value: string) => void; } +export interface ExportConfig { + label: string; + onExport: () => void; + /** When true, the export button is rendered but disabled (e.g. nothing to export or still loading) */ + disabled?: boolean; +} + export interface ColumnDef { key: keyof T; label: string; @@ -76,6 +85,8 @@ export interface DataTableProps { emptyMessage?: string; /** When provided, renders a search field above the table */ search?: SearchConfig; + /** When provided, renders an export button at the end of the toolbar row above the table */ + exportButton?: ExportConfig; // Pagination page?: number; totalPages?: number; @@ -257,6 +268,7 @@ function DataTable({ skeletonRows = 8, emptyMessage, search, + exportButton, page, totalPages, onPageChange, @@ -469,16 +481,38 @@ function DataTable({ // ── Render ──────────────────────────────────────────────────────────────── return ( <> - {search && ( - - search.onChange(e.target.value)} - slotProps={{ htmlInput: { "aria-label": search.ariaLabel } }} - sx={{ width: 280 }} - /> + {(search || exportButton) && ( + + {search && ( + search.onChange(e.target.value)} + slotProps={{ htmlInput: { "aria-label": search.ariaLabel } }} + sx={{ width: 280 }} + /> + )} + {exportButton && ( + + )} )} Promise; } +interface CsvColumn { + header: string; + getValue: (row: T) => string | number | null | undefined; +} + +const downloadCsvFile = (filename: string, csvContent: string): void => { + const blob = new Blob([`${csvContent}`], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +}; + const fracComplete = (s: string): boolean => { const m = s.trim().match(/^(\d+)\s*\/\s*(\d+)$/); return !!m && m[1] === m[2] && +m[2] > 0; @@ -51,6 +69,7 @@ const InstructorStudentsTable: React.FC = ({ modules, treatmentGroups, filteredRows, + sortedRows, pagedRows, pageSize, totalItems, @@ -74,6 +93,64 @@ const InstructorStudentsTable: React.FC = ({ return key ? t(key) : PLACEHOLDER_SYMBOL; }; + const handleDownloadCsv = () => { + const csvColumns: CsvColumn[] = [ + { header: t("instructorDashboard.studentsTable.headers.id"), getValue: (row) => row.id }, + { header: t("instructorDashboard.studentsTable.headers.studentName"), getValue: (row) => row.studentName }, + { header: t("instructorDashboard.studentsTable.headers.programme"), getValue: (row) => row.programme }, + { + header: t("instructorDashboard.studentsTable.headers.qualificationType"), + getValue: (row) => row.qualificationType, + }, + { header: t("instructorDashboard.studentsTable.headers.year"), getValue: (row) => row.year }, + { header: t("instructorDashboard.studentsTable.headers.gender"), getValue: (row) => row.gender }, + ...(treatmentGroups.length > 0 + ? [ + { + header: t("instructorDashboard.studentsTable.headers.treatmentGroup"), + getValue: (row: InstructorStudentRow) => row.treatmentGroup ?? "", + }, + ] + : []), + { header: t("instructorDashboard.studentsTable.headers.lastLogin"), getValue: (row) => row.lastLogin }, + { + header: t("instructorDashboard.studentsTable.headers.lastActiveModule"), + getValue: (row) => formatModuleLabel(row.lastActiveModuleId), + }, + { + header: t("instructorDashboard.studentsTable.headers.careerReadinessStarted"), + getValue: (row) => row.careerReadinessStarted, + }, + { + header: t("instructorDashboard.studentsTable.headers.careerReadinessCompleted"), + getValue: (row) => row.careerReadinessCompleted, + }, + { + header: t("instructorDashboard.studentsTable.headers.skillsInterestsExplored"), + getValue: (row) => t(`instructorDashboard.studentsTable.statusLabels.${row.skillsDiscoveryStatus}`), + }, + { + header: t("instructorDashboard.studentsTable.headers.careerExplorer"), + getValue: (row) => + row.careerExplorerMessagesSent !== null && row.careerExplorerMessagesSent >= 2 + ? t("instructorDashboard.studentsTable.statusLabels.started") + : t("instructorDashboard.studentsTable.statusLabels.not_started"), + }, + ]; + + const csv = Papa.unparse({ + fields: csvColumns.map((column) => column.header), + data: sortedRows.map((row) => + csvColumns.map((column) => { + const value = column.getValue(row); + return value === null || value === undefined ? "" : String(value); + }) + ), + }); + const date = new Date().toISOString().slice(0, 10); + downloadCsvFile(`students_export_${date}.csv`, csv); + }; + const columns: ColumnDef[] = [ { key: "studentName", @@ -272,6 +349,11 @@ const InstructorStudentsTable: React.FC = ({ value: nameSearch, onChange: setNameSearch, }} + exportButton={{ + label: t("instructorDashboard.studentsTable.downloadCsv"), + onExport: handleDownloadCsv, + disabled: loading || sortedRows.length === 0, + }} skeletonRows={8} emptyMessage={filteredRows.length === 0 ? t("instructorDashboard.studentsTable.empty") : undefined} ariaLabel={t("instructorDashboard.studentsTable.ariaLabel")} diff --git a/admin-frontend/src/hooks/useInstructorStudentsTableState.ts b/admin-frontend/src/hooks/useInstructorStudentsTableState.ts index 723af7f0..5f0f016c 100644 --- a/admin-frontend/src/hooks/useInstructorStudentsTableState.ts +++ b/admin-frontend/src/hooks/useInstructorStudentsTableState.ts @@ -237,6 +237,7 @@ export function useInstructorStudentsTableState( modules, treatmentGroups, filteredRows, + sortedRows, pagedRows, pageSize, totalItems: sortedRows.length, diff --git a/admin-frontend/src/i18n/locales/en-GB/translation.json b/admin-frontend/src/i18n/locales/en-GB/translation.json index 9c3508a6..ce9d5fd3 100644 --- a/admin-frontend/src/i18n/locales/en-GB/translation.json +++ b/admin-frontend/src/i18n/locales/en-GB/translation.json @@ -354,7 +354,9 @@ "studentsTable": { "ariaLabel": "Instructor students table", "empty": "No students match the current filters.", + "downloadCsv": "Download CSV", "headers": { + "id": "ID", "studentName": "Student Name", "programme": "Programme", "qualificationType": "Qualification Type", diff --git a/admin-frontend/src/i18n/locales/en-US/translation.json b/admin-frontend/src/i18n/locales/en-US/translation.json index 8ce98d7f..b8c5a305 100644 --- a/admin-frontend/src/i18n/locales/en-US/translation.json +++ b/admin-frontend/src/i18n/locales/en-US/translation.json @@ -353,7 +353,9 @@ "studentsTable": { "ariaLabel": "Instructor students table", "empty": "No students match the current filters.", + "downloadCsv": "Download CSV", "headers": { + "id": "ID", "studentName": "Student Name", "programme": "Programme", "qualificationType": "Qualification Type", diff --git a/admin-frontend/src/i18n/locales/es-AR/translation.json b/admin-frontend/src/i18n/locales/es-AR/translation.json index 9e92405b..a172de41 100644 --- a/admin-frontend/src/i18n/locales/es-AR/translation.json +++ b/admin-frontend/src/i18n/locales/es-AR/translation.json @@ -361,7 +361,9 @@ "studentsTable": { "ariaLabel": "Mesa de alumnos instructores", "empty": "Ningún estudiante coincide con los filtros actuales.", + "downloadCsv": "Descargar CSV", "headers": { + "id": "ID", "studentName": "Nombre del estudiante", "programme": "Programa", "qualificationType": "Tipo de Calificación", diff --git a/admin-frontend/src/i18n/locales/es-ES/translation.json b/admin-frontend/src/i18n/locales/es-ES/translation.json index c3b08611..24eb5784 100644 --- a/admin-frontend/src/i18n/locales/es-ES/translation.json +++ b/admin-frontend/src/i18n/locales/es-ES/translation.json @@ -361,7 +361,9 @@ "studentsTable": { "ariaLabel": "Mesa de alumnos instructores", "empty": "Ningún estudiante coincide con los filtros actuales.", + "downloadCsv": "Descargar CSV", "headers": { + "id": "ID", "studentName": "Nombre del estudiante", "programme": "Programa", "qualificationType": "Tipo de Calificación", diff --git a/admin-frontend/src/i18n/locales/ny-ZM/translation.json b/admin-frontend/src/i18n/locales/ny-ZM/translation.json index 519e30b8..ad2b3510 100644 --- a/admin-frontend/src/i18n/locales/ny-ZM/translation.json +++ b/admin-frontend/src/i18n/locales/ny-ZM/translation.json @@ -361,7 +361,9 @@ "studentsTable": { "ariaLabel": "Gome la ophunzira a Mlangizi", "empty": "Palibe ophunzira omwe akufanana ndi zosefera pano.", + "downloadCsv": "Tsitsani CSV", "headers": { + "id": "ID", "studentName": "Dzina la Wophunzira", "programme": "Pulogalamu", "qualificationType": "Mtundu wa Mayeso", diff --git a/admin-frontend/src/i18n/locales/pt-MZ/translation.json b/admin-frontend/src/i18n/locales/pt-MZ/translation.json index 4784d4d3..ccc0e7ff 100644 --- a/admin-frontend/src/i18n/locales/pt-MZ/translation.json +++ b/admin-frontend/src/i18n/locales/pt-MZ/translation.json @@ -352,7 +352,9 @@ "studentsTable": { "ariaLabel": "Tabela de estudantes do instrutor", "empty": "Nenhum estudante corresponde aos filtros atuais.", + "downloadCsv": "Descarregar CSV", "headers": { + "id": "ID", "studentName": "Nome do Estudante", "programme": "Programa", "qualificationType": "Tipo de Qualificação", diff --git a/admin-frontend/src/i18n/locales/sw-KE/translation.json b/admin-frontend/src/i18n/locales/sw-KE/translation.json index 050508f9..7c38e928 100644 --- a/admin-frontend/src/i18n/locales/sw-KE/translation.json +++ b/admin-frontend/src/i18n/locales/sw-KE/translation.json @@ -361,7 +361,9 @@ "studentsTable": { "ariaLabel": "Jedwali la wanafunzi wa mwalimu", "empty": "Hakuna wanafunzi wanaolingana na vichujio vya sasa.", + "downloadCsv": "Pakua CSV", "headers": { + "id": "ID", "studentName": "Jina la Mwanafunzi", "programme": "Mpango", "qualificationType": "Aina ya Sifa", diff --git a/admin-frontend/yarn.lock b/admin-frontend/yarn.lock index 80322b5d..2a0b3730 100644 --- a/admin-frontend/yarn.lock +++ b/admin-frontend/yarn.lock @@ -4809,6 +4809,13 @@ resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz#56e2cc26c397c038fab0e3a917a12d5c5909e901" integrity sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA== +"@types/papaparse@^5.5.2": + version "5.5.2" + resolved "https://registry.yarnpkg.com/@types/papaparse/-/papaparse-5.5.2.tgz#cb450a1cd183deb43728e593eb1ac2da60f4fa4d" + integrity sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA== + dependencies: + "@types/node" "*" + "@types/parse-json@^4.0.0": version "4.0.2" resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.2.tgz#5950e50960793055845e956c427fc2b0d70c5239" @@ -12046,6 +12053,11 @@ pako@~0.2.0: resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75" integrity sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA== +papaparse@^5.5.4: + version "5.5.4" + resolved "https://registry.yarnpkg.com/papaparse/-/papaparse-5.5.4.tgz#7923f375a5d43850fd313160371c9567e5608c11" + integrity sha512-SwzWD9gl/ElwYLCI0nUja1mFJzjq2D8ziShfNBa7zCHzkOozeOGDwHWQ+tvCzEZcewecWZ5U7kUopDnG+DFYEQ== + param-case@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/param-case/-/param-case-3.0.4.tgz#7d17fe4aa12bde34d4a77d91acfb6219caad01c5"