ENCOA-309

This commit is contained in:
Carlos-Mesquita
2025-01-05 21:03:52 +00:00
parent 61e07dae95
commit 8f77f28aaa
5 changed files with 264 additions and 173 deletions

View File

@@ -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 (
<>
<Card>
@@ -120,11 +129,11 @@ const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ sta
</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 +144,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 +175,79 @@ 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 ||
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>
</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.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(', ')}
</div>
</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">
@@ -283,53 +313,59 @@ const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ sta
<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

@@ -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<ClassroomTransferState>({
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<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: [],
@@ -342,7 +396,8 @@ const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRole
setClassroomTransferState({
stage: 0,
parsedExcel: undefined,
duplicatedRows: undefined,
duplicatedRows: [],
userMismatches: [],
imports: [],
notFoundUsers: [],
otherEntityUsers: [],