import { useEffect, useMemo, useRef, useState } from "react"; import * as XLSX from "xlsx"; import { useQueries } from "@tanstack/react-query"; import { Button } from "@/shared/common/ui/button"; import { Alert, AlertDescription, AlertTitle } from "@/shared/common/ui/alert"; import { toast } from "sonner"; import { BulkUploadPositionDto, BulkUploadUserDto, uploadBulkUsers, updateBulkUsers, UploadUserPayload, UploadUpdate, } from "../services/api/uploadBulkService"; import { useUnit } from "../hooks/useUnit"; import { useAuth } from "@/shared/context/AuthContext"; import { AlertCircle, Loader2, X } from "lucide-react"; import { UnitDto } from "../dto/unit/unitDto"; import { t } from "i18next"; import i18n from "@/i18n"; import { useErrorHandler } from "@/shared/hooks/useErrorHandler"; import { getChildUnits } from "../services/api/unitService"; import { SingleSelect } from "@/shared/common/ui/single-select"; import { logBulkUploadParseAbort, logBulkUploadParseError, logBulkUploadDuplicateCheck, collectBulkUploadDuplicateErrors, logBulkUploadParseStart, logBulkUploadParseSuccess, logBulkUploadSheetParse, logBulkUploadValidation, logBulkUploadWorkbook, } from "./bulkUploadParserLog"; export const BulkUserUpload = () => { type UploadErrorState = { title: string; message: string; errors?: string[]; }; const [activeTab, setActiveTab] = useState<"new" | "update">("new"); const inputRef = useRef(null); const updateInputRef = useRef(null); const [fileName, setFileName] = useState(null); const [updateFileName, setUpdateFileName] = useState(null); const [unitId, setUnitId] = useState(""); const [parsedUsers, setParsedUsers] = useState( null, ); const [parsedUpdatePositions, setParsedUpdatePositions] = useState(null); const [isUploading, setIsUploading] = useState(false); const [isUpdateUploading, setIsUpdateUploading] = useState(false); const [isParsing, setIsParsing] = useState(false); const [isUpdateParsing, setIsUpdateParsing] = useState(false); const [showPreview, setShowPreview] = useState(false); const [showUpdatePreview, setShowUpdatePreview] = useState(false); const [uploadError, setUploadError] = useState(null); const [updateUploadError, setUpdateUploadError] = useState(null); const [submitError, setSubmitError] = useState(null); const [updateSubmitError, setUpdateSubmitError] = useState(null); const { user } = useAuth(); const lang = i18n.language; const { getErrorMessage } = useErrorHandler(t); const organizationId = user?.employee && user.employee.length > 0 ? user.employee[0].organizationId : undefined; const { getAccessibleList } = useUnit(); const { data: unitsResponse, isLoading } = getAccessibleList( organizationId ?? "", { take: 3000, skip: 0 }, ); const topLevelUnits: UnitDto[] = unitsResponse?.data?.items ?? []; // Fetch related children for each top-level unit so they appear in the // selector and can be the import target. const childQueries = useQueries({ queries: topLevelUnits.map((u) => ({ queryKey: ["unitChildren", u.id], queryFn: () => getChildUnits(u.id), enabled: !!u.id, staleTime: 0, })), }); const unitOptions = useMemo(() => { type Option = { id: string; name: UnitDto["name"]; depth: number }; const out: Option[] = []; topLevelUnits.forEach((parent, idx) => { out.push({ id: parent.id, name: parent.name, depth: 0 }); const childResp = childQueries[idx]?.data?.data; const items: any[] = Array.isArray(childResp) ? childResp : (childResp?.items ?? []); items.forEach((child: any) => { if (!child?.id) return; out.push({ id: child.id, name: child.name, depth: 1 }); }); }); const seen = new Set(); return out.filter((o) => { if (seen.has(o.id)) return false; seen.add(o.id); return true; }); }, [unitsResponse, childQueries.map((q) => q.data).join("|")]); console.log("Unit options for bulk upload:", unitOptions); useEffect(() => { if (unitOptions.length === 1) { setUnitId(unitOptions[0].id); } }, [unitOptions]); const clearUploadState = () => { setParsedUsers(null); setShowPreview(false); }; const clearUpdateUploadState = () => { setParsedUpdatePositions(null); setShowUpdatePreview(false); }; const formatDuplicateErrors = (errorsPayload: unknown): string[] => { // Backend ships duplicates as an array of single-key objects: // [{ "row 2": { "username": "username_already_exists: habtamu" } }, …] // Each inner field value is ": ". Older // shapes also send a flat { field: ": " } — handle both. const titleCase = (s: string) => s.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); const parsePair = (raw: string): { reason: string; value: string } => { const i = raw.indexOf(":"); return i >= 0 ? { reason: raw.slice(0, i).trim(), value: raw.slice(i + 1).trim() } : { reason: raw.trim(), value: "" }; }; const phrase = (field: string, raw: string) => { const { reason, value } = parsePair(raw); const fieldLabel = titleCase(field); if (reason.endsWith("_already_exists")) { return value ? `${fieldLabel} "${value}" already exists` : `${fieldLabel} already exists`; } const reasonLabel = titleCase(reason); return value ? `${fieldLabel}: ${reasonLabel} (${value})` : `${fieldLabel}: ${reasonLabel}`; }; const out: { row: number; text: string }[] = []; const pushRow = (rowKey: string, fields: Record) => { const rowMatch = /(\d+)/.exec(rowKey); const rowNum = rowMatch ? Number(rowMatch[1]) : Number.MAX_SAFE_INTEGER; const rowLabel = rowMatch ? `Row ${rowMatch[1]}` : rowKey; for (const [field, raw] of Object.entries(fields)) { if (typeof raw !== "string" || !raw.trim()) continue; out.push({ row: rowNum, text: `${rowLabel} — ${phrase(field, raw)}` }); } }; if (Array.isArray(errorsPayload)) { for (const entry of errorsPayload) { if (!entry || typeof entry !== "object") continue; for (const [k, v] of Object.entries(entry as Record)) { if (v && typeof v === "object") { pushRow(k, v as Record); } } } } else if (errorsPayload && typeof errorsPayload === "object") { // Flat object fallback: { "row 2": {...} } or { username: "msg" } const obj = errorsPayload as Record; for (const [k, v] of Object.entries(obj)) { if (v && typeof v === "object") { pushRow(k, v as Record); } else if (typeof v === "string" && v.trim()) { out.push({ row: Number.MAX_SAFE_INTEGER, text: phrase(k, v) }); } } } return out.sort((a, b) => a.row - b.row).map((item) => item.text); }; const extractSubmitErrorState = async ( error: unknown, ): Promise => { const fallbackMessage = await getErrorMessage(error); const fallbackErrors = fallbackMessage .split(",") .map((item) => item.trim()) .filter(Boolean); if (typeof error === "object" && error !== null && "response" in error) { let data: unknown = (error as any).response?.data; if (data instanceof Blob) { try { data = JSON.parse(await data.text()); } catch { data = null; } } const message = (data as any)?.message ?? (data as any)?.exception?.response?.message; const errorsPayload = (data as any)?.errors ?? (data as any)?.exception?.response?.errors; if ( data && typeof data === "object" && message === "duplicate_values_found" ) { const duplicateErrors = formatDuplicateErrors(errorsPayload); return { title: "Duplicate values found", message: duplicateErrors.length > 0 ? `Found ${duplicateErrors.length} duplicate value${duplicateErrors.length > 1 ? "s" : ""}. Fix the rows below in your Excel file and try again.` : "Some values already exist in the system. Review the conflicts and update the file before trying again.", errors: duplicateErrors.length > 0 ? duplicateErrors : undefined, }; } } if (fallbackErrors.length > 1) { return { title: "We could not submit these users", message: "Some records could not be submitted. Review the issues below, update the file, and try again.", errors: fallbackErrors, }; } return { title: "We could not submit these users", message: fallbackMessage, }; }; const handleFileChange = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; const inputElement = e.target; if (!file) return; try { setIsParsing(true); clearUploadState(); setUploadError(null); setSubmitError(null); setFileName(file.name); logBulkUploadParseStart("new", file.name); const data = await file.arrayBuffer(); const workbook = XLSX.read(data, { type: "array" }); logBulkUploadWorkbook("new", workbook.SheetNames); const positionsSheet = workbook.Sheets["Positions"]; const orgStructureSheet = workbook.Sheets["OrgStructure"]; if (!positionsSheet && !orgStructureSheet) { logBulkUploadParseAbort("new", "missing_required_sheets", { expectedSheets: ["Positions", "OrgStructure"], foundSheets: workbook.SheetNames, }); toast.error(t("contentManagement.posMissing")); setUploadError({ title: "Missing template sheets", message: "The file must include the 'Positions' and 'OrgStructure' sheets before it can be imported.", }); clearUploadState(); return; } const getCellValue = (row: Record, key: string) => { if (row[key] !== undefined) return row[key]; const matchedKey = Object.keys(row).find( (k) => k.trim().toLowerCase() === key.toLowerCase(), ); return matchedKey ? row[matchedKey] : ""; }; const safeTrim = (value: unknown) => String(value ?? "").trim(); const getExcelRowNumber = ( row: Record, fallbackIndex: number, ) => { const rawRowNum = row.__rowNum__; return typeof rawRowNum === "number" ? rawRowNum + 1 : fallbackIndex + 2; }; const hasAnyMappedValue = (row: object) => Object.values(row).some((val) => safeTrim(val) !== ""); const hasAnyRequiredUserValue = (row: BulkUploadUserDto) => [ row.EnglishFirstName, row.EnglishLastName, row.Email, row.PhoneNumber, row.Username, row.Position, ].some((val) => safeTrim(val) !== ""); // Parse both sheets const rawPositions = positionsSheet ? XLSX.utils.sheet_to_json(positionsSheet, { defval: "", }) : []; const positions = rawPositions .map((i: unknown) => { const rowData = i as Record; const row: BulkUploadPositionDto = { Position: safeTrim(getCellValue(rowData, "EnglishPosition")), AmharicPosition: safeTrim( getCellValue(rowData, "AmharicPosition"), ), ReportsTo: safeTrim(getCellValue(rowData, "ReportsTo")), PositionType: safeTrim(getCellValue(rowData, "PositionType")), }; return row; }) .filter((row) => hasAnyMappedValue(row)); logBulkUploadSheetParse("new", "Positions", { rawRowCount: rawPositions.length, parsedRowCount: positions.length, sampleRow: positions[0] ?? null, }); const rawOrgStructure = orgStructureSheet ? XLSX.utils.sheet_to_json(orgStructureSheet, { defval: "", }) : []; const orgStructure: Array = rawOrgStructure .map((r: unknown, index) => { const rowData = r as Record; const row: BulkUploadUserDto & { __rowNumber: number } = { AmharicFirstName: safeTrim( getCellValue(rowData, "AmharicFirstName"), ), AmharicLastName: safeTrim( getCellValue(rowData, "AmharicLastName"), ), EnglishFirstName: safeTrim( getCellValue(rowData, "EnglishFirstName"), ), EnglishLastName: safeTrim( getCellValue(rowData, "EnglishLastName"), ), Email: safeTrim(getCellValue(rowData, "Email")), PhoneNumber: safeTrim(getCellValue(rowData, "PhoneNumber")), Position: safeTrim(getCellValue(rowData, "Position")), Username: safeTrim(getCellValue(rowData, "Username")), __rowNumber: getExcelRowNumber(rowData, index), }; return row; }) .filter((row) => hasAnyRequiredUserValue(row)); logBulkUploadSheetParse("new", "OrgStructure", { rawRowCount: rawOrgStructure.length, parsedRowCount: orgStructure.length, sampleRow: orgStructure[0] ? { ...orgStructure[0], Email: orgStructure[0].Email ? "[redacted]" : "", PhoneNumber: orgStructure[0].PhoneNumber ? "[redacted]" : "", } : null, }); // Validate presence if (!positions.length && !orgStructure.length) { logBulkUploadParseAbort("new", "no_importable_rows", { positionsCount: positions.length, usersCount: orgStructure.length, }); toast.error(t("contentManagement.invalidFile")); setUploadError({ title: "No importable rows found", message: "We could not find any valid rows in the uploaded file. Check that the template columns are filled and try again.", }); clearUploadState(); return; } // Validate user data const validationErrors: string[] = []; orgStructure.forEach((user) => { const rowNumber = user.__rowNumber; if (!user.EnglishFirstName?.trim()) { validationErrors.push( `Row ${rowNumber}: English First Name is required`, ); } if (!user.EnglishLastName?.trim()) { validationErrors.push( `Row ${rowNumber}: English Last Name is required`, ); } if (!user.Email?.trim()) { validationErrors.push(`Row ${rowNumber}: Email is required`); } if (!user.PhoneNumber?.trim()) { validationErrors.push(`Row ${rowNumber}: Phone Number is required`); } if (!user.Username?.trim()) { validationErrors.push(`Row ${rowNumber}: Username is required`); } if (!user.Position?.trim()) { validationErrors.push(`Row ${rowNumber}: Position is required`); } // Additional validation for email format if ( user.Email?.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(user.Email.trim()) ) { validationErrors.push(`Row ${rowNumber}: Invalid email format`); } // Additional validation for phone number (basic check for digits) if ( user.PhoneNumber?.trim() && !/^\+?[\d\s\-\(\)]+$/.test(user.PhoneNumber.trim()) ) { validationErrors.push( `Row ${rowNumber}: Invalid phone number format`, ); } }); if (validationErrors.length > 0) { logBulkUploadValidation("new", { errorCount: validationErrors.length, errors: validationErrors, }); toast.error(`Found ${validationErrors.length} validation errors.`); setUploadError({ title: "Please fix the highlighted rows", message: "Some rows are missing required values or contain invalid formats. Update the file and upload it again.", errors: validationErrors, }); clearUploadState(); return; } const duplicateErrors = collectBulkUploadDuplicateErrors(orgStructure); logBulkUploadDuplicateCheck( "new", positions, orgStructure, ); if (duplicateErrors.length > 0) { logBulkUploadParseAbort("new", "duplicate_values_in_file", { errorCount: duplicateErrors.length, errors: duplicateErrors, }); logBulkUploadValidation("new", { errorCount: duplicateErrors.length, errors: duplicateErrors, type: "duplicate", }); toast.error( `Found ${duplicateErrors.length} duplicate value${duplicateErrors.length > 1 ? "s" : ""} in the file.`, ); setUploadError({ title: "Duplicate values found in file", message: "The Excel file contains duplicate usernames, emails, or phone numbers. Fix the rows below and upload again.", errors: duplicateErrors, }); clearUploadState(); return; } // You can pass this to backend or store in state const parsedDto: UploadUserPayload = { unitId: unitId, positions: positions as BulkUploadPositionDto[], users: orgStructure.map(({ __rowNumber, ...user }) => user), }; setParsedUsers(parsedDto); setUploadError(null); setSubmitError(null); logBulkUploadParseSuccess("new", { unitId, positionsCount: parsedDto.positions.length, usersCount: parsedDto.users.length, payload: parsedDto, }); toast.success(t("contentManagement.parseSuccess")); } catch (error) { logBulkUploadParseError("new", error, { fileName: file.name }); toast.error(t("contentManagement.parseError")); setUploadError({ title: "We could not read this file", message: error instanceof Error ? error.message : "The uploaded file could not be parsed. Please verify the Excel format and try again.", }); clearUploadState(); } finally { setIsParsing(false); inputElement.value = ""; } }; const handleSubmit = async () => { if (!parsedUsers) { toast.error(t("contentManagement.noData")); return; } if (!unitId) { toast.warning(t("organization.selectUnit")); return; } try { setIsUploading(true); setSubmitError(null); await uploadBulkUsers({ ...parsedUsers, unitId }); toast.success(t("organization.uploadSuccess")); setParsedUsers(null); setFileName(null); setShowPreview(false); } catch (error) { setSubmitError(await extractSubmitErrorState(error)); } finally { setIsUploading(false); } }; // ── Update-existing tab: parse only the Positions sheet ── const handleUpdateFileChange = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; const inputElement = e.target; if (!file) return; try { setIsUpdateParsing(true); clearUpdateUploadState(); setUpdateUploadError(null); setUpdateSubmitError(null); setUpdateFileName(file.name); logBulkUploadParseStart("update", file.name); const data = await file.arrayBuffer(); const workbook = XLSX.read(data, { type: "array" }); logBulkUploadWorkbook("update", workbook.SheetNames); const positionsSheet = workbook.Sheets["Positions"]; if (!positionsSheet) { logBulkUploadParseAbort("update", "missing_positions_sheet", { expectedSheets: ["Positions"], foundSheets: workbook.SheetNames, }); toast.error(t("contentManagement.posMissing")); setUpdateUploadError({ title: "Missing Positions sheet", message: "The file must include the 'Positions' sheet before it can be imported.", }); clearUpdateUploadState(); return; } const getCellValue = (row: Record, key: string) => { if (row[key] !== undefined) return row[key]; const matchedKey = Object.keys(row).find( (k) => k.trim().toLowerCase() === key.toLowerCase(), ); return matchedKey ? row[matchedKey] : ""; }; const safeTrim = (value: unknown) => String(value ?? "").trim(); const hasAnyMappedValue = (row: object) => Object.values(row).some((val) => safeTrim(val) !== ""); const rawPositions = XLSX.utils.sheet_to_json(positionsSheet, { defval: "", }); const positions: BulkUploadPositionDto[] = rawPositions .map((i: unknown) => { const rowData = i as Record; const row: BulkUploadPositionDto = { Position: safeTrim(getCellValue(rowData, "EnglishPosition")), AmharicPosition: safeTrim(getCellValue(rowData, "AmharicPosition")), ReportsTo: safeTrim(getCellValue(rowData, "ReportsTo")), PositionType: safeTrim(getCellValue(rowData, "PositionType")), }; return row; }) .filter((row) => hasAnyMappedValue(row)); logBulkUploadSheetParse("update", "Positions", { rawRowCount: rawPositions.length, parsedRowCount: positions.length, sampleRow: positions[0] ?? null, }); if (!positions.length) { logBulkUploadParseAbort("update", "no_importable_rows", { positionsCount: positions.length, }); toast.error(t("contentManagement.invalidFile")); setUpdateUploadError({ title: "No importable rows found", message: "We could not find any valid rows in the Positions sheet. Check that the template columns are filled and try again.", }); clearUpdateUploadState(); return; } const parsedDto: UploadUpdate = { unitId: unitId, positions, }; setParsedUpdatePositions(parsedDto); setUpdateUploadError(null); setUpdateSubmitError(null); logBulkUploadParseSuccess("update", { unitId, positionsCount: parsedDto.positions.length, payload: parsedDto, }); toast.success(t("contentManagement.parseSuccess")); } catch (error) { logBulkUploadParseError("update", error, { fileName: file.name }); toast.error(t("contentManagement.parseError")); setUpdateUploadError({ title: "We could not read this file", message: error instanceof Error ? error.message : "The uploaded file could not be parsed. Please verify the Excel format and try again.", }); clearUpdateUploadState(); } finally { setIsUpdateParsing(false); inputElement.value = ""; } }; const handleUpdateSubmit = async () => { if (!parsedUpdatePositions) { toast.error(t("contentManagement.noData")); return; } if (!unitId) { toast.warning(t("organization.selectUnit")); return; } try { setIsUpdateUploading(true); setUpdateSubmitError(null); await updateBulkUsers({ ...parsedUpdatePositions, unitId }); toast.success(t("organization.uploadSuccess")); setParsedUpdatePositions(null); setUpdateFileName(null); setShowUpdatePreview(false); } catch (error) { setUpdateSubmitError(await extractSubmitErrorState(error)); } finally { setIsUpdateUploading(false); } }; return (
{activeTab === "new" && ( <>
{unitOptions.length > 0 && (
setUnitId(value)} placeholder={ isLoading ? t("organization.loading") : t("organization.selectUnit") } options={unitOptions.map((u) => { const prefix = u.depth > 0 ? "└─ " : ""; const indent = "\u00A0".repeat(u.depth * 4); return { label: `${indent}${prefix}${lang === "en" ? u.name?.en : u.name?.am}`, value: u.id, }; })} className="mt-1" />
)}

{t("contentManagement.bulkMsg")}

{fileName && ( {fileName} )}
{isParsing && (

Reading and validating your Excel file

Please wait while we parse the sheets and check for errors.

)} {uploadError && (
{uploadError.title} {uploadError.message}
{uploadError.errors?.length ? (
Validation details {uploadError.errors.length} issue {uploadError.errors.length > 1 ? "s" : ""}
    {uploadError.errors.map((errorMessage) => (
  • {errorMessage}
  • ))}
) : null}
)} {parsedUsers && (
{isUploading && (

Submitting users to the server

Please wait while we create the uploaded users and positions.

)} {submitError && (
{submitError.title} {submitError.message}
{submitError.errors?.length ? (
Submission details {submitError.errors.length} issue {submitError.errors.length > 1 ? "s" : ""}
    {submitError.errors.map((errorMessage) => (
  • {errorMessage}
  • ))}
) : null}
)}
)} {showPreview && parsedUsers && (

{t("contentManagement.preview")}

                {JSON.stringify(parsedUsers, null, 2)}
              
)} )} {activeTab === "update" && ( <>
{unitOptions.length > 0 && (
setUnitId(value)} placeholder={ isLoading ? t("organization.loading") : t("organization.selectUnit") } options={unitOptions.map((u) => { const prefix = u.depth > 0 ? "└─ " : ""; const indent = "\u00A0".repeat(u.depth * 4); return { label: `${indent}${prefix}${lang === "en" ? u.name?.en : u.name?.am}`, value: u.id, }; })} className="mt-1" />
)}

{t("contentManagement.bulkMsg2")}

{updateFileName && ( {updateFileName} )}
{isUpdateParsing && (

Reading and validating your Excel file

Please wait while we parse the Positions sheet and check for errors.

)} {updateUploadError && (
{updateUploadError.title} {updateUploadError.message}
{updateUploadError.errors?.length ? (
Validation details {updateUploadError.errors.length} issue {updateUploadError.errors.length > 1 ? "s" : ""}
    {updateUploadError.errors.map((errorMessage) => (
  • {errorMessage}
  • ))}
) : null}
)} {parsedUpdatePositions && (
{isUpdateUploading && (

Submitting positions to the server

Please wait while we update the positions.

)} {updateSubmitError && (
{updateSubmitError.title} {updateSubmitError.message}
{updateSubmitError.errors?.length ? (
Submission details {updateSubmitError.errors.length} issue {updateSubmitError.errors.length > 1 ? "s" : ""}
    {updateSubmitError.errors.map((errorMessage) => (
  • {errorMessage}
  • ))}
) : null}
)}
)} {showUpdatePreview && parsedUpdatePositions && (

{t("contentManagement.preview")}

                {JSON.stringify(parsedUpdatePositions, null, 2)}
              
)} )}
); };