Merged in develop (pull request #133)

Develop
This commit is contained in:
Tiago Ribeiro
2025-01-06 21:32:31 +00:00
14 changed files with 490 additions and 497 deletions

View File

@@ -1,4 +1,3 @@
import useExamEditorStore from "@/stores/examEditor";
import ListeningContext from "./listening";
import ReadingContext from "./reading";
@@ -30,7 +29,7 @@ const LevelContext: React.FC<Props> = ({ sectionId }) => {
)}
{(readingSection || listeningSection || hasReadingContext) && (
<div className="space-y-4 mb-4">
{readingSection || hasReadingContext && <ReadingContext sectionId={sectionId} module="level" />}
{(readingSection !== undefined || hasReadingContext) && <ReadingContext sectionId={sectionId} module="level" />}
{listeningSection && <ListeningContext sectionId={sectionId} listeningSection={listeningSection} module="level" level={true} />}
</div>
)}

View File

@@ -11,11 +11,10 @@ import { Module } from "@/interfaces";
interface Props {
module: Module;
sectionId: number;
level?: boolean;
}
const ReadingContext: React.FC<Props> = ({ sectionId, module, level = false }) => {
const ReadingContext: React.FC<Props> = ({ sectionId, module }) => {
const { dispatch } = useExamEditorStore();
const sectionState = useExamEditorStore(
(state) => state.modules[module].sections.find((section) => section.sectionId === sectionId)!
@@ -55,7 +54,7 @@ const ReadingContext: React.FC<Props> = ({ sectionId, module, level = false }) =
}
});
useEffect(()=> {
useEffect(() => {
if (readingPart.text === undefined) {
setTitle('');
setContent('');
@@ -74,7 +73,9 @@ const ReadingContext: React.FC<Props> = ({ sectionId, module, level = false }) =
useEffect(() => {
const passageRes = levelGenResults.find((res) => res.generating === "passage");
const passageRes = [...levelGenResults].reverse()
.find((res) => res.generating === "passage");
if (levelGenResults && passageRes) {
setEditing(true);
setTitle(passageRes.result[0].title);

View File

@@ -6,7 +6,7 @@ import Dropdown from "@/components/Dropdown";
import useExamEditorStore from "@/stores/examEditor";
import Writing from "../../Exercises/Writing";
import Speaking from "../../Exercises/Speaking";
import { ReactElement, ReactNode, useEffect, useState } from "react";
import { ReactNode, useEffect } from "react";
import {
DndContext,
PointerSensor,
@@ -43,7 +43,7 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
const genResult = section?.genResult;
const generating = section?.generating;
const levelGenResults = section?.levelGenResults
const levelGenResults = section?.levelGenResults;
const levelGenerating = section?.levelGenerating;
const sectionState = section?.state;
@@ -66,134 +66,146 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
}, [genResult, dispatch, sectionId, currentModule]);
useEffect(() => {
if (levelGenResults && levelGenResults.some(res => res.generating.startsWith("exercises"))) {
const newExercises = levelGenResults
.filter(res => res.generating.startsWith("exercises"))
.map(res => res.result[0].exercises)
.flat();
const updates = [
{
type: "UPDATE_SECTION_STATE",
payload: {
sectionId,
module: "level",
update: {
exercises: [...(sectionState as ExamPart).exercises, ...newExercises]
}
}
},
{
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "levelGenerating",
value: levelGenerating?.filter(g => !g?.startsWith("exercises"))
}
},
{
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "levelGenResults",
value: levelGenResults.filter(res => !res.generating.startsWith("exercises"))
const handleExerciseGen = (
results: any[],
assignExercisesFn: (results: any[]) => any[],
{
sectionId,
currentModule,
sectionState,
levelGenerating,
levelGenResults
}: {
sectionId: number;
currentModule: string;
sectionState: ExamPart;
levelGenerating?: Generating[];
levelGenResults: any[];
}
) => {
const nonWritingOrSpeaking = results[0]?.generating.startsWith("exercises");
const updates = [
{
type: "UPDATE_SECTION_STATE",
payload: {
sectionId,
module: "level",
update: {
exercises: [
...sectionState.exercises,
...assignExercisesFn(results)
]
}
}
] as Action[];
},
{
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "levelGenerating",
value: levelGenerating?.filter(g =>
nonWritingOrSpeaking
? !g?.startsWith("exercises")
: !results.flatMap(res => res.generating as Generating).includes(g)
)
}
},
{
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "levelGenResults",
value: levelGenResults.filter(res =>
nonWritingOrSpeaking
? !res.generating.startsWith("exercises")
: !results.flatMap(res => res.generating as Generating).includes(res.generating)
)
}
}
] as Action[];
updates.forEach(update => dispatch(update));
};
updates.forEach(update => dispatch(update));
useEffect(() => {
if (levelGenResults && levelGenResults?.some(res => res.generating.startsWith("exercises"))) {
const results = levelGenResults.filter(res =>
res.generating.startsWith("exercises")
);
const assignExercises = (results: any[]) =>
results
.map(res => res.result[0].exercises)
.flat();
handleExerciseGen(
results,
assignExercises,
{
sectionId,
currentModule,
sectionState: sectionState as ExamPart,
levelGenerating,
levelGenResults
}
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelGenResults, sectionState, levelGenerating, sectionId, currentModule]);
useEffect(() => {
if (levelGenResults && levelGenResults.some(res => res.generating === "writing_letter" || res.generating === "writing_2")) {
const results = levelGenResults.filter(res => res.generating === "writing_letter" || res.generating === "writing_2");
if (levelGenResults && levelGenResults?.some(res =>
res.generating === "writing_letter" || res.generating === "writing_2"
)) {
const results = levelGenResults.filter(res =>
res.generating === "writing_letter" || res.generating === "writing_2"
);
const updates = [
const assignExercises = (results: any[]) =>
results.map(res => ({
...writingTask(res.generating === "writing_letter" ? 1 : 2),
prompt: res.result[0].prompt,
variant: res.generating === "writing_letter" ? "letter" : "essay"
}) as WritingExercise);
handleExerciseGen(
results,
assignExercises,
{
type: "UPDATE_SECTION_STATE",
payload: {
sectionId,
module: "level",
update: {
exercises: [...(sectionState as ExamPart).exercises,
...results.map((res) => {
return {
...writingTask(res.generating === "writing_letter" ? 1 : 2),
prompt: res.result[0].prompt,
variant: res.generating === "writing_letter" ? "letter" : "essay"
} as WritingExercise;
})
]
}
}
},
{
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "levelGenerating",
value: levelGenerating?.filter(g => !results.flatMap(res => res.generating as Generating).includes(g))
}
},
{
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "levelGenResults",
value: levelGenResults.filter(res => !results.flatMap(res => res.generating as Generating).includes(res.generating))
}
sectionId,
currentModule,
sectionState: sectionState as ExamPart,
levelGenerating,
levelGenResults
}
] as Action[];
updates.forEach(update => dispatch(update));
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelGenResults, sectionState, levelGenerating, sectionId, currentModule]);
useEffect(() => {
if (levelGenResults && levelGenResults.some(res => res.generating.startsWith("speaking"))) {
const results = levelGenResults.filter(res => res.generating.startsWith("speaking"));
const updates = [
{
type: "UPDATE_SECTION_STATE",
payload: {
sectionId,
module: "level",
update: {
exercises: [...(sectionState as ExamPart).exercises,
...results.map(createSpeakingExercise)
]
}
}
},
{
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "levelGenerating",
value: levelGenerating?.filter(g => !results.flatMap(res => res.generating as Generating).includes(g))
}
},
{
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "levelGenResults",
value: levelGenResults.filter(res => !results.flatMap(res => res.generating as Generating).includes(res.generating))
}
}
] as Action[];
if (levelGenResults && levelGenResults?.some(res => res.generating.startsWith("speaking"))) {
const results = levelGenResults.filter(res =>
res.generating.startsWith("speaking")
);
updates.forEach(update => dispatch(update));
const assignExercises = (results: any[]) =>
results.map(createSpeakingExercise);
handleExerciseGen(
results,
assignExercises,
{
sectionId,
currentModule,
sectionState: sectionState as ExamPart,
levelGenerating,
levelGenResults
}
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelGenResults, sectionState, levelGenerating, sectionId, currentModule]);
@@ -243,7 +255,7 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
const onFocus = (questionId: string, id: string | undefined) => {
if (id) {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { module: currentModule, sectionId, field: "focusedExercise", value: { questionId, id} } })
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { module: currentModule, sectionId, field: "focusedExercise", value: { questionId, id } } })
}
}
@@ -284,9 +296,9 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
{currentModule === "level" && (
<>
{
questions.ids?.length === 0 && !levelGenerating?.some((g) => g?.startsWith("exercises") || g?.startsWith("writing") || g?.startsWith("speaking")) && generating !== "exercises"
questions.ids?.length === 0 && !levelGenerating?.some((g) => g?.startsWith("exercises") || g?.startsWith("writing") || g?.startsWith("speaking")) && generating !== "exercises"
&& background(<span className="flex justify-center">Generated exercises will appear here!</span>)}
{levelGenerating?.some((g) => g?.startsWith("exercises") || g?.startsWith("writing") || g?.startsWith("speaking")) && <GenLoader module={currentModule} className="mt-4" />}
{levelGenerating?.some((g) => g?.startsWith("exercises") || g?.startsWith("writing") || g?.startsWith("speaking")) && <GenLoader module={currentModule} className="mt-4" />}
</>)
}
</DndContext >

View File

@@ -38,11 +38,7 @@ const SectionPicker: React.FC<Props> = ({
const newValue = currentValue === value ? undefined : value;
setSelectedValue(newValue);
let update = {};
if (module == "reading") {
update = {
text: undefined
}
} else {
if (module === "listening") {
if (state.audio?.source) {
URL.revokeObjectURL(state.audio.source)
}

View File

@@ -15,10 +15,8 @@ import { usePersistentExamStore } from "@/stores/exam";
import openDetachedTab from "@/utils/popout";
import ListeningComponents from "./listening/components";
import ReadingComponents from "./reading/components";
import WritingComponents from "./writing/components";
import SpeakingComponents from "./speaking/components";
import SectionPicker from "./Shared/SectionPicker";
import SettingsDropdown from "./Shared/SettingsDropdown";
const LevelSettings: React.FC = () => {

View File

@@ -14,19 +14,27 @@ import UserTable from '../Tables/UserTable';
import ParseExcelErrors from './ExcelError';
import { errorsByRows } from '@/utils/excel.errors';
import { ClassroomTransferState } from '../Imports/StudentClassroomTransfer';
import { ExcelUserDuplicatesMap } from './User';
const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ state }) => {
const ClassroomImportSummary: React.FC<{ state: ClassroomTransferState }> = ({ state }) => {
const [showErrorsModal, setShowErrorsModal] = useState(false);
const [showDuplicatesModal, setShowDuplicatesModal] = useState(false);
const [showNotFoundModal, setShowNotFoundModal] = useState(false);
const [showOtherEntityModal, setShowOtherEntityModal] = useState(false);
const [showAlreadyInClassModal, setShowAlreadyInClassModal] = useState(false);
const [showNotOwnedModal, setShowNotOwnedModal] = useState(false);
const [showMismatchesModal, setShowMismatchesModal] = useState(false);
const errorCount = state.parsedExcel?.errors ?
const errorCount = state.parsedExcel?.errors ?
Object.entries(errorsByRows(state.parsedExcel.errors)).length : 0;
const fieldMapper: { [key: string]: string } = {
"firstName": "First Name",
"lastName": "Last Name",
"studentID": "Student ID",
"phone": "Phone Number",
"passport_id": "Passport/National ID",
"country": "Country"
}
return (
<>
<Card>
@@ -105,26 +113,11 @@ const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ sta
</div>
)}
{state.notOwnedClassrooms.length > 0 && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FaLock className="h-5 w-5 text-red-500" />
<span>{`${state.notOwnedClassrooms.length} classroom${state.notOwnedClassrooms.length !== 1 ? 's' : ''} not owned`}</span>
</div>
<button
onClick={() => setShowNotOwnedModal(true)}
className="inline-flex items-center justify-center px-3 py-1.5 text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-md transition-colors"
>
View details
</button>
</div>
)}
{state.duplicatedRows && state.duplicatedRows.count > 0 && (
{state.duplicatedRows.length > 0 && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FaExclamationCircle className="h-5 w-5 text-yellow-500" />
<span>{state.duplicatedRows.count} duplicate entries in file</span>
<span>{state.duplicatedRows.length} duplicate entries in file</span>
</div>
<button
onClick={() => setShowDuplicatesModal(true)}
@@ -135,6 +128,21 @@ const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ sta
</div>
)}
{state.userMismatches.length > 0 && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FaExclamationTriangle className="h-5 w-5 text-orange-500" />
<span>{state.userMismatches.length} users with mismatched information</span>
</div>
<button
onClick={() => setShowMismatchesModal(true)}
className="inline-flex items-center justify-center px-3 py-1.5 text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-md transition-colors"
>
View mismatches
</button>
</div>
)}
{errorCount > 0 && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
@@ -151,73 +159,68 @@ const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ sta
)}
</div>
{((state.duplicatedRows?.count ?? 0) > 0 || errorCount > 0 ||
state.notFoundUsers.length > 0 || state.otherEntityUsers.length > 0 ||
state.notOwnedClassrooms.length > 0) && (
<div className="mt-6 rounded-lg border border-red-100 bg-white p-6">
<div className="flex items-start gap-4">
<div className="mt-1">
<FaExclamationTriangle className="h-5 w-5 text-red-400" />
</div>
<div className="flex-1 space-y-3">
<p className="font-medium text-gray-900">
The following will be excluded from transfer:
</p>
<ul className="space-y-4">
{state.notFoundUsers.length > 0 && (
<li>
<div className="text-gray-700">
<span className="font-medium">{state.notFoundUsers.length}</span> users not found
</div>
</li>
)}
{state.otherEntityUsers.length > 0 && (
<li>
<div className="text-gray-700">
<span className="font-medium">{state.otherEntityUsers.length}</span> users from different entities
</div>
</li>
)}
{state.notOwnedClassrooms.length > 0 && (
<li>
<div className="text-gray-700">
<span className="font-medium">{state.notOwnedClassrooms.length}</span> classrooms not owned:
<div className="mt-1 ml-4 text-sm text-gray-600">
{state.notOwnedClassrooms.join(', ')}
{(state.duplicatedRows.length > 0 || state.userMismatches.length > 0 || errorCount > 0 ||
state.notFoundUsers.length > 0 || state.otherEntityUsers.length > 0) && (
<div className="mt-6 rounded-lg border border-red-100 bg-white p-6">
<div className="flex items-start gap-4">
<div className="mt-1">
<FaExclamationTriangle className="h-5 w-5 text-red-400" />
</div>
<div className="flex-1 space-y-3">
<p className="font-medium text-gray-900">
The following will be excluded from transfer:
</p>
<ul className="space-y-4">
{state.notFoundUsers.length > 0 && (
<li>
<div className="text-gray-700">
<span className="font-medium">{state.notFoundUsers.length}</span> users not found
</div>
</div>
</li>
)}
{state.duplicatedRows && state.duplicatedRows.count > 0 && (
<li>
<div className="text-gray-700">
<span className="font-medium">{state.duplicatedRows.count}</span> duplicate entries
</div>
</li>
)}
{state.alreadyInClass && state.alreadyInClass.length > 0 && (
<li>
<div className="text-gray-700">
<span className="font-medium">{state.alreadyInClass.length}</span> users that are already assigned to the classroom
</div>
</li>
)}
{errorCount > 0 && (
<li>
<div className="text-gray-700">
<span className="font-medium">{errorCount}</span> rows with invalid information
</div>
</li>
)}
</ul>
</li>
)}
{state.otherEntityUsers.length > 0 && (
<li>
<div className="text-gray-700">
<span className="font-medium">{state.otherEntityUsers.length}</span> users from different entities
</div>
</li>
)}
{state.duplicatedRows.length > 0 && (
<li>
<div className="text-gray-700">
<span className="font-medium">{state.duplicatedRows.length}</span> duplicate entries
</div>
</li>
)}
{state.userMismatches.length > 0 && (
<li>
<div className="text-gray-700">
<span className="font-medium">{state.userMismatches.length}</span> users with mismatched information
</div>
</li>
)}
{state.alreadyInClass && state.alreadyInClass.length > 0 && (
<li>
<div className="text-gray-700">
<span className="font-medium">{state.alreadyInClass.length}</span> users that are already assigned to the classroom
</div>
</li>
)}
{errorCount > 0 && (
<li>
<div className="text-gray-700">
<span className="font-medium">{errorCount}</span> rows with invalid information
</div>
</li>
)}
</ul>
</div>
</div>
</div>
</div>
)}
)}
</CardContent>
</Card>
{/* Modals */}
<Modal isOpen={showErrorsModal} onClose={() => setShowErrorsModal(false)}>
<>
<div className="flex items-center gap-2 mb-6">
@@ -258,78 +261,65 @@ const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ sta
</>
</Modal>
<Modal isOpen={showNotOwnedModal} onClose={() => setShowNotOwnedModal(false)}>
<>
<div className="flex items-center gap-2 mb-6">
<FaLock className="w-5 h-5 text-red-500" />
<h2 className="text-lg font-semibold text-gray-900">Classrooms Not Owned</h2>
</div>
<div className="space-y-3">
{state.notOwnedClassrooms.map(classroom => (
<div
key={classroom}
className="flex justify-between items-center rounded-lg border border-gray-200 bg-gray-50 p-3"
>
<span className="text-gray-700">{classroom}</span>
</div>
))}
</div>
</>
</Modal>
<Modal isOpen={showDuplicatesModal} onClose={() => setShowDuplicatesModal(false)}>
<>
<div className="flex items-center gap-2 mb-6">
<FaExclamationCircle className="w-5 h-5 text-yellow-500" />
<h2 className="text-lg font-semibold text-gray-900">Duplicate Entries</h2>
</div>
{state.duplicatedRows && (
<div className="space-y-6">
{(Object.keys(state.duplicatedRows.duplicates) as Array<keyof ExcelUserDuplicatesMap>).map(field => {
const duplicates = Array.from(state.duplicatedRows!.duplicates[field].entries())
.filter((entry): entry is [string, number[]] => entry[1].length > 1);
if (duplicates.length === 0) return null;
return (
<div key={field} className="relative">
<div className="flex items-center gap-2 mb-3">
<h2 className="text-md font-medium text-gray-700">
{field} duplicates
</h2>
<span className="text-xs text-gray-500 ml-auto">
{duplicates.length} {duplicates.length === 1 ? 'duplicate' : 'duplicates'}
</span>
<div className="space-y-3">
{state.duplicatedRows.map((duplicate) => (
<div
key={`${duplicate.email}-${duplicate.classroom}-${duplicate.rowNumber}`}
className="group relative rounded-lg border border-gray-200 bg-gray-50 p-3 hover:bg-gray-100 transition-colors"
>
<div className="flex items-start justify-between">
<div>
<span className="font-medium text-gray-900">Row {duplicate.rowNumber}</span>
<div className="mt-1 text-sm text-gray-600">
Email: <span className="text-blue-600 font-medium">{duplicate.email}</span>
</div>
<div className="space-y-2">
{duplicates.map(([value, rows]) => (
<div
key={value}
className="group relative rounded-lg border border-gray-200 bg-gray-50 p-3 hover:bg-gray-100 transition-colors"
>
<div className="flex items-start justify-between">
<div>
<span className="font-medium text-gray-900">{value}</span>
<div className="mt-1 text-sm text-gray-600">
Appears in rows:
<span className="ml-1 text-blue-600 font-medium">
{rows.join(', ')}
</span>
</div>
</div>
<span className="text-xs text-gray-500">
{rows.length} occurrences
</span>
</div>
</div>
))}
<div className="mt-1 text-sm text-gray-600">
Classroom: <span className="text-blue-600 font-medium">{duplicate.classroom}</span>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
))}
</div>
</>
</Modal>
<Modal isOpen={showMismatchesModal} onClose={() => setShowMismatchesModal(false)}>
<>
<div className="flex items-center gap-2 mb-6">
<FaExclamationTriangle className="w-5 h-5 text-orange-500" />
<h2 className="text-lg font-semibold text-gray-900">Mismatched User Information</h2>
</div>
<div className="space-y-6">
{state.userMismatches.map((mismatch) => (
<div
key={mismatch.email}
className="relative rounded-lg border border-gray-200 bg-gray-50 p-4"
>
<div className="mb-2 font-medium text-gray-900">
Email: {mismatch.email}
</div>
<div className="text-sm text-gray-600">
Rows: {mismatch.rows.join(', ')}
</div>
<div className="mt-3 space-y-2">
{mismatch.mismatches.map((field) => (
<div key={field.field} className="pl-4 border-l-2 border-orange-500">
<div className="font-medium text-gray-700">{fieldMapper[field.field]}:</div>
<div className="mt-1 text-sm text-gray-600">
Values: {field.values.join(', ')}
</div>
</div>
))}
</div>
</div>
))}
</div>
</>
</Modal>
</>

View File

@@ -116,7 +116,7 @@ const CodeGenImportSummary: React.FC<Props> = ({ infos, parsedExcel, duplicateRo
if (duplicates.length === 0) return null;
return (
<div key={field} className="ml-4 text-sm text-gray-600">
<span className="text-gray-500">{field}:</span> rows {
<span className="text-gray-500">{fieldMapper[field]}:</span> rows {
duplicates.map(([_, rows]) => rows.join(', ')).join('; ')
}
</div>

View File

@@ -162,7 +162,7 @@ const UserImportSummary: React.FC<Props> = ({ parsedExcel, newUsers, enlistedUse
if (duplicates.length === 0) return null;
return (
<div key={field} className="ml-4 text-sm text-gray-600">
<span className="text-gray-500">{field}:</span> rows {
<span className="text-gray-500">{fieldMapper[field]}:</span> rows {
duplicates.map(([_, rows]) => rows.join(', ')).join('; ')
}
</div>

View File

@@ -4,7 +4,6 @@ import Modal from "../Modal";
import { useFilePicker } from "use-file-picker";
import readXlsxFile from "read-excel-file";
import countryCodes from "country-codes-list";
import { ExcelUserDuplicatesMap } from "../ImportSummaries/User";
import { UserImport } from "@/interfaces/IUserImport";
import axios from "axios";
import { toast } from "react-toastify";
@@ -18,20 +17,35 @@ import UserTable from "../Tables/UserTable";
import ClassroomImportSummary from "../ImportSummaries/Classroom";
import Select from "../Low/Select";
import { EntityWithRoles } from "@/interfaces/entity";
import { useRouter } from "next/router";
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
export interface DuplicateClassroom {
rowNumber: number;
email: string;
classroom: string;
}
export interface Mismatches {
email: string;
rows: number[];
mismatches: {
field: string;
values: any[];
}[];
}
export interface ClassroomTransferState {
stage: number;
parsedExcel: { rows?: any[]; errors?: any[] } | undefined;
duplicatedRows: { duplicates: ExcelUserDuplicatesMap, count: number } | undefined;
duplicatedRows: DuplicateClassroom[];
userMismatches: Mismatches[];
imports: UserImport[];
notFoundUsers: UserImport[];
otherEntityUsers: UserImport[];
alreadyInClass: UserImport[];
notOwnedClassrooms: string[];
classroomsToCreate: string[];
}
@@ -43,15 +57,16 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
const [classroomTransferState, setClassroomTransferState] = useState<ClassroomTransferState>({
stage: 0,
parsedExcel: undefined,
duplicatedRows: undefined,
duplicatedRows: [],
userMismatches: [],
imports: [],
notFoundUsers: [],
otherEntityUsers: [],
alreadyInClass: [],
notOwnedClassrooms: [],
classroomsToCreate: []
})
const router = useRouter();
const { openFilePicker, filesContent, clear } = useFilePicker({
accept: ".xlsx",
multiple: false,
@@ -166,89 +181,126 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filesContent]);
}, [filesContent, entity]);
// Stage 1 - Excel Parsing
// - Find duplicate rows
// - Group rows by emails
// - Find duplicate rows (email + classroom)
// - Find email and other fields mismatch except classroom
// - Parsing errors
// - Set the data
useEffect(() => {
if (classroomTransferState.parsedExcel && classroomTransferState.parsedExcel.rows) {
const duplicates: ExcelUserDuplicatesMap = {
studentID: new Map(),
email: new Map(),
passport_id: new Map(),
phone: new Map()
};
const duplicateValues = new Set<string>();
const duplicateRowIndices = new Set<number>();
const errorRowIndices = new Set(
classroomTransferState.parsedExcel.errors?.map(error => error.row) || []
);
const emailClassroomMap = new Map<string, Map<string, number[]>>(); // email -> (group -> row numbers)
const emailRowMap = new Map<string, Map<number, any>>(); // email -> (row number -> row data)
// Group rows by email and assign row data
classroomTransferState.parsedExcel.rows.forEach((row, index) => {
if (!errorRowIndices.has(index + 2)) {
(Object.keys(duplicates) as Array<keyof ExcelUserDuplicatesMap>).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 rowNum = index + 2;
if (!errorRowIndices.has(rowNum) && row !== null) {
const email = row.email.toString().trim().toLowerCase();
const classroom = row.group;
if (!emailRowMap.has(email)) {
emailRowMap.set(email, new Map());
}
emailRowMap.get(email)!.set(rowNum, row);
if (!emailClassroomMap.has(email)) {
emailClassroomMap.set(email, new Map());
}
const classroomMap = emailClassroomMap.get(email)!;
if (!classroomMap.has(classroom)) {
classroomMap.set(classroom, []);
}
classroomMap.get(classroom)!.push(rowNum);
}
});
const infos = classroomTransferState.parsedExcel.rows
.map((row, index) => {
if (errorRowIndices.has(index + 2) || duplicateRowIndices.has(index + 2) || row === null) {
return undefined;
const duplicatedRows: DuplicateClassroom[] = [];
const userMismatches: Mismatches[] = [];
const validRows = [];
for (const [email, classroomMap] of emailClassroomMap) {
const rowDataMap = emailRowMap.get(email)!;
const allRowsForEmail = Array.from(rowDataMap.keys());
// Check for duplicates (same email + classroom)
for (const [classroom, rowNumbers] of classroomMap) {
if (rowNumbers.length > 1) {
rowNumbers.forEach(row => duplicatedRows.push({
rowNumber: row,
email,
classroom
}));
} else {
validRows.push(rowNumbers[0]);
}
const { firstName, lastName, studentID, passport_id, email, phone, group, country } = row;
if (!email || !EMAIL_REGEX.test(email.toString().trim())) {
return undefined;
}
// Check for mismatches in other fields
if (allRowsForEmail.length > 1) {
const fields = ['firstName', 'lastName', 'studentID', 'passport_id', 'phone'];
const mismatches: {field: string; values: any[]}[] = [];
fields.forEach(field => {
const uniqueValues = new Set(
allRowsForEmail.map(rowNum => rowDataMap.get(rowNum)![field])
);
if (uniqueValues.size > 1) {
mismatches.push({
field,
values: Array.from(uniqueValues)
});
}
});
if (mismatches.length > 0) {
userMismatches.push({
email,
rows: allRowsForEmail,
mismatches
});
}
return {
email: email.toString().trim().toLowerCase(),
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
passport_id: passport_id?.toString().trim() || undefined,
groupName: group,
studentID,
demographicInformation: {
country: country?.countryCode,
passport_id: passport_id?.toString().trim() || undefined,
phone: phone.toString(),
},
entity: undefined,
type: undefined
} as UserImport;
})
.filter((item): item is UserImport => item !== undefined);
}
}
const imports = validRows
.map(rowNum => classroomTransferState.parsedExcel!.rows![rowNum - 2])
.filter((row): row is any => row !== null)
.map(row => ({
email: row.email.toString().trim().toLowerCase(),
name: `${row.firstName ?? ""} ${row.lastName ?? ""}`.trim(),
passport_id: row.passport_id?.toString().trim() || undefined,
groupName: row.group,
studentID: row.studentID,
demographicInformation: {
country: row.country?.countryCode,
passport_id: row.passport_id?.toString().trim() || undefined,
phone: row.phone.toString(),
},
entity: undefined,
type: undefined
} as UserImport));
// On import reset state except excel parsing
setClassroomTransferState((prev) => ({
...prev,
stage: 1,
duplicatedRows: { duplicates, count: duplicateRowIndices.size },
imports: infos,
duplicatedRows,
userMismatches,
imports,
notFoundUsers: [],
otherEntityUsers: [],
notOwnedClassrooms: [],
classroomsToCreate: [],
validClassrooms: [],
}));
}
}, [classroomTransferState.parsedExcel]);
}, [classroomTransferState.parsedExcel, entity]);
// Stage 2 - Student Filter
// - Filter non existant students
@@ -265,7 +317,8 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
email: info.email,
classroom: info.groupName
})),
entity
entity,
userId: user.id
});
const excludeEmails = new Set([
@@ -304,51 +357,19 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
crossRefUsers();
}
}, [classroomTransferState.imports, user.entities, classroomTransferState.stage, entity])
// Stage 3 - Classroom Filter
// - See if there are classrooms with same name but different admin
// - Find which new classrooms need to be created
useEffect(() => {
const crossRefClassrooms = async () => {
const classrooms = Array.from(new Set(classroomTransferState.imports.map((i) => i.groupName)));
try {
const { data: notOwnedClassroomsSameName } = await axios.post("/api/groups/controller?op=crossRefOwnership", {
userId: user.id,
classrooms
});
setClassroomTransferState((prev) => ({
...prev,
stage: 3,
notOwnedClassrooms: notOwnedClassroomsSameName,
classroomsToCreate: Array.from(classrooms).filter(
(name) => !new Set(notOwnedClassroomsSameName).has(name)
)
}))
} catch (error) {
toast.error("Something went wrong, please try again later!");
}
};
if (classroomTransferState.imports.length > 0 && classroomTransferState.stage === 2) {
crossRefClassrooms();
}
}, [classroomTransferState.imports, classroomTransferState.stage, user.id])
}, [classroomTransferState.imports, user.entities, classroomTransferState.stage, entity, user.id])
const clearAndReset = () => {
setIsLoading(false);
setClassroomTransferState({
stage: 0,
parsedExcel: undefined,
duplicatedRows: undefined,
duplicatedRows: [],
userMismatches: [],
imports: [],
notFoundUsers: [],
otherEntityUsers: [],
alreadyInClass: [],
notOwnedClassrooms: [],
classroomsToCreate: []
});
clear();
};
@@ -367,7 +388,8 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
try {
setIsLoading(true);
const classrooms = Array.from(new Set(classroomTransferState.imports.map((i) => i.groupName)));
const getIds = async () => {
try {
const { data: emailIdMap } = await axios.post("/api/users/controller?op=getIds", {
@@ -390,7 +412,6 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
if (!imports) return;
await axios.post("/api/groups/controller?op=deletePriorEntitiesGroups", {
ids: imports.map((u) => u.id),
entity
@@ -401,6 +422,9 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
entity
});
const { data: existingGroupsMap } = await axios.post("/api/groups/controller?op=existantGroupIds", {
names: classrooms
});
const groupedUsers = imports.reduce((acc, user) => {
if (!acc[user.groupName]) {
@@ -409,12 +433,18 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
acc[user.groupName].push(user);
return acc;
}, {} as Record<string, UserImport[]>);
// ##############
// # New Groups #
// ##############
const newGroupUsers = Object.fromEntries(
Object.entries(groupedUsers)
.filter(([groupName]) => classroomTransferState.classroomsToCreate.includes(groupName))
.filter(([groupName]) =>
classrooms.includes(groupName) &&
!existingGroupsMap[groupName]
)
);
const createGroupPromises = Object.entries(newGroupUsers).map(([groupName, users]) => {
const groupData: Partial<Group> = {
admin: user.id,
@@ -424,25 +454,33 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
};
return axios.post('/api/groups', groupData);
});
const existingGroupUsers = Object.fromEntries(
// ###################
// # Existant Groups #
// ###################
const allExistingUsers = Object.fromEntries(
Object.entries(groupedUsers)
.filter(([groupName]) => !classroomTransferState.classroomsToCreate.includes(groupName))
.filter(([groupName]) =>
!classrooms.includes(groupName) ||
existingGroupsMap[groupName]
)
);
let updatePromises: Promise<any>[] = [];
if (Object.keys(allExistingUsers).length > 0) {
const { data: { groups: groupNameToId, users: userEmailToId } } = await axios.post('/api/groups/controller?op=getIds', {
names: Object.keys(allExistingUsers),
userEmails: Object.values(allExistingUsers).flat().map(user => user.email)
});
if (Object.keys(existingGroupUsers).length > 0) {
const { groups: groupNameToId, users: userEmailToId } = await axios.post('/api/groups?op=getIds', {
names: Object.keys(existingGroupUsers),
userEmails: Object.values(existingGroupUsers).flat().map(user => user.email)
}).then(response => response.data);
const existingGroupsWithIds = Object.entries(existingGroupUsers).reduce((acc, [groupName, users]) => {
acc[groupNameToId[groupName]] = users;
const existingGroupsWithIds = Object.entries(allExistingUsers).reduce((acc, [groupName, users]) => {
const groupId = groupNameToId[groupName];
if (groupId) {
acc[groupId] = users;
}
return acc;
}, {} as Record<string, any[]>);
}, {} as Record<string, UserImport[]>);
updatePromises = Object.entries(existingGroupsWithIds).map(([groupId, users]) => {
const userIds = users.map(user => userEmailToId[user.email]);
@@ -451,13 +489,14 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
});
});
}
await Promise.all([
...createGroupPromises,
...updatePromises
]);
toast.success(`Successfully assigned all ${classroomTransferState.imports.length} user(s)!`);
router.replace("/classrooms");
onFinish();
} catch (error) {
console.error(error);

View File

@@ -22,6 +22,7 @@ import { IoInformationCircleOutline } from "react-icons/io5";
import { FaFileDownload } from "react-icons/fa";
import { HiOutlineDocumentText } from "react-icons/hi";
import UserImportSummary, { ExcelUserDuplicatesMap } from "@/components/ImportSummaries/User";
import { v4 } from "uuid";
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
@@ -87,7 +88,7 @@ export default function BatchCreateUser({ user, entities = [], permissions, onFi
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
const [type, setType] = useState<Type>("student");
const [showHelp, setShowHelp] = useState(false);
const [entity, setEntity] = useState<{id: string | null, label: string | null}| undefined>(() => {
const [entity, setEntity] = useState<{ id: string | null, label: string | null } | undefined>(() => {
if (!entities?.length) {
return undefined;
}
@@ -307,11 +308,11 @@ export default function BatchCreateUser({ user, entities = [], permissions, onFi
}));
setNewUsers(newUsersList);
const {data: emailEntityMap} = await axios.post("/api/users/controller?op=getEntities", {
const { data: emailEntityMap } = await axios.post("/api/users/controller?op=getEntities", {
emails: dupes.map((x) => x.email)
});
const withLabels = dupes.map((u) => ({
...u,
...u,
entityLabels: emailEntityMap.find((e: any) => e.email === u.email)?.entityLabels || []
}))
setDuplicatedUsers(withLabels);
@@ -339,18 +340,17 @@ 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(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);
try {
await axios.post("/api/batch_users", { users: newUsers.map((user) => ({ ...user, type, expiryDate })) });
toast.success(`Successfully added ${newUsers.length} user(s)!`);
const withIds = newUsers.map((user) => ({ ...user, type, expiryDate, id: v4() }));
await axios.post("/api/batch_users", { users: withIds});
toast.success(`Successfully added ${withIds.length} user(s)!`);
Promise.all(withIds.map(async (u) => await axios.post(`/api/invites`, { to: u.id, entity: entity?.id, from: user.id })))
.then(() => toast.success(`Successfully invited ${withIds.length} registered student(s)!`))
.finally(() => {
if (withIds.length === 0) setIsLoading(false);
});
onFinish();
} catch (e) {
console.error(e)
@@ -486,14 +486,14 @@ export default function BatchCreateUser({ user, entities = [], permissions, onFi
options={entities.map((e) => ({ value: e.id, label: e.label }))}
onChange={(e) => {
if (!e) {
setEntity(undefined);
return;
setEntity(undefined);
return;
}
setEntity({
id: e?.value,
label: e?.label
id: e?.value,
label: e?.label
});
}}
}}
isClearable={checkAccess(user, ["admin", "developer"])}
/>
</div>

View File

@@ -38,7 +38,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
passport_id?: string;
phone: string;
};
entity?: string
entity: { id: string, label: string }
entities: { id: string, role: string }[]
passwordHash: string | undefined;
passwordSalt: string | undefined;
@@ -49,11 +49,10 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const salt = crypto.randomBytes(16).toString('base64');
const hash = await scrypt.hash(user.passport_id, salt);
const entity = await getEntityWithRoles(currentUser.entity!)
const entity = await getEntityWithRoles(currentUser.entity!.id)
const defaultRole = findBy(entity?.roles || [], "isDefault", true)
currentUser.entities = [{ id: entity?.id || "", role: defaultRole?.id || "" }]
delete currentUser.entity
currentUser.email = currentUser.email.toLowerCase();
currentUser.passwordHash = hash;

View File

@@ -19,8 +19,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
}
else if (req.method === 'POST') {
switch (op) {
case 'crossRefOwnership':
res.status(200).json(await crossRefOwnership(req.body));
case 'existantGroupIds':
res.status(200).json(await existantGroupIds(req.body.names));
break;
case 'getIds':
res.status(200).json(await getIds(req.body));
@@ -37,45 +37,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
}
}
async function crossRefOwnership(body: any): Promise<string[]> {
const { userId, classrooms, entity } = body;
const existingClassrooms = await db.collection('groups')
.find({
name: { $in: classrooms },
admin: { $ne: userId }
})
.project({ name: 1, admin: 1, _id: 0 })
.toArray();
if (existingClassrooms.length === 0) {
return [];
}
const adminUsers = await db.collection('users')
.find({
id: { $in: existingClassrooms.map(classroom => classroom.admin) }
})
.project({ id: 1, entities: 1, _id: 0 })
.toArray();
const adminEntitiesMap = new Map(
adminUsers.map(admin => [
admin.id,
admin.entities?.map((e: any) => e.id) || []
])
);
return Array.from(new Set(
existingClassrooms
.filter(classroom => {
const adminEntities = adminEntitiesMap.get(classroom.admin) || [];
return adminEntities.includes(entity);
})
.map(classroom => classroom.name)
));
}
async function getIds(body: any): Promise<Record<string, string>> {
const { names, userEmails } = body;
@@ -104,7 +65,6 @@ async function getIds(body: any): Promise<Record<string, string>> {
async function deletePriorEntitiesGroups(body: any) {
const { ids, entity } = body;
if (!Array.isArray(ids) || ids.length === 0 || !entity) {
return;
}
@@ -123,8 +83,33 @@ async function deletePriorEntitiesGroups(body: any) {
return;
}
db.collection('groups').updateMany(
const affectedGroups = await db.collection('groups')
.find({ participants: { $in: toDeleteUserIds } })
.project({ id: 1, _id: 0 })
.toArray();
await db.collection('groups').updateMany(
{ participants: { $in: toDeleteUserIds } },
{ $pull: { participants: { $in: toDeleteUserIds } } } as any
);
// delete groups that were updated and have no participants
await db.collection('groups').deleteMany({
id: { $in: affectedGroups.map(g => g.id) },
participants: { $size: 0 }
});
}
async function existantGroupIds(names: string[]) {
const existingGroups = await db.collection('groups')
.find({
name: { $in: names }
})
.project({ id: 1, name: 1, _id: 0 })
.toArray();
return existingGroups.reduce((acc, group) => {
acc[group.name] = group.id;
return acc;
}, {} as Record<string, string>);
}

View File

@@ -169,8 +169,12 @@ async function entityCheck(body: Record<string, any>): Promise<Array<{
}
async function crossRefClassrooms(body: any) {
const { sets, entity } = body as { sets: { email: string, classroom: string }[], entity: string };
const { sets, entity, userId } = body as {
sets: { email: string, classroom: string }[],
entity: string,
userId: string
};
const pipeline = [
// Match users with the provided emails
{
@@ -189,6 +193,8 @@ async function crossRefClassrooms(body: any) {
$expr: {
$and: [
{ $in: ['$$userId', '$participants'] },
{ $eq: ['$admin', userId] },
{ $eq: ['$entity', entity] },
{
$let: {
vars: {
@@ -210,38 +216,6 @@ async function crossRefClassrooms(body: any) {
]
}
}
},
// Lookup admin's entities
{
$lookup: {
from: 'users',
let: { adminId: '$admin' },
pipeline: [
{
$match: {
$expr: { $eq: ['$id', '$$adminId'] }
}
}
],
as: 'adminInfo'
}
},
// Filter where admin has the target entity
{
$match: {
$expr: {
$in: [
entity,
{
$map: {
input: { $arrayElemAt: ['$adminInfo.entities', 0] },
as: 'entityObj',
in: '$$entityObj.id'
}
}
]
}
}
}
],
as: 'matchingGroups'

View File

@@ -124,7 +124,7 @@ export default function Home({ user, groups, entities }: Props) {
<ToastContainer />
<Layout user={user} className="!gap-4">
<section className="flex flex-col gap-4 w-full h-full">
<Modal isOpen={showImport} onClose={() => setShowImport(false)}>
<Modal isOpen={showImport} onClose={() => setShowImport(false)} maxWidth="max-w-[85%]">
<StudentClassroomTransfer user={user} entities={entities} onFinish={() => setShowImport(false)} />
</Modal>
<div className="flex flex-col gap-4">