mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-04 10:13:44 +00:00
1227 lines
49 KiB
TypeScript
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>
|
|
);
|
|
};
|