From 316ce3066324bfa5ddb408b2f4d9843c3d169f63 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 9 Mar 2026 22:20:54 +0900 Subject: [PATCH] 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. --- .../src/services/tableManagementService.ts | 14 +- .../components/common/ExcelUploadModal.tsx | 320 +++++++++++++++++- .../common/MultiTableExcelUploadModal.tsx | 299 +++++++++++++++- 3 files changed, 622 insertions(+), 11 deletions(-) diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index d07c02d2..6d994f93 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -3463,10 +3463,12 @@ export class TableManagementService { } // ORDER BY 절 구성 - // sortBy가 없으면 created_date 컬럼이 있는 경우에만 기본 정렬 적용 + // sortBy가 메인 테이블 컬럼이면 main. 접두사, 조인 별칭이면 접두사 없이 사용 const hasCreatedDateColumn = selectColumns.includes("created_date"); const orderBy = options.sortBy - ? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}` + ? selectColumns.includes(options.sortBy) + ? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}` + : `"${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}` : hasCreatedDateColumn ? `main."created_date" DESC` : ""; @@ -3710,7 +3712,9 @@ export class TableManagementService { selectColumns, "", // WHERE 절은 나중에 추가 options.sortBy - ? `main."${options.sortBy}" ${options.sortOrder || "ASC"}` + ? selectColumns.includes(options.sortBy) + ? `main."${options.sortBy}" ${options.sortOrder || "ASC"}` + : `"${options.sortBy}" ${options.sortOrder || "ASC"}` : hasCreatedDateForSearch ? `main."created_date" DESC` : undefined, @@ -3901,7 +3905,9 @@ export class TableManagementService { const whereClause = whereConditions.join(" AND "); const hasCreatedDateForOrder = selectColumns.includes("created_date"); const orderBy = options.sortBy - ? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}` + ? selectColumns.includes(options.sortBy) + ? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}` + : `"${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}` : hasCreatedDateForOrder ? `main."created_date" DESC` : ""; diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index f3c2ff2d..f5bfc5f9 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -27,6 +27,7 @@ import { ArrowRight, Zap, Copy, + Loader2, } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox"; import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport"; @@ -35,6 +36,8 @@ import { getTableSchema, TableColumn } from "@/lib/api/tableSchema"; import { cn } from "@/lib/utils"; import { findMappingByColumns, saveMappingTemplate } from "@/lib/api/excelMapping"; import { EditableSpreadsheet } from "./EditableSpreadsheet"; +import { getCategoryValues } from "@/lib/api/tableCategoryValue"; +import { getTableColumns } from "@/lib/api/tableManagement"; // 마스터-디테일 엑셀 업로드 설정 (버튼 설정에서 설정) export interface MasterDetailExcelConfig { @@ -133,6 +136,19 @@ export const ExcelUploadModal: React.FC = ({ // 중복 처리 방법 (전역 설정) const [duplicateAction, setDuplicateAction] = useState<"overwrite" | "skip">("skip"); + // 카테고리 검증 관련 + const [showCategoryValidation, setShowCategoryValidation] = useState(false); + const [isCategoryValidating, setIsCategoryValidating] = useState(false); + // { [columnName]: { invalidValue: string, replacement: string | null, validOptions: {code: string, label: string}[], rowIndices: number[] }[] } + const [categoryMismatches, setCategoryMismatches] = useState< + Record; + rowIndices: number[]; + }>> + >({}); + // 3단계: 확인 const [isUploading, setIsUploading] = useState(false); @@ -601,8 +617,177 @@ export const ExcelUploadModal: React.FC = ({ // 중복 체크 설정된 컬럼 수 const duplicateCheckCount = columnMappings.filter((m) => m.checkDuplicate && m.systemColumn).length; + // 카테고리 컬럼 검증: 엑셀 데이터에서 유효하지 않은 카테고리 값 감지 + const validateCategoryColumns = async () => { + try { + setIsCategoryValidating(true); + + const targetTableName = isMasterDetail && masterDetailRelation + ? masterDetailRelation.detailTable + : tableName; + + // 테이블의 카테고리 타입 컬럼 조회 + const colResponse = await getTableColumns(targetTableName); + if (!colResponse.success || !colResponse.data?.columns) { + return null; + } + + const categoryColumns = colResponse.data.columns.filter( + (col: any) => col.inputType === "category" + ); + + if (categoryColumns.length === 0) { + return null; + } + + // 매핑된 컬럼 중 카테고리 타입인 것 찾기 + const mappedCategoryColumns: Array<{ + systemCol: string; + excelCol: string; + displayName: string; + }> = []; + + for (const mapping of columnMappings) { + if (!mapping.systemColumn) continue; + const rawName = mapping.systemColumn.includes(".") + ? mapping.systemColumn.split(".")[1] + : mapping.systemColumn; + + const catCol = categoryColumns.find( + (cc: any) => (cc.columnName || cc.column_name) === rawName + ); + if (catCol) { + mappedCategoryColumns.push({ + systemCol: rawName, + excelCol: mapping.excelColumn, + displayName: catCol.displayName || catCol.display_name || rawName, + }); + } + } + + if (mappedCategoryColumns.length === 0) { + return null; + } + + // 각 카테고리 컬럼의 유효값 조회 및 엑셀 데이터 검증 + const mismatches: typeof categoryMismatches = {}; + + for (const catCol of mappedCategoryColumns) { + const valuesResponse = await getCategoryValues(targetTableName, catCol.systemCol); + if (!valuesResponse.success || !valuesResponse.data) continue; + + const validValues = valuesResponse.data as Array<{ + valueCode: string; + valueLabel: string; + }>; + + // 유효한 코드와 라벨 Set 생성 + 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(); + + allData.forEach((row, rowIdx) => { + const val = row[catCol.excelCol]; + 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, + })); + + mismatches[`${catCol.systemCol}|||${catCol.displayName}`] = 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 [key, items] of Object.entries(categoryMismatches)) { + for (const item of items) { + if (item.replacement === null) { + toast.error("모든 항목의 대체 값을 선택해주세요."); + return false; + } + } + } + + // 엑셀 컬럼명 → 시스템 컬럼명 매핑 구축 + const systemToExcelMap = new Map(); + for (const mapping of columnMappings) { + if (!mapping.systemColumn) continue; + const rawName = mapping.systemColumn.includes(".") + ? mapping.systemColumn.split(".")[1] + : mapping.systemColumn; + systemToExcelMap.set(rawName, mapping.excelColumn); + } + + const newData = allData.map((row) => ({ ...row })); + + for (const [key, items] of Object.entries(categoryMismatches)) { + const systemCol = key.split("|||")[0]; + const excelCol = systemToExcelMap.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 && !file) { toast.error("파일을 선택해주세요."); return; @@ -655,7 +840,7 @@ export const ExcelUploadModal: React.FC = ({ } } - // 2단계 → 3단계 전환 시: NOT NULL 컬럼 매핑 필수 검증 + // 2단계 → 3단계 전환 시: NOT NULL 컬럼 매핑 필수 검증 + 카테고리 검증 if (currentStep === 2) { // 매핑된 시스템 컬럼 (원본 이름 그대로 + dot 뒤 이름 둘 다 저장) const mappedSystemCols = new Set(); @@ -681,6 +866,14 @@ export const ExcelUploadModal: React.FC = ({ toast.error(`필수(NOT NULL) 컬럼이 매핑되지 않았습니다: ${colNames}`); return; } + + // 카테고리 컬럼 검증 + const mismatches = await validateCategoryColumns(); + if (mismatches) { + setCategoryMismatches(mismatches); + setShowCategoryValidation(true); + return; + } } setCurrentStep((prev) => Math.min(prev + 1, 3)); @@ -1108,12 +1301,17 @@ export const ExcelUploadModal: React.FC = ({ setSystemColumns([]); setColumnMappings([]); setDuplicateAction("skip"); + // 카테고리 검증 초기화 + setShowCategoryValidation(false); + setCategoryMismatches({}); + setIsCategoryValidating(false); // 🆕 마스터-디테일 모드 초기화 setMasterFieldValues({}); } }, [open]); return ( + <> = ({ {currentStep < 3 ? ( ) : ( + + {/* 카테고리 대체값 선택 다이얼로그 */} + { + if (!open) { + setShowCategoryValidation(false); + setCategoryMismatches({}); + } + }}> + + + + + 존재하지 않는 카테고리 값 감지 + + + 엑셀 데이터에 등록되지 않은 카테고리 값이 있습니다. 각 항목에 대해 대체할 값을 선택해주세요. + + + +
+ {Object.entries(categoryMismatches).map(([key, items]) => { + const [columnName, displayName] = key.split("|||"); + return ( +
+

+ {displayName || columnName} +

+ {items.map((item, idx) => ( +
+
+ + {item.invalidValue} + + + {item.rowIndices.length}건 + +
+ + +
+ ))} +
+ ); + })} +
+ + + + + + +
+
+ ); }; diff --git a/frontend/components/common/MultiTableExcelUploadModal.tsx b/frontend/components/common/MultiTableExcelUploadModal.tsx index 867bdc14..f6d11ae9 100644 --- a/frontend/components/common/MultiTableExcelUploadModal.tsx +++ b/frontend/components/common/MultiTableExcelUploadModal.tsx @@ -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; + rowIndices: number[]; + }>> + >({}); + const selectedMode = config.uploadModes.find((m) => m.id === selectedModeId); // 선택된 모드에서 활성화되는 컬럼 목록 @@ -302,8 +316,161 @@ export const MultiTableExcelUploadModal: React.FC { + 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(); + + 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(); + 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 Math.min(prev + 1, 3)); @@ -349,10 +524,14 @@ export const MultiTableExcelUploadModal: React.FC - 다음 + {isCategoryValidating ? ( + <> + + 검증 중... + + ) : ( + "다음" + )} ) : ( + + {/* 카테고리 대체값 선택 다이얼로그 */} + { + if (!open) { + setShowCategoryValidation(false); + setCategoryMismatches({}); + } + }}> + + + + + 존재하지 않는 카테고리 값 감지 + + + 엑셀 데이터에 등록되지 않은 카테고리 값이 있습니다. 각 항목에 대해 대체할 값을 선택해주세요. + + + +
+ {Object.entries(categoryMismatches).map(([key, items]) => { + const [, displayName] = key.split("|||"); + return ( +
+

+ {displayName} +

+ {items.map((item, idx) => ( +
+
+ + {item.invalidValue} + + + {item.rowIndices.length}건 + +
+ + +
+ ))} +
+ ); + })} +
+ + + + + + +
+
+ ); }; -- 2.43.0