diff --git a/src/components/ImportSummaries/Classroom.tsx b/src/components/ImportSummaries/Classroom.tsx index d0386412..215d7d53 100644 --- a/src/components/ImportSummaries/Classroom.tsx +++ b/src/components/ImportSummaries/Classroom.tsx @@ -14,19 +14,28 @@ 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 ( <> @@ -120,11 +129,11 @@ const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ sta )} - {state.duplicatedRows && state.duplicatedRows.count > 0 && ( + {state.duplicatedRows.length > 0 && (
- {state.duplicatedRows.count} duplicate entries in file + {state.duplicatedRows.length} duplicate entries in file
)} + {state.userMismatches.length > 0 && ( +
+
+ + {state.userMismatches.length} users with mismatched information +
+ +
+ )} + {errorCount > 0 && (
@@ -151,73 +175,79 @@ const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ sta )}
- {((state.duplicatedRows?.count ?? 0) > 0 || errorCount > 0 || - state.notFoundUsers.length > 0 || state.otherEntityUsers.length > 0 || - state.notOwnedClassrooms.length > 0) && ( -
-
-
- -
-
-

- The following will be excluded from transfer: -

-
    - {state.notFoundUsers.length > 0 && ( -
  • -
    - {state.notFoundUsers.length} users not found -
    -
  • - )} - {state.otherEntityUsers.length > 0 && ( -
  • -
    - {state.otherEntityUsers.length} users from different entities -
    -
  • - )} - {state.notOwnedClassrooms.length > 0 && ( -
  • -
    - {state.notOwnedClassrooms.length} classrooms not owned: -
    - {state.notOwnedClassrooms.join(', ')} + {(state.duplicatedRows.length > 0 || state.userMismatches.length > 0 || errorCount > 0 || + state.notFoundUsers.length > 0 || state.otherEntityUsers.length > 0 || + state.notOwnedClassrooms.length > 0) && ( +
    +
    +
    + +
    +
    +

    + The following will be excluded from transfer: +

    +
      + {state.notFoundUsers.length > 0 && ( +
    • +
      + {state.notFoundUsers.length} users not found
      -
    -
  • - )} - {state.duplicatedRows && state.duplicatedRows.count > 0 && ( -
  • -
    - {state.duplicatedRows.count} duplicate entries -
    -
  • - )} - {state.alreadyInClass && state.alreadyInClass.length > 0 && ( -
  • -
    - {state.alreadyInClass.length} users that are already assigned to the classroom -
    -
  • - )} - {errorCount > 0 && ( -
  • -
    - {errorCount} rows with invalid information -
    -
  • - )} -
+ + )} + {state.otherEntityUsers.length > 0 && ( +
  • +
    + {state.otherEntityUsers.length} users from different entities +
    +
  • + )} + {state.notOwnedClassrooms.length > 0 && ( +
  • +
    + {state.notOwnedClassrooms.length} classrooms not owned: +
    + {state.notOwnedClassrooms.join(', ')} +
    +
    +
  • + )} + {state.duplicatedRows.length > 0 && ( +
  • +
    + {state.duplicatedRows.length} duplicate entries +
    +
  • + )} + + {state.userMismatches.length > 0 && ( +
  • +
    + {state.userMismatches.length} users with mismatched information +
    +
  • + )} + {state.alreadyInClass && state.alreadyInClass.length > 0 && ( +
  • +
    + {state.alreadyInClass.length} users that are already assigned to the classroom +
    +
  • + )} + {errorCount > 0 && ( +
  • +
    + {errorCount} rows with invalid information +
    +
  • + )} + +
    -
    - )} + )}
    - - {/* Modals */} setShowErrorsModal(false)}> <>
    @@ -283,53 +313,59 @@ const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ sta

    Duplicate Entries

    - {state.duplicatedRows && ( -
    - {(Object.keys(state.duplicatedRows.duplicates) as Array).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 ( -
    -
    -

    - {field} duplicates -

    - - {duplicates.length} {duplicates.length === 1 ? 'duplicate' : 'duplicates'} - +
    + {state.duplicatedRows.map((duplicate) => ( +
    +
    +
    + Row {duplicate.rowNumber} +
    + Email: {duplicate.email}
    - -
    - {duplicates.map(([value, rows]) => ( -
    -
    -
    - {value} -
    - Appears in rows: - - {rows.join(', ')} - -
    -
    - - {rows.length} occurrences - -
    -
    - ))} +
    + Classroom: {duplicate.classroom}
    - ); - })} -
    - )} +
    +
    + ))} +
    + + + setShowMismatchesModal(false)}> + <> +
    + +

    Mismatched User Information

    +
    +
    + {state.userMismatches.map((mismatch) => ( +
    +
    + Email: {mismatch.email} +
    +
    + Rows: {mismatch.rows.join(', ')} +
    +
    + {mismatch.mismatches.map((field) => ( +
    +
    {fieldMapper[field.field]}:
    +
    + Values: {field.values.join(', ')} +
    +
    + ))} +
    +
    + ))} +
    diff --git a/src/components/ImportSummaries/Codegen.tsx b/src/components/ImportSummaries/Codegen.tsx index 3e531d30..a48068d2 100644 --- a/src/components/ImportSummaries/Codegen.tsx +++ b/src/components/ImportSummaries/Codegen.tsx @@ -116,7 +116,7 @@ const CodeGenImportSummary: React.FC = ({ infos, parsedExcel, duplicateRo if (duplicates.length === 0) return null; return (
    - {field}: rows { + {fieldMapper[field]}: rows { duplicates.map(([_, rows]) => rows.join(', ')).join('; ') }
    diff --git a/src/components/ImportSummaries/User.tsx b/src/components/ImportSummaries/User.tsx index c89e3f4a..2e15b331 100644 --- a/src/components/ImportSummaries/User.tsx +++ b/src/components/ImportSummaries/User.tsx @@ -162,7 +162,7 @@ const UserImportSummary: React.FC = ({ parsedExcel, newUsers, enlistedUse if (duplicates.length === 0) return null; return (
    - {field}: rows { + {fieldMapper[field]}: rows { duplicates.map(([_, rows]) => rows.join(', ')).join('; ') }
    diff --git a/src/components/Imports/StudentClassroomTransfer.tsx b/src/components/Imports/StudentClassroomTransfer.tsx index bc6f2dc6..ae7d127c 100644 --- a/src/components/Imports/StudentClassroomTransfer.tsx +++ b/src/components/Imports/StudentClassroomTransfer.tsx @@ -22,10 +22,26 @@ import { EntityWithRoles } from "@/interfaces/entity"; 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[]; @@ -43,7 +59,8 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole const [classroomTransferState, setClassroomTransferState] = useState({ stage: 0, parsedExcel: undefined, - duplicatedRows: undefined, + duplicatedRows: [], + userMismatches: [], imports: [], notFoundUsers: [], otherEntityUsers: [], @@ -169,79 +186,116 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole }, [filesContent]); // 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(); - const duplicateRowIndices = new Set(); - const errorRowIndices = new Set( classroomTransferState.parsedExcel.errors?.map(error => error.row) || [] ); - + + const emailClassroomMap = new Map>(); // email -> (group -> row numbers) + const emailRowMap = new Map>(); // 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).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: [], @@ -342,7 +396,8 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole setClassroomTransferState({ stage: 0, parsedExcel: undefined, - duplicatedRows: undefined, + duplicatedRows: [], + userMismatches: [], imports: [], notFoundUsers: [], otherEntityUsers: [], diff --git a/src/pages/classrooms/index.tsx b/src/pages/classrooms/index.tsx index 958450c4..293ac3d1 100644 --- a/src/pages/classrooms/index.tsx +++ b/src/pages/classrooms/index.tsx @@ -124,7 +124,7 @@ export default function Home({ user, groups, entities }: Props) {
    - setShowImport(false)}> + setShowImport(false)} maxWidth="max-w-[85%]"> setShowImport(false)} />