Files
edr-platform/apps/edr-freight-web/backoffice/src/user-management/bulkUpload/bulkUpload.tsx
natib21 e6e44e773b fix ui
2026-07-10 11:25:59 +00:00

1227 lines
49 KiB
TypeScript

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<HTMLInputElement | null>(null);
const updateInputRef = useRef<HTMLInputElement | null>(null);
const [fileName, setFileName] = useState<string | null>(null);
const [updateFileName, setUpdateFileName] = useState<string | null>(null);
const [unitId, setUnitId] = useState("");
const [parsedUsers, setParsedUsers] = useState<UploadUserPayload | null>(
null,
);
const [parsedUpdatePositions, setParsedUpdatePositions] = useState<UploadUpdate | null>(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<UploadErrorState | null>(null);
const [updateUploadError, setUpdateUploadError] = useState<UploadErrorState | null>(null);
const [submitError, setSubmitError] = useState<UploadErrorState | null>(null);
const [updateSubmitError, setUpdateSubmitError] = useState<UploadErrorState | null>(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<string>();
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 "<reason_key>: <conflicting_value>". Older
// shapes also send a flat { field: "<reason>: <value>" } — 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<string, unknown>) => {
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<string, unknown>)) {
if (v && typeof v === "object") {
pushRow(k, v as Record<string, unknown>);
}
}
}
} else if (errorsPayload && typeof errorsPayload === "object") {
// Flat object fallback: { "row 2": {...} } or { username: "msg" }
const obj = errorsPayload as Record<string, unknown>;
for (const [k, v] of Object.entries(obj)) {
if (v && typeof v === "object") {
pushRow(k, v as Record<string, unknown>);
} 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<UploadErrorState> => {
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<HTMLInputElement>) => {
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<string, unknown>, 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<string, unknown>,
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<string, unknown>;
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<BulkUploadUserDto & { __rowNumber: number }> =
rawOrgStructure
.map((r: unknown, index) => {
const rowData = r as Record<string, unknown>;
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<HTMLInputElement>) => {
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<string, unknown>, 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<string, unknown>;
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 (
<div className="p-4 border rounded-xl bg-white shadow-sm dark:bg-gray-900 mt-10">
<div className="mb-4 flex gap-2 border-b border-gray-200 dark:border-gray-700 pb-2">
<button
className={`px-4 py-2 text-sm font-medium rounded-t transition-colors ${
activeTab === "new"
? "bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
: "text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
}`}
onClick={() => setActiveTab("new")}
>
{t("contentManagement.uploadNew")}
</button>
<button
className={`px-4 py-2 text-sm font-medium rounded-t transition-colors ${
activeTab === "update"
? "bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
: "text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
}`}
onClick={() => setActiveTab("update")}
>
{t("contentManagement.updateExisting")}
</button>
</div>
{activeTab === "new" && (
<>
<div>
{unitOptions.length > 0 && (
<div className="mb-4 w-1/2">
<label className="block text-sm font-medium text-gray-700">
{t("organization.selectUnit")}
</label>
<SingleSelect
value={unitId}
onValueChange={(value) => 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"
/>
</div>
)}
</div>
<p className="text-sm mb-2 text-gray-700 dark:text-gray-300 font-medium">
{t("contentManagement.bulkMsg")}
</p>
<div className="flex items-center gap-3 mb-3">
<Button
variant="outline"
onClick={() => {
if (!unitId) {
toast.warning(t("organization.selectUnit"));
return;
}
inputRef.current?.click();
}}
disabled={isUploading || isParsing || !unitId}
>
{isParsing ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Parsing file...
</span>
) : isUploading ? (
t("userIncoming.Processing...")
) : (
t("contentManagement.selectFile")
)}
</Button>
{fileName && (
<span className="text-sm text-gray-500 dark:text-gray-400 truncate">
{fileName}
</span>
)}
</div>
<input
ref={inputRef}
type="file"
accept=".xlsx, .xls"
onChange={handleFileChange}
className="hidden"
/>
{isParsing && (
<div className="mb-4 flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700 shadow-sm dark:border-slate-800 dark:bg-slate-900/60 dark:text-slate-200">
<Loader2 className="h-4 w-4 animate-spin text-slate-600 dark:text-slate-300" />
<div className="min-w-0">
<p className="font-medium">
Reading and validating your Excel file
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
Please wait while we parse the sheets and check for errors.
</p>
</div>
</div>
)}
{uploadError && (
<Alert className="mb-4 border-red-200 bg-red-50/80 text-red-950 shadow-sm dark:border-red-900/60 dark:bg-red-950/30 dark:text-red-100">
<AlertCircle className="h-4 w-4" />
<div className="w-full space-y-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<AlertTitle className="text-sm font-semibold">
{uploadError.title}
</AlertTitle>
<AlertDescription className="text-sm text-red-800 dark:text-red-200">
{uploadError.message}
</AlertDescription>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 self-start px-2 text-red-700 hover:bg-red-100 hover:text-red-900 dark:text-red-200 dark:hover:bg-red-900/40 dark:hover:text-red-50"
onClick={() => setUploadError(null)}
>
<X className="mr-1 h-4 w-4" />
{t("common.close")}
</Button>
</div>
{uploadError.errors?.length ? (
<div className="rounded-lg border border-red-200/80 bg-white/80 p-3 dark:border-red-900/60 dark:bg-red-950/20">
<div className="mb-2 flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-red-900 dark:text-red-100">
Validation details
</span>
<span className="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/50 dark:text-red-100">
{uploadError.errors.length} issue
{uploadError.errors.length > 1 ? "s" : ""}
</span>
</div>
<div className="max-h-56 overflow-y-auto pr-1">
<ul className="grid gap-2 text-sm text-red-800 dark:text-red-200 sm:grid-cols-2">
{uploadError.errors.map((errorMessage) => (
<li
key={errorMessage}
className="rounded-md border border-red-100 bg-red-50 px-3 py-2 leading-relaxed dark:border-red-900/50 dark:bg-red-950/30"
>
{errorMessage}
</li>
))}
</ul>
</div>
</div>
) : null}
</div>
</Alert>
)}
{parsedUsers && (
<div className="mt-4 space-y-4">
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={() => setShowPreview(!showPreview)}
disabled={isUploading}
>
{showPreview
? t("common.Cancel")
: t("contentManagement.preview")}
</Button>
<Button
onClick={handleSubmit}
disabled={isUploading || isParsing}
>
{isUploading ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Uploading users...
</span>
) : (
t("contentManagement.submitUsers")
)}
</Button>
</div>
{isUploading && (
<div className="flex items-center gap-3 rounded-lg border border-primary-200 bg-primary-50 px-4 py-3 text-sm text-primary-900 shadow-sm dark:border-primary-900/60 dark:bg-primary-950/30 dark:text-primary-100">
<Loader2 className="h-4 w-4 animate-spin text-primary-700 dark:text-primary-200" />
<div className="min-w-0">
<p className="font-medium">
Submitting users to the server
</p>
<p className="text-xs text-primary-700/80 dark:text-primary-200/80">
Please wait while we create the uploaded users and
positions.
</p>
</div>
</div>
)}
{submitError && (
<Alert className="border-amber-200 bg-amber-50/80 text-amber-950 shadow-sm dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-100">
<AlertCircle className="h-4 w-4" />
<div className="w-full space-y-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<AlertTitle className="text-sm font-semibold">
{submitError.title}
</AlertTitle>
<AlertDescription className="text-sm text-amber-800 dark:text-amber-200">
{submitError.message}
</AlertDescription>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 self-start px-2 text-amber-700 hover:bg-amber-100 hover:text-amber-900 dark:text-amber-200 dark:hover:bg-amber-900/40 dark:hover:text-amber-50"
onClick={() => setSubmitError(null)}
>
<X className="mr-1 h-4 w-4" />
{t("common.close")}
</Button>
</div>
{submitError.errors?.length ? (
<div className="rounded-lg border border-amber-200/80 bg-white/80 p-3 dark:border-amber-900/60 dark:bg-amber-950/20">
<div className="mb-2 flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-amber-900 dark:text-amber-100">
Submission details
</span>
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/50 dark:text-amber-100">
{submitError.errors.length} issue
{submitError.errors.length > 1 ? "s" : ""}
</span>
</div>
<div className="max-h-56 overflow-y-auto pr-1">
<ul className="grid gap-2 text-sm text-amber-800 dark:text-amber-200 sm:grid-cols-2">
{submitError.errors.map((errorMessage) => (
<li
key={errorMessage}
className="rounded-md border border-amber-100 bg-amber-50 px-3 py-2 leading-relaxed dark:border-amber-900/50 dark:bg-amber-950/30"
>
{errorMessage}
</li>
))}
</ul>
</div>
</div>
) : null}
</div>
</Alert>
)}
</div>
)}
{showPreview && parsedUsers && (
<div className="mt-4 p-4 border rounded-lg bg-gray-50 dark:bg-gray-800 max-h-96 overflow-auto">
<div className="flex justify-between items-center mb-2">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{t("contentManagement.preview")}
</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setShowPreview(false)}
>
</Button>
</div>
<pre className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
{JSON.stringify(parsedUsers, null, 2)}
</pre>
</div>
)}
</>
)}
{activeTab === "update" && (
<>
<div>
{unitOptions.length > 0 && (
<div className="mb-4 w-1/2">
<label className="block text-sm font-medium text-gray-700">
{t("organization.selectUnit")}
</label>
<SingleSelect
value={unitId}
onValueChange={(value) => 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"
/>
</div>
)}
</div>
<p className="text-sm mb-2 text-gray-700 dark:text-gray-300 font-medium">
{t("contentManagement.bulkMsg2")}
</p>
<div className="flex items-center gap-3 mb-3">
<Button
variant="outline"
onClick={() => {
if (!unitId) {
toast.warning(t("organization.selectUnit"));
return;
}
updateInputRef.current?.click();
}}
disabled={isUpdateUploading || isUpdateParsing || !unitId}
>
{isUpdateParsing ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Parsing file...
</span>
) : isUpdateUploading ? (
t("userIncoming.Processing...")
) : (
t("contentManagement.selectFile")
)}
</Button>
{updateFileName && (
<span className="text-sm text-gray-500 dark:text-gray-400 truncate">
{updateFileName}
</span>
)}
</div>
<input
ref={updateInputRef}
type="file"
accept=".xlsx, .xls"
onChange={handleUpdateFileChange}
className="hidden"
/>
{isUpdateParsing && (
<div className="mb-4 flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700 shadow-sm dark:border-slate-800 dark:bg-slate-900/60 dark:text-slate-200">
<Loader2 className="h-4 w-4 animate-spin text-slate-600 dark:text-slate-300" />
<div className="min-w-0">
<p className="font-medium">
Reading and validating your Excel file
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
Please wait while we parse the Positions sheet and check for errors.
</p>
</div>
</div>
)}
{updateUploadError && (
<Alert className="mb-4 border-red-200 bg-red-50/80 text-red-950 shadow-sm dark:border-red-900/60 dark:bg-red-950/30 dark:text-red-100">
<AlertCircle className="h-4 w-4" />
<div className="w-full space-y-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<AlertTitle className="text-sm font-semibold">
{updateUploadError.title}
</AlertTitle>
<AlertDescription className="text-sm text-red-800 dark:text-red-200">
{updateUploadError.message}
</AlertDescription>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 self-start px-2 text-red-700 hover:bg-red-100 hover:text-red-900 dark:text-red-200 dark:hover:bg-red-900/40 dark:hover:text-red-50"
onClick={() => setUpdateUploadError(null)}
>
<X className="mr-1 h-4 w-4" />
{t("common.close")}
</Button>
</div>
{updateUploadError.errors?.length ? (
<div className="rounded-lg border border-red-200/80 bg-white/80 p-3 dark:border-red-900/60 dark:bg-red-950/20">
<div className="mb-2 flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-red-900 dark:text-red-100">
Validation details
</span>
<span className="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/50 dark:text-red-100">
{updateUploadError.errors.length} issue
{updateUploadError.errors.length > 1 ? "s" : ""}
</span>
</div>
<div className="max-h-56 overflow-y-auto pr-1">
<ul className="grid gap-2 text-sm text-red-800 dark:text-red-200 sm:grid-cols-2">
{updateUploadError.errors.map((errorMessage) => (
<li
key={errorMessage}
className="rounded-md border border-red-100 bg-red-50 px-3 py-2 leading-relaxed dark:border-red-900/50 dark:bg-red-950/30"
>
{errorMessage}
</li>
))}
</ul>
</div>
</div>
) : null}
</div>
</Alert>
)}
{parsedUpdatePositions && (
<div className="mt-4 space-y-4">
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={() => setShowUpdatePreview(!showUpdatePreview)}
disabled={isUpdateUploading}
>
{showUpdatePreview
? t("common.Cancel")
: t("contentManagement.preview")}
</Button>
<Button
onClick={handleUpdateSubmit}
disabled={isUpdateUploading || isUpdateParsing}
>
{isUpdateUploading ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Updating positions...
</span>
) : (
t("contentManagement.submitUsers")
)}
</Button>
</div>
{isUpdateUploading && (
<div className="flex items-center gap-3 rounded-lg border border-primary-200 bg-primary-50 px-4 py-3 text-sm text-primary-900 shadow-sm dark:border-primary-900/60 dark:bg-primary-950/30 dark:text-primary-100">
<Loader2 className="h-4 w-4 animate-spin text-primary-700 dark:text-primary-200" />
<div className="min-w-0">
<p className="font-medium">
Submitting positions to the server
</p>
<p className="text-xs text-primary-700/80 dark:text-primary-200/80">
Please wait while we update the positions.
</p>
</div>
</div>
)}
{updateSubmitError && (
<Alert className="border-amber-200 bg-amber-50/80 text-amber-950 shadow-sm dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-100">
<AlertCircle className="h-4 w-4" />
<div className="w-full space-y-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<AlertTitle className="text-sm font-semibold">
{updateSubmitError.title}
</AlertTitle>
<AlertDescription className="text-sm text-amber-800 dark:text-amber-200">
{updateSubmitError.message}
</AlertDescription>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 self-start px-2 text-amber-700 hover:bg-amber-100 hover:text-amber-900 dark:text-amber-200 dark:hover:bg-amber-900/40 dark:hover:text-amber-50"
onClick={() => setUpdateSubmitError(null)}
>
<X className="mr-1 h-4 w-4" />
{t("common.close")}
</Button>
</div>
{updateSubmitError.errors?.length ? (
<div className="rounded-lg border border-amber-200/80 bg-white/80 p-3 dark:border-amber-900/60 dark:bg-amber-950/20">
<div className="mb-2 flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-amber-900 dark:text-amber-100">
Submission details
</span>
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/50 dark:text-amber-100">
{updateSubmitError.errors.length} issue
{updateSubmitError.errors.length > 1 ? "s" : ""}
</span>
</div>
<div className="max-h-56 overflow-y-auto pr-1">
<ul className="grid gap-2 text-sm text-amber-800 dark:text-amber-200 sm:grid-cols-2">
{updateSubmitError.errors.map((errorMessage) => (
<li
key={errorMessage}
className="rounded-md border border-amber-100 bg-amber-50 px-3 py-2 leading-relaxed dark:border-amber-900/50 dark:bg-amber-950/30"
>
{errorMessage}
</li>
))}
</ul>
</div>
</div>
) : null}
</div>
</Alert>
)}
</div>
)}
{showUpdatePreview && parsedUpdatePositions && (
<div className="mt-4 p-4 border rounded-lg bg-gray-50 dark:bg-gray-800 max-h-96 overflow-auto">
<div className="flex justify-between items-center mb-2">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{t("contentManagement.preview")}
</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setShowUpdatePreview(false)}
>
</Button>
</div>
<pre className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
{JSON.stringify(parsedUpdatePositions, null, 2)}
</pre>
</div>
)}
</>
)}
</div>
);
};