feat: Enhance Excel upload modals with category validation and error handling
- Added category validation functionality to both ExcelUploadModal and MultiTableExcelUploadModal components, allowing for the detection of invalid category values in uploaded Excel data. - Implemented state management for category validation, including tracking mismatches and user interactions for replacements. - Updated the handleNext function to incorporate category validation checks before proceeding to the next step in the upload process. - Enhanced user feedback with toast notifications for category replacements and validation errors. These changes significantly improve the robustness of the Excel upload process by ensuring data integrity and providing users with clear guidance on category-related issues.
This commit is contained in:
@@ -36,6 +36,8 @@ import {
|
||||
TableChainConfig,
|
||||
uploadMultiTableExcel,
|
||||
} from "@/lib/api/multiTableExcel";
|
||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||
import { getTableColumns } from "@/lib/api/tableManagement";
|
||||
|
||||
export interface MultiTableExcelUploadModalProps {
|
||||
open: boolean;
|
||||
@@ -79,6 +81,18 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
|
||||
// 업로드
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
// 카테고리 검증 관련
|
||||
const [showCategoryValidation, setShowCategoryValidation] = useState(false);
|
||||
const [isCategoryValidating, setIsCategoryValidating] = useState(false);
|
||||
const [categoryMismatches, setCategoryMismatches] = useState<
|
||||
Record<string, Array<{
|
||||
invalidValue: string;
|
||||
replacement: string | null;
|
||||
validOptions: Array<{ code: string; label: string }>;
|
||||
rowIndices: number[];
|
||||
}>>
|
||||
>({});
|
||||
|
||||
const selectedMode = config.uploadModes.find((m) => m.id === selectedModeId);
|
||||
|
||||
// 선택된 모드에서 활성화되는 컬럼 목록
|
||||
@@ -302,8 +316,161 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
|
||||
}
|
||||
};
|
||||
|
||||
// 카테고리 검증: 매핑된 컬럼 중 카테고리 타입인 것의 유효하지 않은 값 감지
|
||||
const validateCategoryColumns = async () => {
|
||||
try {
|
||||
setIsCategoryValidating(true);
|
||||
|
||||
if (!selectedMode) return null;
|
||||
|
||||
const mismatches: typeof categoryMismatches = {};
|
||||
|
||||
// 활성 레벨별로 카테고리 컬럼 검증
|
||||
for (const levelIdx of selectedMode.activeLevels) {
|
||||
const level = config.levels[levelIdx];
|
||||
if (!level) continue;
|
||||
|
||||
// 해당 테이블의 카테고리 타입 컬럼 조회
|
||||
const colResponse = await getTableColumns(level.tableName);
|
||||
if (!colResponse.success || !colResponse.data?.columns) continue;
|
||||
|
||||
const categoryColumns = colResponse.data.columns.filter(
|
||||
(col: any) => col.inputType === "category"
|
||||
);
|
||||
if (categoryColumns.length === 0) continue;
|
||||
|
||||
// 매핑된 컬럼 중 카테고리 타입인 것 찾기
|
||||
for (const catCol of categoryColumns) {
|
||||
const catColName = catCol.columnName || catCol.column_name;
|
||||
const catDisplayName = catCol.displayName || catCol.display_name || catColName;
|
||||
|
||||
// level.columns에서 해당 dbColumn 찾기
|
||||
const levelCol = level.columns.find((lc) => lc.dbColumn === catColName);
|
||||
if (!levelCol) continue;
|
||||
|
||||
// 매핑에서 해당 excelHeader에 연결된 엑셀 컬럼 찾기
|
||||
const mapping = columnMappings.find((m) => m.targetColumn === levelCol.excelHeader);
|
||||
if (!mapping) continue;
|
||||
|
||||
// 유효한 카테고리 값 조회
|
||||
const valuesResponse = await getCategoryValues(level.tableName, catColName);
|
||||
if (!valuesResponse.success || !valuesResponse.data) continue;
|
||||
|
||||
const validValues = valuesResponse.data as Array<{
|
||||
valueCode: string;
|
||||
valueLabel: string;
|
||||
}>;
|
||||
|
||||
const validCodes = new Set(validValues.map((v) => v.valueCode));
|
||||
const validLabels = new Set(validValues.map((v) => v.valueLabel));
|
||||
const validLabelsLower = new Set(validValues.map((v) => v.valueLabel.toLowerCase()));
|
||||
|
||||
// 엑셀 데이터에서 유효하지 않은 값 수집
|
||||
const invalidMap = new Map<string, number[]>();
|
||||
|
||||
allData.forEach((row, rowIdx) => {
|
||||
const val = row[mapping.excelColumn];
|
||||
if (val === undefined || val === null || String(val).trim() === "") return;
|
||||
const strVal = String(val).trim();
|
||||
|
||||
if (validCodes.has(strVal)) return;
|
||||
if (validLabels.has(strVal)) return;
|
||||
if (validLabelsLower.has(strVal.toLowerCase())) return;
|
||||
|
||||
if (!invalidMap.has(strVal)) {
|
||||
invalidMap.set(strVal, []);
|
||||
}
|
||||
invalidMap.get(strVal)!.push(rowIdx);
|
||||
});
|
||||
|
||||
if (invalidMap.size > 0) {
|
||||
const options = validValues.map((v) => ({
|
||||
code: v.valueCode,
|
||||
label: v.valueLabel,
|
||||
}));
|
||||
|
||||
const key = `${catColName}|||[${level.label}] ${catDisplayName}`;
|
||||
mismatches[key] = Array.from(invalidMap.entries()).map(
|
||||
([invalidValue, rowIndices]) => ({
|
||||
invalidValue,
|
||||
replacement: null,
|
||||
validOptions: options,
|
||||
rowIndices,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(mismatches).length > 0) {
|
||||
return mismatches;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("카테고리 검증 실패:", error);
|
||||
return null;
|
||||
} finally {
|
||||
setIsCategoryValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 카테고리 대체값 적용
|
||||
const applyCategoryReplacements = () => {
|
||||
for (const [, items] of Object.entries(categoryMismatches)) {
|
||||
for (const item of items) {
|
||||
if (item.replacement === null) {
|
||||
toast.error("모든 항목의 대체 값을 선택해주세요.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 시스템 컬럼명 → 엑셀 컬럼명 역매핑 구축
|
||||
const dbColToExcelCol = new Map<string, string>();
|
||||
if (selectedMode) {
|
||||
for (const levelIdx of selectedMode.activeLevels) {
|
||||
const level = config.levels[levelIdx];
|
||||
if (!level) continue;
|
||||
for (const lc of level.columns) {
|
||||
const mapping = columnMappings.find((m) => m.targetColumn === lc.excelHeader);
|
||||
if (mapping) {
|
||||
dbColToExcelCol.set(lc.dbColumn, mapping.excelColumn);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newData = allData.map((row) => ({ ...row }));
|
||||
|
||||
for (const [key, items] of Object.entries(categoryMismatches)) {
|
||||
const systemCol = key.split("|||")[0];
|
||||
const excelCol = dbColToExcelCol.get(systemCol);
|
||||
if (!excelCol) continue;
|
||||
|
||||
for (const item of items) {
|
||||
if (!item.replacement) continue;
|
||||
const selectedOption = item.validOptions.find((opt) => opt.code === item.replacement);
|
||||
const replacementLabel = selectedOption?.label || item.replacement;
|
||||
|
||||
for (const rowIdx of item.rowIndices) {
|
||||
if (newData[rowIdx]) {
|
||||
newData[rowIdx][excelCol] = replacementLabel;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setAllData(newData);
|
||||
setDisplayData(newData);
|
||||
setShowCategoryValidation(false);
|
||||
setCategoryMismatches({});
|
||||
toast.success("카테고리 값이 대체되었습니다.");
|
||||
setCurrentStep(3);
|
||||
return true;
|
||||
};
|
||||
|
||||
// 다음/이전 단계
|
||||
const handleNext = () => {
|
||||
const handleNext = async () => {
|
||||
if (currentStep === 1) {
|
||||
if (!file) {
|
||||
toast.error("파일을 선택해주세요.");
|
||||
@@ -328,6 +495,14 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
|
||||
toast.error(`필수 컬럼이 매핑되지 않았습니다: ${unmappedRequired.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 카테고리 컬럼 검증
|
||||
const mismatches = await validateCategoryColumns();
|
||||
if (mismatches) {
|
||||
setCategoryMismatches(mismatches);
|
||||
setShowCategoryValidation(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setCurrentStep((prev) => Math.min(prev + 1, 3));
|
||||
@@ -349,10 +524,14 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
|
||||
setDisplayData([]);
|
||||
setExcelColumns([]);
|
||||
setColumnMappings([]);
|
||||
setShowCategoryValidation(false);
|
||||
setCategoryMismatches({});
|
||||
setIsCategoryValidating(false);
|
||||
}
|
||||
}, [open, config.uploadModes]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-h-[95vh] max-w-[95vw] sm:max-w-[1200px]"
|
||||
@@ -758,10 +937,17 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
|
||||
{currentStep < 3 ? (
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={isUploading || (currentStep === 1 && !file)}
|
||||
disabled={isUploading || isCategoryValidating || (currentStep === 1 && !file)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
다음
|
||||
{isCategoryValidating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
검증 중...
|
||||
</>
|
||||
) : (
|
||||
"다음"
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -782,5 +968,112 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 카테고리 대체값 선택 다이얼로그 */}
|
||||
<Dialog open={showCategoryValidation} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setShowCategoryValidation(false);
|
||||
setCategoryMismatches({});
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||
<AlertCircle className="h-5 w-5 text-warning" />
|
||||
존재하지 않는 카테고리 값 감지
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
엑셀 데이터에 등록되지 않은 카테고리 값이 있습니다. 각 항목에 대해 대체할 값을 선택해주세요.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[400px] space-y-4 overflow-y-auto pr-1">
|
||||
{Object.entries(categoryMismatches).map(([key, items]) => {
|
||||
const [, displayName] = key.split("|||");
|
||||
return (
|
||||
<div key={key} className="space-y-2">
|
||||
<h4 className="text-sm font-semibold text-foreground">
|
||||
{displayName}
|
||||
</h4>
|
||||
{items.map((item, idx) => (
|
||||
<div
|
||||
key={`${key}-${idx}`}
|
||||
className="grid grid-cols-[1fr_auto_1fr] items-center gap-2 rounded-md border border-border bg-muted/30 p-2"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-medium text-destructive line-through">
|
||||
{item.invalidValue}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{item.rowIndices.length}건
|
||||
</span>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
<Select
|
||||
value={item.replacement || ""}
|
||||
onValueChange={(val) => {
|
||||
setCategoryMismatches((prev) => {
|
||||
const updated = { ...prev };
|
||||
updated[key] = updated[key].map((it, i) =>
|
||||
i === idx ? { ...it, replacement: val } : it
|
||||
);
|
||||
return updated;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-9 sm:text-sm">
|
||||
<SelectValue placeholder="대체 값 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{item.validOptions.map((opt) => (
|
||||
<SelectItem
|
||||
key={opt.code}
|
||||
value={opt.code}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowCategoryValidation(false);
|
||||
setCategoryMismatches({});
|
||||
}}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowCategoryValidation(false);
|
||||
setCategoryMismatches({});
|
||||
setCurrentStep(3);
|
||||
}}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
무시하고 진행
|
||||
</Button>
|
||||
<Button
|
||||
onClick={applyCategoryReplacements}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
적용
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user