Merged in feature/ExamGenRework (pull request #131)
Feature/ExamGenRework Approved-by: Tiago Ribeiro
This commit is contained in:
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import useExamEditorStore from "@/stores/examEditor";
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
import ListeningContext from "./listening";
|
import ListeningContext from "./listening";
|
||||||
import ReadingContext from "./reading";
|
import ReadingContext from "./reading";
|
||||||
@@ -30,7 +29,7 @@ const LevelContext: React.FC<Props> = ({ sectionId }) => {
|
|||||||
)}
|
)}
|
||||||
{(readingSection || listeningSection || hasReadingContext) && (
|
{(readingSection || listeningSection || hasReadingContext) && (
|
||||||
<div className="space-y-4 mb-4">
|
<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} />}
|
{listeningSection && <ListeningContext sectionId={sectionId} listeningSection={listeningSection} module="level" level={true} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,11 +11,10 @@ import { Module } from "@/interfaces";
|
|||||||
interface Props {
|
interface Props {
|
||||||
module: Module;
|
module: Module;
|
||||||
sectionId: number;
|
sectionId: number;
|
||||||
level?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const ReadingContext: React.FC<Props> = ({ sectionId, module, level = false }) => {
|
const ReadingContext: React.FC<Props> = ({ sectionId, module }) => {
|
||||||
const { dispatch } = useExamEditorStore();
|
const { dispatch } = useExamEditorStore();
|
||||||
const sectionState = useExamEditorStore(
|
const sectionState = useExamEditorStore(
|
||||||
(state) => state.modules[module].sections.find((section) => section.sectionId === sectionId)!
|
(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) {
|
if (readingPart.text === undefined) {
|
||||||
setTitle('');
|
setTitle('');
|
||||||
setContent('');
|
setContent('');
|
||||||
@@ -74,7 +73,9 @@ const ReadingContext: React.FC<Props> = ({ sectionId, module, level = false }) =
|
|||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const passageRes = levelGenResults.find((res) => res.generating === "passage");
|
const passageRes = [...levelGenResults].reverse()
|
||||||
|
.find((res) => res.generating === "passage");
|
||||||
|
|
||||||
if (levelGenResults && passageRes) {
|
if (levelGenResults && passageRes) {
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
setTitle(passageRes.result[0].title);
|
setTitle(passageRes.result[0].title);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import Dropdown from "@/components/Dropdown";
|
|||||||
import useExamEditorStore from "@/stores/examEditor";
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
import Writing from "../../Exercises/Writing";
|
import Writing from "../../Exercises/Writing";
|
||||||
import Speaking from "../../Exercises/Speaking";
|
import Speaking from "../../Exercises/Speaking";
|
||||||
import { ReactElement, ReactNode, useEffect, useState } from "react";
|
import { ReactNode, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
PointerSensor,
|
PointerSensor,
|
||||||
@@ -43,7 +43,7 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
|
|||||||
|
|
||||||
const genResult = section?.genResult;
|
const genResult = section?.genResult;
|
||||||
const generating = section?.generating;
|
const generating = section?.generating;
|
||||||
const levelGenResults = section?.levelGenResults
|
const levelGenResults = section?.levelGenResults;
|
||||||
const levelGenerating = section?.levelGenerating;
|
const levelGenerating = section?.levelGenerating;
|
||||||
const sectionState = section?.state;
|
const sectionState = section?.state;
|
||||||
|
|
||||||
@@ -66,134 +66,146 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
|
|||||||
}, [genResult, dispatch, sectionId, currentModule]);
|
}, [genResult, dispatch, sectionId, currentModule]);
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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));
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (levelGenResults && levelGenResults.some(res => res.generating.startsWith("exercises"))) {
|
if (levelGenResults && levelGenResults?.some(res => res.generating.startsWith("exercises"))) {
|
||||||
const newExercises = levelGenResults
|
const results = levelGenResults.filter(res =>
|
||||||
.filter(res => res.generating.startsWith("exercises"))
|
res.generating.startsWith("exercises")
|
||||||
|
);
|
||||||
|
|
||||||
|
const assignExercises = (results: any[]) =>
|
||||||
|
results
|
||||||
.map(res => res.result[0].exercises)
|
.map(res => res.result[0].exercises)
|
||||||
.flat();
|
.flat();
|
||||||
|
|
||||||
const updates = [
|
handleExerciseGen(
|
||||||
|
results,
|
||||||
|
assignExercises,
|
||||||
{
|
{
|
||||||
type: "UPDATE_SECTION_STATE",
|
|
||||||
payload: {
|
|
||||||
sectionId,
|
sectionId,
|
||||||
module: "level",
|
currentModule,
|
||||||
update: {
|
sectionState: sectionState as ExamPart,
|
||||||
exercises: [...(sectionState as ExamPart).exercises, ...newExercises]
|
levelGenerating,
|
||||||
|
levelGenResults
|
||||||
}
|
}
|
||||||
}
|
);
|
||||||
},
|
|
||||||
{
|
|
||||||
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"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
] as Action[];
|
|
||||||
|
|
||||||
updates.forEach(update => dispatch(update));
|
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [levelGenResults, sectionState, levelGenerating, sectionId, currentModule]);
|
}, [levelGenResults, sectionState, levelGenerating, sectionId, currentModule]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (levelGenResults && levelGenResults.some(res => res.generating === "writing_letter" || res.generating === "writing_2")) {
|
if (levelGenResults && levelGenResults?.some(res =>
|
||||||
const results = levelGenResults.filter(res => res.generating === "writing_letter" || res.generating === "writing_2");
|
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 => ({
|
||||||
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),
|
...writingTask(res.generating === "writing_letter" ? 1 : 2),
|
||||||
prompt: res.result[0].prompt,
|
prompt: res.result[0].prompt,
|
||||||
variant: res.generating === "writing_letter" ? "letter" : "essay"
|
variant: res.generating === "writing_letter" ? "letter" : "essay"
|
||||||
} as WritingExercise;
|
}) 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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
] as Action[];
|
|
||||||
|
|
||||||
updates.forEach(update => dispatch(update));
|
handleExerciseGen(
|
||||||
|
results,
|
||||||
|
assignExercises,
|
||||||
|
{
|
||||||
|
sectionId,
|
||||||
|
currentModule,
|
||||||
|
sectionState: sectionState as ExamPart,
|
||||||
|
levelGenerating,
|
||||||
|
levelGenResults
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [levelGenResults, sectionState, levelGenerating, sectionId, currentModule]);
|
}, [levelGenResults, sectionState, levelGenerating, sectionId, currentModule]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (levelGenResults && levelGenResults.some(res => res.generating.startsWith("speaking"))) {
|
if (levelGenResults && levelGenResults?.some(res => res.generating.startsWith("speaking"))) {
|
||||||
const results = levelGenResults.filter(res => res.generating.startsWith("speaking"));
|
const results = levelGenResults.filter(res =>
|
||||||
const updates = [
|
res.generating.startsWith("speaking")
|
||||||
{
|
);
|
||||||
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[];
|
|
||||||
|
|
||||||
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [levelGenResults, sectionState, levelGenerating, sectionId, currentModule]);
|
}, [levelGenResults, sectionState, levelGenerating, sectionId, currentModule]);
|
||||||
@@ -243,7 +255,7 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
|
|||||||
|
|
||||||
const onFocus = (questionId: string, id: string | undefined) => {
|
const onFocus = (questionId: string, id: string | undefined) => {
|
||||||
if (id) {
|
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 } } })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,11 +38,7 @@ const SectionPicker: React.FC<Props> = ({
|
|||||||
const newValue = currentValue === value ? undefined : value;
|
const newValue = currentValue === value ? undefined : value;
|
||||||
setSelectedValue(newValue);
|
setSelectedValue(newValue);
|
||||||
let update = {};
|
let update = {};
|
||||||
if (module == "reading") {
|
if (module === "listening") {
|
||||||
update = {
|
|
||||||
text: undefined
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (state.audio?.source) {
|
if (state.audio?.source) {
|
||||||
URL.revokeObjectURL(state.audio.source)
|
URL.revokeObjectURL(state.audio.source)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,10 +15,8 @@ import { usePersistentExamStore } from "@/stores/exam";
|
|||||||
import openDetachedTab from "@/utils/popout";
|
import openDetachedTab from "@/utils/popout";
|
||||||
import ListeningComponents from "./listening/components";
|
import ListeningComponents from "./listening/components";
|
||||||
import ReadingComponents from "./reading/components";
|
import ReadingComponents from "./reading/components";
|
||||||
import WritingComponents from "./writing/components";
|
|
||||||
import SpeakingComponents from "./speaking/components";
|
import SpeakingComponents from "./speaking/components";
|
||||||
import SectionPicker from "./Shared/SectionPicker";
|
import SectionPicker from "./Shared/SectionPicker";
|
||||||
import SettingsDropdown from "./Shared/SettingsDropdown";
|
|
||||||
|
|
||||||
|
|
||||||
const LevelSettings: React.FC = () => {
|
const LevelSettings: React.FC = () => {
|
||||||
|
|||||||
@@ -14,19 +14,28 @@ import UserTable from '../Tables/UserTable';
|
|||||||
import ParseExcelErrors from './ExcelError';
|
import ParseExcelErrors from './ExcelError';
|
||||||
import { errorsByRows } from '@/utils/excel.errors';
|
import { errorsByRows } from '@/utils/excel.errors';
|
||||||
import { ClassroomTransferState } from '../Imports/StudentClassroomTransfer';
|
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 [showErrorsModal, setShowErrorsModal] = useState(false);
|
||||||
const [showDuplicatesModal, setShowDuplicatesModal] = useState(false);
|
const [showDuplicatesModal, setShowDuplicatesModal] = useState(false);
|
||||||
const [showNotFoundModal, setShowNotFoundModal] = useState(false);
|
const [showNotFoundModal, setShowNotFoundModal] = useState(false);
|
||||||
const [showOtherEntityModal, setShowOtherEntityModal] = useState(false);
|
const [showOtherEntityModal, setShowOtherEntityModal] = useState(false);
|
||||||
const [showAlreadyInClassModal, setShowAlreadyInClassModal] = useState(false);
|
const [showAlreadyInClassModal, setShowAlreadyInClassModal] = useState(false);
|
||||||
const [showNotOwnedModal, setShowNotOwnedModal] = 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;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card>
|
<Card>
|
||||||
@@ -120,11 +129,11 @@ const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ sta
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{state.duplicatedRows && state.duplicatedRows.count > 0 && (
|
{state.duplicatedRows.length > 0 && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FaExclamationCircle className="h-5 w-5 text-yellow-500" />
|
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDuplicatesModal(true)}
|
onClick={() => setShowDuplicatesModal(true)}
|
||||||
@@ -135,6 +144,21 @@ const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ sta
|
|||||||
</div>
|
</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 && (
|
{errorCount > 0 && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -151,7 +175,7 @@ const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ sta
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{((state.duplicatedRows?.count ?? 0) > 0 || errorCount > 0 ||
|
{(state.duplicatedRows.length > 0 || state.userMismatches.length > 0 || errorCount > 0 ||
|
||||||
state.notFoundUsers.length > 0 || state.otherEntityUsers.length > 0 ||
|
state.notFoundUsers.length > 0 || state.otherEntityUsers.length > 0 ||
|
||||||
state.notOwnedClassrooms.length > 0) && (
|
state.notOwnedClassrooms.length > 0) && (
|
||||||
<div className="mt-6 rounded-lg border border-red-100 bg-white p-6">
|
<div className="mt-6 rounded-lg border border-red-100 bg-white p-6">
|
||||||
@@ -188,10 +212,18 @@ const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ sta
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
{state.duplicatedRows && state.duplicatedRows.count > 0 && (
|
{state.duplicatedRows.length > 0 && (
|
||||||
<li>
|
<li>
|
||||||
<div className="text-gray-700">
|
<div className="text-gray-700">
|
||||||
<span className="font-medium">{state.duplicatedRows.count}</span> duplicate entries
|
<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>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
@@ -216,8 +248,6 @@ const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ sta
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Modals */}
|
|
||||||
<Modal isOpen={showErrorsModal} onClose={() => setShowErrorsModal(false)}>
|
<Modal isOpen={showErrorsModal} onClose={() => setShowErrorsModal(false)}>
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2 mb-6">
|
<div className="flex items-center gap-2 mb-6">
|
||||||
@@ -283,53 +313,59 @@ const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ sta
|
|||||||
<FaExclamationCircle className="w-5 h-5 text-yellow-500" />
|
<FaExclamationCircle className="w-5 h-5 text-yellow-500" />
|
||||||
<h2 className="text-lg font-semibold text-gray-900">Duplicate Entries</h2>
|
<h2 className="text-lg font-semibold text-gray-900">Duplicate Entries</h2>
|
||||||
</div>
|
</div>
|
||||||
{state.duplicatedRows && (
|
<div className="space-y-3">
|
||||||
<div className="space-y-6">
|
{state.duplicatedRows.map((duplicate) => (
|
||||||
{(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>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{duplicates.map(([value, rows]) => (
|
|
||||||
<div
|
<div
|
||||||
key={value}
|
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"
|
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 className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-gray-900">{value}</span>
|
<span className="font-medium text-gray-900">Row {duplicate.rowNumber}</span>
|
||||||
<div className="mt-1 text-sm text-gray-600">
|
<div className="mt-1 text-sm text-gray-600">
|
||||||
Appears in rows:
|
Email: <span className="text-blue-600 font-medium">{duplicate.email}</span>
|
||||||
<span className="ml-1 text-blue-600 font-medium">
|
</div>
|
||||||
{rows.join(', ')}
|
<div className="mt-1 text-sm text-gray-600">
|
||||||
</span>
|
Classroom: <span className="text-blue-600 font-medium">{duplicate.classroom}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-gray-500">
|
</div>
|
||||||
{rows.length} occurrences
|
</div>
|
||||||
</span>
|
))}
|
||||||
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ const CodeGenImportSummary: React.FC<Props> = ({ infos, parsedExcel, duplicateRo
|
|||||||
if (duplicates.length === 0) return null;
|
if (duplicates.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<div key={field} className="ml-4 text-sm text-gray-600">
|
<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('; ')
|
duplicates.map(([_, rows]) => rows.join(', ')).join('; ')
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ const UserImportSummary: React.FC<Props> = ({ parsedExcel, newUsers, enlistedUse
|
|||||||
if (duplicates.length === 0) return null;
|
if (duplicates.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<div key={field} className="ml-4 text-sm text-gray-600">
|
<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('; ')
|
duplicates.map(([_, rows]) => rows.join(', ')).join('; ')
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,20 +18,37 @@ import UserTable from "../Tables/UserTable";
|
|||||||
import ClassroomImportSummary from "../ImportSummaries/Classroom";
|
import ClassroomImportSummary from "../ImportSummaries/Classroom";
|
||||||
import Select from "../Low/Select";
|
import Select from "../Low/Select";
|
||||||
import { EntityWithRoles } from "@/interfaces/entity";
|
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]+)*$/);
|
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 {
|
export interface ClassroomTransferState {
|
||||||
stage: number;
|
stage: number;
|
||||||
parsedExcel: { rows?: any[]; errors?: any[] } | undefined;
|
parsedExcel: { rows?: any[]; errors?: any[] } | undefined;
|
||||||
duplicatedRows: { duplicates: ExcelUserDuplicatesMap, count: number } | undefined;
|
duplicatedRows: DuplicateClassroom[];
|
||||||
|
userMismatches: Mismatches[];
|
||||||
imports: UserImport[];
|
imports: UserImport[];
|
||||||
notFoundUsers: UserImport[];
|
notFoundUsers: UserImport[];
|
||||||
otherEntityUsers: UserImport[];
|
otherEntityUsers: UserImport[];
|
||||||
alreadyInClass: UserImport[];
|
alreadyInClass: UserImport[];
|
||||||
notOwnedClassrooms: string[];
|
notOwnedClassrooms: string[];
|
||||||
classroomsToCreate: string[];
|
validClassrooms: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -43,15 +60,18 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
|
|||||||
const [classroomTransferState, setClassroomTransferState] = useState<ClassroomTransferState>({
|
const [classroomTransferState, setClassroomTransferState] = useState<ClassroomTransferState>({
|
||||||
stage: 0,
|
stage: 0,
|
||||||
parsedExcel: undefined,
|
parsedExcel: undefined,
|
||||||
duplicatedRows: undefined,
|
duplicatedRows: [],
|
||||||
|
userMismatches: [],
|
||||||
imports: [],
|
imports: [],
|
||||||
notFoundUsers: [],
|
notFoundUsers: [],
|
||||||
otherEntityUsers: [],
|
otherEntityUsers: [],
|
||||||
alreadyInClass: [],
|
alreadyInClass: [],
|
||||||
notOwnedClassrooms: [],
|
notOwnedClassrooms: [],
|
||||||
classroomsToCreate: []
|
validClassrooms: []
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const { openFilePicker, filesContent, clear } = useFilePicker({
|
const { openFilePicker, filesContent, clear } = useFilePicker({
|
||||||
accept: ".xlsx",
|
accept: ".xlsx",
|
||||||
multiple: false,
|
multiple: false,
|
||||||
@@ -166,89 +186,126 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
|
|||||||
|
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [filesContent]);
|
}, [filesContent, entity]);
|
||||||
|
|
||||||
// Stage 1 - Excel Parsing
|
// 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
|
// - Parsing errors
|
||||||
// - Set the data
|
// - Set the data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (classroomTransferState.parsedExcel && classroomTransferState.parsedExcel.rows) {
|
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(
|
const errorRowIndices = new Set(
|
||||||
classroomTransferState.parsedExcel.errors?.map(error => error.row) || []
|
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) => {
|
classroomTransferState.parsedExcel.rows.forEach((row, index) => {
|
||||||
if (!errorRowIndices.has(index + 2)) {
|
const rowNum = index + 2;
|
||||||
(Object.keys(duplicates) as Array<keyof ExcelUserDuplicatesMap>).forEach(field => {
|
if (!errorRowIndices.has(rowNum) && row !== null) {
|
||||||
if (row !== null) {
|
const email = row.email.toString().trim().toLowerCase();
|
||||||
const value = row[field];
|
const classroom = row.group;
|
||||||
if (value) {
|
|
||||||
if (!duplicates[field].has(value)) {
|
if (!emailRowMap.has(email)) {
|
||||||
duplicates[field].set(value, [index + 2]);
|
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 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 {
|
} else {
|
||||||
const existingRows = duplicates[field].get(value);
|
validRows.push(rowNumbers[0]);
|
||||||
if (existingRows) {
|
|
||||||
existingRows.push(index + 2);
|
|
||||||
duplicateValues.add(value);
|
|
||||||
existingRows.forEach(rowNum => duplicateRowIndices.add(rowNum));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const infos = classroomTransferState.parsedExcel.rows
|
if (mismatches.length > 0) {
|
||||||
.map((row, index) => {
|
userMismatches.push({
|
||||||
if (errorRowIndices.has(index + 2) || duplicateRowIndices.has(index + 2) || row === null) {
|
email,
|
||||||
return undefined;
|
rows: allRowsForEmail,
|
||||||
|
mismatches
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const { firstName, lastName, studentID, passport_id, email, phone, group, country } = row;
|
|
||||||
if (!email || !EMAIL_REGEX.test(email.toString().trim())) {
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const imports = validRows
|
||||||
email: email.toString().trim().toLowerCase(),
|
.map(rowNum => classroomTransferState.parsedExcel!.rows![rowNum - 2])
|
||||||
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
|
.filter((row): row is any => row !== null)
|
||||||
passport_id: passport_id?.toString().trim() || undefined,
|
.map(row => ({
|
||||||
groupName: group,
|
email: row.email.toString().trim().toLowerCase(),
|
||||||
studentID,
|
name: `${row.firstName ?? ""} ${row.lastName ?? ""}`.trim(),
|
||||||
|
passport_id: row.passport_id?.toString().trim() || undefined,
|
||||||
|
groupName: row.group,
|
||||||
|
studentID: row.studentID,
|
||||||
demographicInformation: {
|
demographicInformation: {
|
||||||
country: country?.countryCode,
|
country: row.country?.countryCode,
|
||||||
passport_id: passport_id?.toString().trim() || undefined,
|
passport_id: row.passport_id?.toString().trim() || undefined,
|
||||||
phone: phone.toString(),
|
phone: row.phone.toString(),
|
||||||
},
|
},
|
||||||
entity: undefined,
|
entity: undefined,
|
||||||
type: undefined
|
type: undefined
|
||||||
} as UserImport;
|
} as UserImport));
|
||||||
})
|
|
||||||
.filter((item): item is UserImport => item !== undefined);
|
|
||||||
|
|
||||||
// On import reset state except excel parsing
|
// On import reset state except excel parsing
|
||||||
setClassroomTransferState((prev) => ({
|
setClassroomTransferState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
stage: 1,
|
stage: 1,
|
||||||
duplicatedRows: { duplicates, count: duplicateRowIndices.size },
|
duplicatedRows,
|
||||||
imports: infos,
|
userMismatches,
|
||||||
|
imports,
|
||||||
notFoundUsers: [],
|
notFoundUsers: [],
|
||||||
otherEntityUsers: [],
|
otherEntityUsers: [],
|
||||||
notOwnedClassrooms: [],
|
notOwnedClassrooms: [],
|
||||||
classroomsToCreate: [],
|
validClassrooms: [],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}, [classroomTransferState.parsedExcel]);
|
}, [classroomTransferState.parsedExcel, entity]);
|
||||||
|
|
||||||
// Stage 2 - Student Filter
|
// Stage 2 - Student Filter
|
||||||
// - Filter non existant students
|
// - Filter non existant students
|
||||||
@@ -323,7 +380,7 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
|
|||||||
...prev,
|
...prev,
|
||||||
stage: 3,
|
stage: 3,
|
||||||
notOwnedClassrooms: notOwnedClassroomsSameName,
|
notOwnedClassrooms: notOwnedClassroomsSameName,
|
||||||
classroomsToCreate: Array.from(classrooms).filter(
|
validClassrooms: Array.from(classrooms).filter(
|
||||||
(name) => !new Set(notOwnedClassroomsSameName).has(name)
|
(name) => !new Set(notOwnedClassroomsSameName).has(name)
|
||||||
)
|
)
|
||||||
}))
|
}))
|
||||||
@@ -334,7 +391,7 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
|
|||||||
if (classroomTransferState.imports.length > 0 && classroomTransferState.stage === 2) {
|
if (classroomTransferState.imports.length > 0 && classroomTransferState.stage === 2) {
|
||||||
crossRefClassrooms();
|
crossRefClassrooms();
|
||||||
}
|
}
|
||||||
}, [classroomTransferState.imports, classroomTransferState.stage, user.id])
|
}, [classroomTransferState.imports, classroomTransferState.stage, user.id, entity])
|
||||||
|
|
||||||
|
|
||||||
const clearAndReset = () => {
|
const clearAndReset = () => {
|
||||||
@@ -342,13 +399,14 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
|
|||||||
setClassroomTransferState({
|
setClassroomTransferState({
|
||||||
stage: 0,
|
stage: 0,
|
||||||
parsedExcel: undefined,
|
parsedExcel: undefined,
|
||||||
duplicatedRows: undefined,
|
duplicatedRows: [],
|
||||||
|
userMismatches: [],
|
||||||
imports: [],
|
imports: [],
|
||||||
notFoundUsers: [],
|
notFoundUsers: [],
|
||||||
otherEntityUsers: [],
|
otherEntityUsers: [],
|
||||||
alreadyInClass: [],
|
alreadyInClass: [],
|
||||||
notOwnedClassrooms: [],
|
notOwnedClassrooms: [],
|
||||||
classroomsToCreate: []
|
validClassrooms: []
|
||||||
});
|
});
|
||||||
clear();
|
clear();
|
||||||
};
|
};
|
||||||
@@ -367,7 +425,6 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
|
|||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
|
|
||||||
const getIds = async () => {
|
const getIds = async () => {
|
||||||
try {
|
try {
|
||||||
const { data: emailIdMap } = await axios.post("/api/users/controller?op=getIds", {
|
const { data: emailIdMap } = await axios.post("/api/users/controller?op=getIds", {
|
||||||
@@ -390,7 +447,6 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
|
|||||||
|
|
||||||
if (!imports) return;
|
if (!imports) return;
|
||||||
|
|
||||||
|
|
||||||
await axios.post("/api/groups/controller?op=deletePriorEntitiesGroups", {
|
await axios.post("/api/groups/controller?op=deletePriorEntitiesGroups", {
|
||||||
ids: imports.map((u) => u.id),
|
ids: imports.map((u) => u.id),
|
||||||
entity
|
entity
|
||||||
@@ -401,6 +457,9 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
|
|||||||
entity
|
entity
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: existingGroupsMap } = await axios.post("/api/groups/controller?op=existantGroupIds", {
|
||||||
|
names: classroomTransferState.validClassrooms
|
||||||
|
});
|
||||||
|
|
||||||
const groupedUsers = imports.reduce((acc, user) => {
|
const groupedUsers = imports.reduce((acc, user) => {
|
||||||
if (!acc[user.groupName]) {
|
if (!acc[user.groupName]) {
|
||||||
@@ -410,9 +469,15 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
|
|||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, UserImport[]>);
|
}, {} as Record<string, UserImport[]>);
|
||||||
|
|
||||||
|
// ##############
|
||||||
|
// # New Groups #
|
||||||
|
// ##############
|
||||||
const newGroupUsers = Object.fromEntries(
|
const newGroupUsers = Object.fromEntries(
|
||||||
Object.entries(groupedUsers)
|
Object.entries(groupedUsers)
|
||||||
.filter(([groupName]) => classroomTransferState.classroomsToCreate.includes(groupName))
|
.filter(([groupName]) =>
|
||||||
|
classroomTransferState.validClassrooms.includes(groupName) &&
|
||||||
|
!existingGroupsMap[groupName]
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const createGroupPromises = Object.entries(newGroupUsers).map(([groupName, users]) => {
|
const createGroupPromises = Object.entries(newGroupUsers).map(([groupName, users]) => {
|
||||||
@@ -425,24 +490,32 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
|
|||||||
return axios.post('/api/groups', groupData);
|
return axios.post('/api/groups', groupData);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ###################
|
||||||
const existingGroupUsers = Object.fromEntries(
|
// # Existant Groups #
|
||||||
|
// ###################
|
||||||
|
const allExistingUsers = Object.fromEntries(
|
||||||
Object.entries(groupedUsers)
|
Object.entries(groupedUsers)
|
||||||
.filter(([groupName]) => !classroomTransferState.classroomsToCreate.includes(groupName))
|
.filter(([groupName]) =>
|
||||||
|
!classroomTransferState.validClassrooms.includes(groupName) ||
|
||||||
|
existingGroupsMap[groupName]
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
let updatePromises: Promise<any>[] = [];
|
let updatePromises: Promise<any>[] = [];
|
||||||
|
|
||||||
if (Object.keys(existingGroupUsers).length > 0) {
|
if (Object.keys(allExistingUsers).length > 0) {
|
||||||
const { groups: groupNameToId, users: userEmailToId } = await axios.post('/api/groups?op=getIds', {
|
const { data: { groups: groupNameToId, users: userEmailToId } } = await axios.post('/api/groups/controller?op=getIds', {
|
||||||
names: Object.keys(existingGroupUsers),
|
names: Object.keys(allExistingUsers),
|
||||||
userEmails: Object.values(existingGroupUsers).flat().map(user => user.email)
|
userEmails: Object.values(allExistingUsers).flat().map(user => user.email)
|
||||||
}).then(response => response.data);
|
});
|
||||||
|
|
||||||
const existingGroupsWithIds = Object.entries(existingGroupUsers).reduce((acc, [groupName, users]) => {
|
const existingGroupsWithIds = Object.entries(allExistingUsers).reduce((acc, [groupName, users]) => {
|
||||||
acc[groupNameToId[groupName]] = users;
|
const groupId = groupNameToId[groupName];
|
||||||
|
if (groupId) {
|
||||||
|
acc[groupId] = users;
|
||||||
|
}
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, any[]>);
|
}, {} as Record<string, UserImport[]>);
|
||||||
|
|
||||||
updatePromises = Object.entries(existingGroupsWithIds).map(([groupId, users]) => {
|
updatePromises = Object.entries(existingGroupsWithIds).map(([groupId, users]) => {
|
||||||
const userIds = users.map(user => userEmailToId[user.email]);
|
const userIds = users.map(user => userEmailToId[user.email]);
|
||||||
@@ -458,6 +531,7 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
toast.success(`Successfully assigned all ${classroomTransferState.imports.length} user(s)!`);
|
toast.success(`Successfully assigned all ${classroomTransferState.imports.length} user(s)!`);
|
||||||
|
router.replace("/classrooms");
|
||||||
onFinish();
|
onFinish();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { IoInformationCircleOutline } from "react-icons/io5";
|
|||||||
import { FaFileDownload } from "react-icons/fa";
|
import { FaFileDownload } from "react-icons/fa";
|
||||||
import { HiOutlineDocumentText } from "react-icons/hi";
|
import { HiOutlineDocumentText } from "react-icons/hi";
|
||||||
import UserImportSummary, { ExcelUserDuplicatesMap } from "@/components/ImportSummaries/User";
|
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]+)*$/);
|
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 [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||||
const [type, setType] = useState<Type>("student");
|
const [type, setType] = useState<Type>("student");
|
||||||
const [showHelp, setShowHelp] = useState(false);
|
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) {
|
if (!entities?.length) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -307,7 +308,7 @@ export default function BatchCreateUser({ user, entities = [], permissions, onFi
|
|||||||
}));
|
}));
|
||||||
setNewUsers(newUsersList);
|
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)
|
emails: dupes.map((x) => x.email)
|
||||||
});
|
});
|
||||||
const withLabels = dupes.map((u) => ({
|
const withLabels = dupes.map((u) => ({
|
||||||
@@ -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?`))
|
if (!confirm(`You are about to ${[newUsersSentence, existingUsersSentence].filter((x) => !!x).join(" and ")}, are you sure you want to continue?`))
|
||||||
return;
|
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) {
|
if (newUsers.length > 0) {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.post("/api/batch_users", { users: newUsers.map((user) => ({ ...user, type, expiryDate })) });
|
const withIds = newUsers.map((user) => ({ ...user, type, expiryDate, id: v4() }));
|
||||||
toast.success(`Successfully added ${newUsers.length} user(s)!`);
|
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();
|
onFinish();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
passport_id?: string;
|
passport_id?: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
};
|
};
|
||||||
entity?: string
|
entity: { id: string, label: string }
|
||||||
entities: { id: string, role: string }[]
|
entities: { id: string, role: string }[]
|
||||||
passwordHash: string | undefined;
|
passwordHash: string | undefined;
|
||||||
passwordSalt: string | undefined;
|
passwordSalt: string | undefined;
|
||||||
@@ -49,11 +49,10 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
const salt = crypto.randomBytes(16).toString('base64');
|
const salt = crypto.randomBytes(16).toString('base64');
|
||||||
const hash = await scrypt.hash(user.passport_id, salt);
|
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)
|
const defaultRole = findBy(entity?.roles || [], "isDefault", true)
|
||||||
|
|
||||||
currentUser.entities = [{ id: entity?.id || "", role: defaultRole?.id || "" }]
|
currentUser.entities = [{ id: entity?.id || "", role: defaultRole?.id || "" }]
|
||||||
delete currentUser.entity
|
|
||||||
|
|
||||||
currentUser.email = currentUser.email.toLowerCase();
|
currentUser.email = currentUser.email.toLowerCase();
|
||||||
currentUser.passwordHash = hash;
|
currentUser.passwordHash = hash;
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
else if (req.method === 'POST') {
|
else if (req.method === 'POST') {
|
||||||
switch (op) {
|
switch (op) {
|
||||||
|
case 'existantGroupIds':
|
||||||
|
res.status(200).json(await existantGroupIds(req.body.names));
|
||||||
|
break;
|
||||||
case 'crossRefOwnership':
|
case 'crossRefOwnership':
|
||||||
res.status(200).json(await crossRefOwnership(req.body));
|
res.status(200).json(await crossRefOwnership(req.body));
|
||||||
break;
|
break;
|
||||||
@@ -104,7 +107,6 @@ async function getIds(body: any): Promise<Record<string, string>> {
|
|||||||
|
|
||||||
async function deletePriorEntitiesGroups(body: any) {
|
async function deletePriorEntitiesGroups(body: any) {
|
||||||
const { ids, entity } = body;
|
const { ids, entity } = body;
|
||||||
|
|
||||||
if (!Array.isArray(ids) || ids.length === 0 || !entity) {
|
if (!Array.isArray(ids) || ids.length === 0 || !entity) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -123,8 +125,33 @@ async function deletePriorEntitiesGroups(body: any) {
|
|||||||
return;
|
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 } },
|
{ participants: { $in: toDeleteUserIds } },
|
||||||
{ $pull: { participants: { $in: toDeleteUserIds } } } as any
|
{ $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>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export default function Home({ user, groups, entities }: Props) {
|
|||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
<Layout user={user} className="!gap-4">
|
<Layout user={user} className="!gap-4">
|
||||||
<section className="flex flex-col gap-4 w-full h-full">
|
<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)} />
|
<StudentClassroomTransfer user={user} entities={entities} onFinish={() => setShowImport(false)} />
|
||||||
</Modal>
|
</Modal>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user