diff --git a/src/components/ExamEditor/Exercises/Writing/index.tsx b/src/components/ExamEditor/Exercises/Writing/index.tsx index 46fc87a6..27ae951f 100644 --- a/src/components/ExamEditor/Exercises/Writing/index.tsx +++ b/src/components/ExamEditor/Exercises/Writing/index.tsx @@ -20,6 +20,9 @@ interface Props { const Writing: React.FC = ({ sectionId, exercise, module, index }) => { const { currentModule, dispatch } = useExamEditorStore(); + const { type, academic_url } = useExamEditorStore( + (state) => state.modules[currentModule] + ); const { generating, genResult, state } = useExamEditorStore( (state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)! ); @@ -64,11 +67,11 @@ const Writing: React.FC = ({ sectionId, exercise, module, index }) => { } }, onPractice: () => { - const newState = { - ...state, + const newState = { + ...state, isPractice: !local.isPractice }; - setLocal((prev) => ({...prev, isPractice: !local.isPractice})) + setLocal((prev) => ({ ...prev, isPractice: !local.isPractice })) dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } }); } }); @@ -96,7 +99,7 @@ const Writing: React.FC = ({ sectionId, exercise, module, index }) => { <>
= ({ sectionId, exercise, module, index }) => {
{loading ? : - ( - editing ? ( -
- setPrompt(text)} - placeholder="Instructions ..." - /> + <> + { + editing ? ( +
+ setPrompt(text)} + placeholder="Instructions ..." + /> +
+ ) : ( +

+ {prompt === "" ? "Instructions ..." : prompt} +

+ ) + } + {academic_url && sectionId == 1 && ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Visual Information +
- ) : ( -

- {prompt === "" ? "Instructions ..." : prompt} -

- ) - )} + )} + + }
); diff --git a/src/components/ExamEditor/SettingsEditor/Shared/Generate.ts b/src/components/ExamEditor/SettingsEditor/Shared/Generate.ts index fbd9cbe8..b48bafec 100644 --- a/src/components/ExamEditor/SettingsEditor/Shared/Generate.ts +++ b/src/components/ExamEditor/SettingsEditor/Shared/Generate.ts @@ -8,6 +8,7 @@ import { Module } from "@/interfaces"; interface GeneratorConfig { method: 'GET' | 'POST'; queryParams?: Record; + files?: Record; body?: Record; } @@ -66,18 +67,60 @@ export function generate( const url = `/api/exam/generate/${module}/${sectionId}${queryString ? `?${queryString}` : ''}`; - const request = config.method === 'POST' - ? axios.post(url, config.body) - : axios.get(url); + let body = null; + console.log(config.files); + if (config.files && Object.keys(config.files).length > 0 && config.method === 'POST') { + const formData = new FormData(); - request - .then((result) => { - playSound("check"); - setGeneratedResult(level ? levelSectionId! : sectionId, type, mapData(result.data), level); - }) - .catch((error) => { - setGenerating(sectionId, undefined, level, true); - playSound("error"); - toast.error("Something went wrong! Try to generate again."); - }) + const buildForm = async () => { + await Promise.all( + Object.entries(config.files ?? {}).map(async ([key, blobUrl]) => { + const response = await fetch(blobUrl); + const blob = await response.blob(); + const file = new File([blob], key, { type: blob.type }); + formData.append(key, file); + }) + ); + + if (config.body) { + Object.entries(config.body).forEach(([key, value]) => { + formData.append(key, value as string); + }); + } + return formData; + }; + + buildForm().then(form => { + body = form; + + const request = axios.post(url, body, { headers: { 'Content-Type': 'multipart/form-data' } }); + request + .then((result) => { + playSound("check"); + setGeneratedResult(level ? levelSectionId! : sectionId, type, mapData(result.data), level); + }) + .catch((error) => { + setGenerating(sectionId, undefined, level, true); + playSound("error"); + toast.error("Something went wrong! Try to generate again."); + }); + }); + } else { + body = config.body; + + const request = config.method === 'POST' + ? axios.post(url, body, { headers: { 'Content-Type': 'application/json' } }) + : axios.get(url); + + request + .then((result) => { + playSound("check"); + setGeneratedResult(level ? levelSectionId! : sectionId, type, mapData(result.data), level); + }) + .catch((error) => { + setGenerating(sectionId, undefined, level, true); + playSound("error"); + toast.error("Something went wrong! Try to generate again."); + }); + } } \ No newline at end of file diff --git a/src/components/ExamEditor/SettingsEditor/writing/components.tsx b/src/components/ExamEditor/SettingsEditor/writing/components.tsx index e2c6b0e6..9bacbf41 100644 --- a/src/components/ExamEditor/SettingsEditor/writing/components.tsx +++ b/src/components/ExamEditor/SettingsEditor/writing/components.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from "react"; +import React, { useCallback, useRef, useState } from "react"; import Dropdown from "../Shared/SettingsDropdown"; import Input from "@/components/Low/Input"; import { generate } from "../Shared/Generate"; @@ -6,6 +6,8 @@ import GenerateBtn from "../Shared/GenerateBtn"; import { LevelSectionSettings, WritingSectionSettings } from "@/stores/examEditor/types"; import useExamEditorStore from "@/stores/examEditor"; import { WritingExercise } from "@/interfaces/exam"; +import clsx from "clsx"; +import { FaFileUpload } from "react-icons/fa"; interface Props { @@ -15,69 +17,155 @@ interface Props { level?: boolean; } -const WritingComponents: React.FC = ({localSettings, updateLocalAndScheduleGlobal, level}) => { - const { currentModule } = useExamEditorStore(); +const WritingComponents: React.FC = ({ localSettings, updateLocalAndScheduleGlobal, level }) => { + const { currentModule, dispatch } = useExamEditorStore(); const { difficulty, focusedSection, + type, + academic_url } = useExamEditorStore((store) => store.modules["writing"]); - const generatePassage = useCallback((sectionId: number) => { - generate( - sectionId, - currentModule, - "writing", - { - method: 'GET', - queryParams: { - difficulty, - ...(localSettings.writingTopic && { topic: localSettings.writingTopic }) - } - }, - (data: any) => [{ - prompt: data.question - }] - ); + if (type === "academic" && academic_url !== undefined && sectionId == 1) { + generate( + sectionId, + currentModule, + "writing", + { + method: 'POST', + queryParams: { + difficulty, + type: type! + }, + files: { + file: academic_url!, + } + }, + (data: any) => [{ + prompt: data.question + }] + ) + } else { + generate( + sectionId, + currentModule, + "writing", + { + method: 'GET', + queryParams: { + difficulty, + type: type!, + ...(localSettings.writingTopic && { topic: localSettings.writingTopic }) + } + }, + (data: any) => [{ + prompt: data.question + }] + ); + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [localSettings.writingTopic, difficulty]); + }, [localSettings.writingTopic, difficulty, academic_url]); const onTopicChange = useCallback((writingTopic: string) => { updateLocalAndScheduleGlobal({ writingTopic }); }, [updateLocalAndScheduleGlobal]); + const fileInputRef = useRef(null); + + const triggerFileInput = () => { + fileInputRef.current?.click(); + }; + + const handleFileUpload = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + const blobUrl = URL.createObjectURL(file); + if (academic_url !== undefined) { + URL.revokeObjectURL(academic_url); + } + dispatch({ type: "UPDATE_MODULE", payload: { updates: { academic_url: blobUrl } } }); + } + }; + return ( <> - updateLocalAndScheduleGlobal({ isWritingTopicOpen: isOpen }, false)} - contentWrapperClassName={level ? `border border-ielts-writing`: ''} + open={localSettings.isImageUploadOpen} + setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isImageUploadOpen: isOpen }, false)} + contentWrapperClassName={level ? `border border-ielts-writing` : ''} > -
-
- - + + Upload a graph, chart or diagram + + +
-
- + className={clsx( + "flex items-center w-[140px] px-4 py-2 text-white rounded-xl transition-colors duration-300 text-lg disabled:cursor-not-allowed", + `bg-ielts-writing/70 border border-ielts-writing hover:bg-ielts-writing disabled:bg-ielts-writing/40`, + )} + onClick={triggerFileInput} + > +
+ + Upload +
+
-
+ } + { + (type !== "academic" || (type === "academic" && academic_url !== undefined)) && updateLocalAndScheduleGlobal({ isWritingTopicOpen: isOpen }, false)} + contentWrapperClassName={level ? `border border-ielts-writing` : ''} + > + +
+ {type !== "academic" ? +
+ + +
+ : +
+ + Generate instructions based on the uploaded image. + +
+ } +
+ +
+
+
+ } ); }; diff --git a/src/components/ExamEditor/SettingsEditor/writing/index.tsx b/src/components/ExamEditor/SettingsEditor/writing/index.tsx index 0d1aab5f..6f52c78e 100644 --- a/src/components/ExamEditor/SettingsEditor/writing/index.tsx +++ b/src/components/ExamEditor/SettingsEditor/writing/index.tsx @@ -8,7 +8,6 @@ import { useRouter } from "next/router"; import { usePersistentExamStore } from "@/stores/exam"; import openDetachedTab from "@/utils/popout"; import { WritingExam, WritingExercise } from "@/interfaces/exam"; -import { v4 } from "uuid"; import axios from "axios"; import { playSound } from "@/utils/sound"; import { toast } from "react-toastify"; @@ -25,7 +24,8 @@ const WritingSettings: React.FC = () => { isPrivate, sections, focusedSection, - type + type, + academic_url } = useExamEditorStore((store) => store.modules["writing"]); const states = sections.flatMap((s) => s.state) as WritingExercise[]; @@ -58,8 +58,16 @@ const WritingSettings: React.FC = () => { const openTab = () => { setExam({ - exercises: sections.map((s) => { + exercises: sections.map((s, index) => { const exercise = s.state as WritingExercise; + if (type === "academic" && index == 0 && academic_url) { + console.log("Added the URL"); + exercise["attachment"] = { + url: academic_url, + description: "Visual Information" + } + } + return { ...exercise, intro: s.settings.currentIntro, @@ -79,36 +87,66 @@ const WritingSettings: React.FC = () => { openDetachedTab("popout?type=Exam&module=writing", router) } - const submitWriting = () => { - const exam: WritingExam = { - exercises: sections.map((s) => { - const exercise = s.state as WritingExercise; - return { - ...exercise, - intro: localSettings.currentIntro, - category: localSettings.category - }; - }), - minTimer, - module: "writing", - id: title, - isDiagnostic: false, - variant: undefined, - difficulty, - private: isPrivate, - type: type! - }; + const submitWriting = async () => { + if (title === "") { + toast.error("Enter a title for the exam!"); + return; + } + try { + let firebase_url: string | undefined = undefined; + if (type === "academic" && academic_url) { + const formData = new FormData(); + const fetchedBlob = await fetch(academic_url); + const blob = await fetchedBlob.blob(); + formData.append('file', blob); - axios - .post(`/api/exam/reading`, exam) - .then((result) => { - playSound("sent"); - toast.success(`Submitted Exam ID: ${result.data.id}`); - }) - .catch((error) => { - console.log(error); - toast.error(error.response.data.error || "Something went wrong while submitting, please try again later."); - }) + const response = await axios.post('/api/storage', formData, { + params: { + directory: 'writing_attachments' + }, + headers: { + 'Content-Type': 'multipart/form-data' + } + }); + firebase_url = response.data.urls[0]; + } + + const exam: WritingExam = { + exercises: sections.map((s, index) => { + const exercise = s.state as WritingExercise; + if (index == 0 && firebase_url) { + exercise["attachment"] = { + url: firebase_url, + description: "Visual Information" + } + } + + return { + ...exercise, + intro: localSettings.currentIntro, + category: localSettings.category + }; + }), + minTimer, + module: "writing", + id: title, + isDiagnostic: false, + variant: undefined, + difficulty, + private: isPrivate, + type: type! + }; + + const result = await axios.post(`/api/exam/writing`, exam) + playSound("sent"); + toast.success(`Submitted Exam ID: ${result.data.id}`); + + } catch (error: any) { + console.error('Error submitting exam:', error); + toast.error( + "Something went wrong while submitting, please try again later." + ); + } } return ( diff --git a/src/components/UserImportSummary/ExcelError.tsx b/src/components/UserImportSummary/ExcelError.tsx new file mode 100644 index 00000000..20245371 --- /dev/null +++ b/src/components/UserImportSummary/ExcelError.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { FiAlertCircle } from "react-icons/fi"; +import Dropdown from '../Dropdown'; +import { errorsByRows, ExcelError } from '@/utils/excel.errors'; + +const ParseExcelErrors: React.FC<{ errors: ExcelError[] }> = (props) => { + const errorsByRow = errorsByRows(props.errors); + + const ErrorTitle = (row: string) => ( +
+ +

+ Errors found in row {row} +

+
+ ); + + return ( +
+ {Object.entries(errorsByRow).map(([row, rowErrors]) => ( +
+ +
+ {rowErrors.required.length > 0 && ( +
+
+ Missing values: +
+
+ + On columns:  + + + {rowErrors.required.join(', ')} + +
+
+ )} + + {rowErrors.other.length > 0 && ( +
+ {rowErrors.other.map((error, index) => ( +
+
+ {error.error}: +
+
+ Value + + {error.value} + + in column + + {error.column} + + is invalid! +
+
+ ))} +
+ )} +
+
+
+ ))} +
+ ); +}; + +export default ParseExcelErrors; diff --git a/src/components/UserImportSummary/index.tsx b/src/components/UserImportSummary/index.tsx new file mode 100644 index 00000000..ad58846d --- /dev/null +++ b/src/components/UserImportSummary/index.tsx @@ -0,0 +1,298 @@ +import React, { useState, useMemo } from 'react'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; +import { + FaCheckCircle, + FaTimesCircle, + FaExclamationCircle, + FaInfoCircle, + FaUsers, + FaExclamationTriangle +} from 'react-icons/fa'; +import { UserImport } from '@/interfaces/IUserImport'; +import Modal from '../Modal'; +import ParseExcelErrors from './ExcelError'; +import { errorsByRows, ExcelError } from '@/utils/excel.errors'; +import UserTable from '../UserTable'; + +interface Props { + parsedExcel: { rows?: any[]; errors?: any[] }, + newUsers: UserImport[], + enlistedUsers: UserImport[], + duplicateRows?: { duplicates: DuplicatesMap, count: number } +} + +export interface DuplicatesMap { + studentID: Map; + email: Map; + passport_id: Map; + phone: Map; +} + +interface ClassroomCounts { + [key: string]: number; +} + + +const UserImportSummary: React.FC = ({ parsedExcel, newUsers, enlistedUsers, duplicateRows }) => { + const [showErrorsModal, setShowErrorsModal] = useState(false); + const [showDuplicatesModal, setShowDuplicatesModal] = useState(false); + const [showEnlistedModal, setShowEnlistedModal] = useState(false); + const [showClassroomModal, setShowClassromModal] = useState(false); + + const classroomCounts = useMemo(() => { + return newUsers.reduce((acc: ClassroomCounts, user) => { + const group = user.groupName; + acc[group] = (acc[group] || 0) + 1; + return acc; + }, {}); + }, [newUsers]); + + const errorCount = Object.entries(errorsByRows(parsedExcel.errors as ExcelError[])).length || 0; + + const fieldMapper = { + "studentID": "Student ID", + "passport_id": "Passport/National ID", + "phone": "Phone Number", + "email": "E-mail" + } + + return ( + <> + + + Import Summary + + +
+
+ + + {`${parsedExcel.rows?.length} total spreadsheet rows`} + {parsedExcel.rows && parsedExcel.rows.filter(row => row === null).length > 0 && ( + + {` (${parsedExcel.rows.filter(row => row === null).length} empty)`} + + )} + +
+
+
+ {newUsers.length > 0 ? ( + + ) : ( + + )} + {`${newUsers.length} new user${newUsers.length > 1 ? "s" : ''} to import`} +
+ {newUsers.length > 0 && ( + + )} +
+ + {enlistedUsers.length > 0 && ( +
+
+ + {`${enlistedUsers.length} already registered user${enlistedUsers.length > 1 ? "s" : ''}`} +
+ +
+ )} + + {(duplicateRows && duplicateRows.count > 0) && ( +
+
+ + {duplicateRows.count} duplicate entries in file +
+ +
+ )} + + {errorCount > 0 && ( +
+
+ + {errorCount} invalid rows +
+ +
+ )} +
+ {(enlistedUsers.length > 0 || (duplicateRows && duplicateRows.count > 0) || errorCount > 0) && ( +
+
+
+ +
+
+

+ The following will be excluded from import: +

+
    + {duplicateRows && duplicateRows.count > 0 && ( +
  • +
    + {duplicateRows.count} rows with duplicate values +
    +
    + {(Object.keys(duplicateRows.duplicates) as Array).map(field => { + const duplicates = Array.from(duplicateRows.duplicates[field].entries()) + .filter((entry) => entry[1].length > 1); + if (duplicates.length === 0) return null; + return ( +
    + {field}: rows { + duplicates.map(([_, rows]) => rows.join(', ')).join('; ') + } +
    + ); + })} +
    +
  • + )} + {errorCount > 0 && ( +
  • +
    + {errorCount} rows with invalid or missing information +
    +
    + Rows: {Object.keys(errorsByRows(parsedExcel.errors || [])).join(', ')} +
    +
  • + )} +
+
+
+
+ )} +
+
+ + setShowErrorsModal(false)}> + <> +
+ +

Validation Errors

+
+ + +
+ + setShowEnlistedModal(false)}> + <> +
+ +

Already Registered Users

+
+
+ +
+ +
+ + setShowDuplicatesModal(false)}> + <> +
+ +

Duplicate Entries

+
+ {duplicateRows && + < div className="space-y-6"> + {(Object.keys(duplicateRows.duplicates) as Array).map(field => { + const duplicates = Array.from(duplicateRows!.duplicates[field].entries()) + .filter((entry): entry is [string, number[]] => entry[1].length > 1); + + if (duplicates.length === 0) return null; + + return ( +
+
+

+ {fieldMapper[field]} duplicates +

+ + {duplicates.length} {duplicates.length === 1 ? 'duplicate' : 'duplicates'} + +
+ +
+ {duplicates.map(([value, rows]) => ( +
+
+
+ {value} +
+ Appears in rows: + + {rows.join(', ')} + +
+
+ + {rows.length} occurrences + +
+
+ ))} +
+
+ ); + })} +
+ } + + + + setShowClassromModal(false)}> + <> +
+ +

Students by Classroom

+
+
+ {Object.entries(classroomCounts).map(([classroom, count]) => ( +
+
+ {classroom} +
+
+ {count} + students +
+
+ ))} +
+ +
+ + ); +}; + +export default UserImportSummary; diff --git a/src/pages/(admin)/Lists/BatchCreateUser.tsx b/src/pages/(admin)/Lists/BatchCreateUser.tsx index a2c46f02..7c389b8f 100644 --- a/src/pages/(admin)/Lists/BatchCreateUser.tsx +++ b/src/pages/(admin)/Lists/BatchCreateUser.tsx @@ -6,14 +6,13 @@ import { toast } from "react-toastify"; import { useFilePicker } from "use-file-picker"; import readXlsxFile from "read-excel-file"; import Modal from "@/components/Modal"; -import { BsQuestionCircleFill } from "react-icons/bs"; import { PermissionType } from "@/interfaces/permissions"; import moment from "moment"; import { checkAccess, getTypesOfUser } from "@/utils/permissions"; import Checkbox from "@/components/Low/Checkbox"; import ReactDatePicker from "react-datepicker"; import clsx from "clsx"; -import countryCodes from "country-codes-list"; +import countryCodes, { CountryData } from "country-codes-list"; import { User, Type as UserType } from "@/interfaces/user"; import { Type, UserImport } from "../../../interfaces/IUserImport"; import UserTable from "../../../components/UserTable"; @@ -22,6 +21,7 @@ import Select from "@/components/Low/Select"; import { IoInformationCircleOutline } from "react-icons/io5"; import { FaFileDownload } from "react-icons/fa"; import { HiOutlineDocumentText } from "react-icons/hi"; +import UserImportSummary, { DuplicatesMap } from "@/components/UserImportSummary"; const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/); @@ -71,11 +71,14 @@ interface Props { onFinish: () => void; } + export default function BatchCreateUser({ user, entities = [], permissions, onFinish }: Props) { const [infos, setInfos] = useState([]); + const [parsedExcel, setParsedExcel] = useState<{ rows?: any[]; errors?: any[] }>({ rows: undefined, errors: undefined }); const [duplicatedUsers, setDuplicatedUsers] = useState([]); const [newUsers, setNewUsers] = useState([]); + const [duplicatedRows, setDuplicatedRows] = useState<{ duplicates: DuplicatesMap, count: number }>(); const [isLoading, setIsLoading] = useState(false); const [expiryDate, setExpiryDate] = useState( @@ -96,61 +99,187 @@ export default function BatchCreateUser({ user, entities = [], permissions, onFi if (!isExpiryDateEnabled) setExpiryDate(null); }, [isExpiryDateEnabled]); + const schema = { + 'First Name': { + prop: 'firstName', + type: String, + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') { + throw new Error('First Name cannot be empty') + } + return true + } + }, + 'Last Name': { + prop: 'lastName', + type: String, + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') { + throw new Error('Last Name cannot be empty') + } + return true + } + }, + 'Student ID': { + prop: 'studentID', + type: String, + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') { + throw new Error('Student ID cannot be empty') + } + return true + } + }, + 'Passport/National ID': { + prop: 'passport_id', + type: String, + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') { + throw new Error('Passport/National ID cannot be empty') + } + return true + } + }, + 'E-mail': { + prop: 'email', + required: true, + type: (value: any) => { + if (!value || value.trim() === '') { + throw new Error('Email cannot be empty') + } + if (!EMAIL_REGEX.test(value.trim())) { + throw new Error('Invalid Email') + } + return value + } + }, + 'Phone Number': { + prop: 'phone', + type: Number, + required: true, + validate: (value: number) => { + if (value === null || value === undefined || String(value).trim() === '') { + throw new Error('Phone Number cannot be empty') + } + return true + } + }, + 'Classroom Name': { + prop: 'group', + type: String, + required: true, + validate: (value: string) => { + if (!value || value.trim() === '') { + throw new Error('Classroom Name cannot be empty') + } + return true + } + }, + 'Country': { + prop: 'country', + type: (value: any) => { + if (!value || value.trim() === '') { + throw new Error('Country cannot be empty') + } + const validCountry = countryCodes.findOne("countryCode" as any, value.toUpperCase()) || + countryCodes.all().find((x) => x.countryNameEn.toLowerCase() === value.toLowerCase()); + if (!validCountry) { + throw new Error('Invalid Country/Country Code') + } + return validCountry; + }, + required: true + } + } + useEffect(() => { if (filesContent.length > 0) { const file = filesContent[0]; - readXlsxFile(file.content).then((rows) => { - try { - const information = uniqBy( - rows - .map((row) => { - const [firstName, lastName, studentID, passport_id, email, phone, group, country] = row as string[]; - const countryItem = - countryCodes.findOne("countryCode" as any, country.toUpperCase()) || - countryCodes.all().find((x) => x.countryNameEn.toLowerCase() === country.toLowerCase()); - - return EMAIL_REGEX.test(email.toString().trim()) - ? { - email: email.toString().trim().toLowerCase(), - name: `${firstName ?? ""} ${lastName ?? ""}`.trim(), - type: type, - passport_id: passport_id?.toString().trim() || undefined, - groupName: group, - studentID, - entity, - demographicInformation: { - country: countryItem?.countryCode, - passport_id: passport_id?.toString().trim() || undefined, - phone: phone.toString(), - }, - } - : undefined; - }) - .filter((x) => !!x) as typeof infos, - (x) => x.email, - ); - - if (information.length === 0) { - toast.error( - "Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!", - ); - return clear(); - } - - setInfos(information); - } catch (e) { - console.log(e) - - toast.error( - "Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!", - ); - return clear(); - } - }); + readXlsxFile( + file.content, { schema, ignoreEmptyRows: false }) + .then((data) => { + setParsedExcel(data) + }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [filesContent]); + useEffect(() => { + if (parsedExcel.rows) { + const duplicates: DuplicatesMap = { + studentID: new Map(), + email: new Map(), + passport_id: new Map(), + phone: new Map() + }; + const duplicateValues = new Set(); + const duplicateRowIndices = new Set(); + + const errorRowIndices = new Set( + parsedExcel.errors?.map(error => error.row) || [] + ); + + parsedExcel.rows.forEach((row, index) => { + if (!errorRowIndices.has(index + 2)) { + (Object.keys(duplicates) as Array).forEach(field => { + if (row !== null) { + const value = row[field]; + if (value) { + if (!duplicates[field].has(value)) { + duplicates[field].set(value, [index + 2]); + } else { + const existingRows = duplicates[field].get(value); + if (existingRows) { + existingRows.push(index + 2); + duplicateValues.add(value); + existingRows.forEach(rowNum => duplicateRowIndices.add(rowNum)); + } + } + } + } + }); + } + }); + + const infos = parsedExcel.rows + .map((row, index) => { + if (errorRowIndices.has(index + 2) || duplicateRowIndices.has(index + 2) || row === null) { + return undefined; + } + const { firstName, lastName, studentID, passport_id, email, phone, group, country } = row; + if (!email || !EMAIL_REGEX.test(email.toString().trim())) { + return undefined; + } + + return { + email: email.toString().trim().toLowerCase(), + name: `${firstName ?? ""} ${lastName ?? ""}`.trim(), + type: type, + passport_id: passport_id?.toString().trim() || undefined, + groupName: group, + studentID, + entity, + demographicInformation: { + country: country?.countryCode, + passport_id: passport_id?.toString().trim() || undefined, + phone: phone.toString(), + }, + } as UserImport; + }) + .filter((item): item is UserImport => item !== undefined); + + setDuplicatedRows({ duplicates, count: duplicateRowIndices.size }); + setInfos(infos); + setNewUsers([]); + setDuplicatedUsers([]); + } + }, [entity, parsedExcel, type]); + + useEffect(() => { const crossReferenceEmails = async () => { try { @@ -185,12 +314,12 @@ export default function BatchCreateUser({ user, entities = [], permissions, onFi if (!confirm(`You are about to ${[newUsersSentence, existingUsersSentence].filter((x) => !!x).join(" and ")}, are you sure you want to continue?`)) return; - /*Promise.all(duplicatedUsers.map(async (u) => await axios.post(`/api/invites`, {to: u.id, entity, from: user.id}))) - .then(() => toast.success(`Successfully invited ${duplicatedUsers.length} registered student(s)!`)) + Promise.all(newUsers.map(async (u) => await axios.post(`/api/invites`, { to: u.id, entity, from: user.id }))) + .then(() => toast.success(`Successfully invited ${newUsers.length} registered student(s)!`)) .finally(() => { if (newUsers.length === 0) setIsLoading(false); }); - */ + if (newUsers.length > 0) { setIsLoading(true); @@ -246,7 +375,7 @@ export default function BatchCreateUser({ user, entities = [], permissions, onFi be an Excel .xlsx document.
  • - only have a single spreadsheet with only the following 8 columns in the same order: + only have a single spreadsheet with the following exact same name columns:
    @@ -281,7 +410,7 @@ export default function BatchCreateUser({ user, entities = [], permissions, onFi all already registered e-mails will be ignored.
  • - the spreadsheet may have a header row with the format above, however, it is not necessary as long the columns are in the right order. + all rows which contain duplicate values in the columns: "Student ID", "Passport/National ID", "E-mail", will be ignored.
  • all of the e-mails in the file will receive an e-mail to join EnCoach with the role selected below. @@ -380,20 +509,16 @@ export default function BatchCreateUser({ user, entities = [], permissions, onFi ))} )} + {parsedExcel.rows !== undefined && } {newUsers.length !== 0 && (
    New Users:
    )} - {duplicatedUsers.length !== 0 && ( -
    - Duplicated Users: - -
    - )} + {(duplicatedUsers.length !== 0 && newUsers.length === 0) && The imported .csv only contains duplicated users!} diff --git a/src/pages/(exam)/ExamPage.tsx b/src/pages/(exam)/ExamPage.tsx index 922e7c1b..9a041a35 100644 --- a/src/pages/(exam)/ExamPage.tsx +++ b/src/pages/(exam)/ExamPage.tsx @@ -11,7 +11,7 @@ import Reading from "@/exams/Reading"; import Selection from "@/exams/Selection"; import Speaking from "@/exams/Speaking"; import Writing from "@/exams/Writing"; -import { Exam, LevelExam, UserSolution, Variant } from "@/interfaces/exam"; +import { Exam, LevelExam, UserSolution, Variant, WritingExam } from "@/interfaces/exam"; import { User } from "@/interfaces/user"; import { evaluateSpeakingAnswer, evaluateWritingAnswer } from "@/utils/evaluation"; import { getExam } from "@/utils/exams"; @@ -127,7 +127,7 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar = await Promise.all( exam.exercises.map(async (exercise, index) => { if (exercise.type === "writing") - await evaluateWritingAnswer(user.id, sessionId, exercise, index + 1, userSolutions.find((x) => x.exercise === exercise.id)!); + await evaluateWritingAnswer(user.id, sessionId, exercise, index + 1, userSolutions.find((x) => x.exercise === exercise.id)!, exercise.attachment?.url); if (exercise.type === "interactiveSpeaking" || exercise.type === "speaking") { await evaluateSpeakingAnswer( diff --git a/src/pages/api/exam/[module]/index.ts b/src/pages/api/exam/[module]/index.ts index 0e8d7eef..d54e7835 100644 --- a/src/pages/api/exam/[module]/index.ts +++ b/src/pages/api/exam/[module]/index.ts @@ -65,7 +65,8 @@ async function POST(req: NextApiRequest, res: NextApiResponse) { // Check whether the id of the exam matches another exam with different // owners, throw exception if there is, else allow editing const ownersSet = new Set(docSnap?.owners || []); - if (docSnap?.owners?.length === exam.owners.lenght && exam.owners.every((e: string) => ownersSet.has(e))) { + + if (docSnap !== null && docSnap?.owners?.length === exam.owners.lenght && exam.owners.every((e: string) => ownersSet.has(e))) { throw new Error("Name already exists"); } diff --git a/src/pages/api/exam/generate/[...module].ts b/src/pages/api/exam/generate/[...module].ts index 4ae0a692..73710469 100644 --- a/src/pages/api/exam/generate/[...module].ts +++ b/src/pages/api/exam/generate/[...module].ts @@ -31,6 +31,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) { if (!req.session.user) return res.status(401).json({ ok: false }); const queryParams = queryToURLSearchParams(req); + let endpoint = queryParams.getAll('module').join("/"); if (endpoint.startsWith("level")) { @@ -41,12 +42,27 @@ async function post(req: NextApiRequest, res: NextApiResponse) { endpoint = "reading/" } - const result = await axios.post(`${process.env.BACKEND_URL}/${endpoint}`, - req.body, - { - headers: { Authorization: `Bearer ${process.env.BACKEND_JWT}` }, - }, - ); + queryParams.delete('module'); + const queryString = queryParams.toString(); - res.status(200).json(result.data); -} \ No newline at end of file + const hasFiles = req.headers['content-type']?.startsWith('multipart/form-data'); + + // https://github.com/vercel/next.js/discussions/36153#discussioncomment-3029675 + // This just proxies the request + + const { data } = await axios.post( + `${process.env.BACKEND_URL}/${endpoint}${hasFiles ? '/attachment' : ''}${queryString.length > 0 ? `?${queryString}` : ''}`, req, { + responseType: "stream", + headers: { + "Content-Type": req.headers["content-type"], + Authorization: `Bearer ${process.env.BACKEND_JWT}`, + }, + }); + data.pipe(res); +} + +export const config = { + api: { + bodyParser: false, + }, +}; diff --git a/src/pages/generation.tsx b/src/pages/generation.tsx index fd519537..fdbc61e6 100644 --- a/src/pages/generation.tsx +++ b/src/pages/generation.tsx @@ -93,6 +93,11 @@ export default function Generation({ id, user, exam, examModule, permissions }: useEffect(() => { return () => { const state = modules; + + if (state.writing.academic_url) { + URL.revokeObjectURL(state.writing.academic_url); + } + state.listening.sections.forEach(section => { const listeningPart = section.state as ListeningPart; if (listeningPart.audio?.source) { diff --git a/src/stores/examEditor/defaults.ts b/src/stores/examEditor/defaults.ts index c1d66518..3b1f79e4 100644 --- a/src/stores/examEditor/defaults.ts +++ b/src/stores/examEditor/defaults.ts @@ -23,7 +23,8 @@ export const defaultSettings = (module: Module) => { return { ...baseSettings, writingTopic: '', - isWritingTopicOpen: false + isWritingTopicOpen: false, + isImageUploadOpen: false, } case 'reading': return { @@ -57,6 +58,7 @@ export const defaultSettings = (module: Module) => { isListeningDropdownOpen: false, isWritingTopicOpen: false, + isImageUploadOpen: false, writingTopic: '', isPassageOpen: false, diff --git a/src/stores/examEditor/types.ts b/src/stores/examEditor/types.ts index b2b1454a..22f66938 100644 --- a/src/stores/examEditor/types.ts +++ b/src/stores/examEditor/types.ts @@ -1,7 +1,6 @@ import { Difficulty, InteractiveSpeakingExercise, LevelPart, ListeningPart, ReadingPart, SpeakingExercise, WritingExercise } from "@/interfaces/exam"; import { Module } from "@/interfaces"; import Option from "@/interfaces/option"; -import { ExerciseConfig } from "@/components/ExamEditor/ExercisePicker/ExerciseWizard"; export interface GeneratedExercises { exercises: Record[]; @@ -42,6 +41,7 @@ export interface ListeningSectionSettings extends SectionSettings { export interface WritingSectionSettings extends SectionSettings { isWritingTopicOpen: boolean; writingTopic: string; + isImageUploadOpen: boolean; } export interface LevelSectionSettings extends SectionSettings { @@ -55,6 +55,7 @@ export interface LevelSectionSettings extends SectionSettings { // writing isWritingTopicOpen: boolean; + isImageUploadOpen: boolean; writingTopic: string; // reading @@ -116,6 +117,7 @@ export interface ModuleState { importing: boolean; edit: number[]; type?: "general" | "academic"; + academic_url?: string | undefined; } export interface Avatar { diff --git a/src/utils/evaluation.ts b/src/utils/evaluation.ts index 19fdd4c1..532e33b0 100644 --- a/src/utils/evaluation.ts +++ b/src/utils/evaluation.ts @@ -12,14 +12,16 @@ export const evaluateWritingAnswer = async ( exercise: WritingExercise, task: number, solution: UserSolution, + attachment?: string, ): Promise => { await axios.post("/api/evaluate/writing", { - question: `${exercise.prompt} ${exercise.attachment ? exercise.attachment.description : ""}`.replaceAll("\n", ""), + question: `${exercise.prompt}`.replaceAll("\n", ""), answer: solution.solutions[0].solution.trim().replaceAll("\n", " "), task, userId, sessionId, - exerciseId: exercise.id + exerciseId: exercise.id, + attachment, }); }; diff --git a/src/utils/excel.errors.ts b/src/utils/excel.errors.ts new file mode 100644 index 00000000..232441f7 --- /dev/null +++ b/src/utils/excel.errors.ts @@ -0,0 +1,27 @@ +export interface ExcelError { + type: string; + value: any; + error: string; + reason: string; + row: number; + column: string; +} + +export const errorsByRows = (errors: ExcelError[] ) => { + return errors.reduce((acc, error) => { + if (!acc[error.row]) { + acc[error.row] = { + required: [], + other: [] + }; + } + + if (error.error === 'required') { + acc[error.row].required.push(error.column); + } else { + acc[error.row].other.push(error); + } + + return acc; + }, {} as Record); +}