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}건 + +
+ + +
+ ))} +
+ ); + })} +
+ + + + + + +
+
+ ); };