"use client"; import React, { useState, useRef, useEffect } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { toast } from "sonner"; import { Upload, FileSpreadsheet, AlertCircle, CheckCircle2, ArrowRight, Zap, } from "lucide-react"; import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport"; import { DynamicFormApi } from "@/lib/api/dynamicForm"; import { getTableSchema, TableColumn } from "@/lib/api/tableSchema"; import { cn } from "@/lib/utils"; import { findMappingByColumns, saveMappingTemplate } from "@/lib/api/excelMapping"; import { EditableSpreadsheet } from "./EditableSpreadsheet"; // 마스터-디테일 엑셀 업로드 설정 (버튼 설정에서 설정) export interface MasterDetailExcelConfig { // 테이블 정보 masterTable?: string; detailTable?: string; masterKeyColumn?: string; detailFkColumn?: string; // 채번 numberingRuleId?: string; // 업로드 전 사용자가 선택할 마스터 테이블 필드 masterSelectFields?: Array<{ columnName: string; columnLabel: string; required: boolean; inputType: "entity" | "date" | "text" | "select"; referenceTable?: string; referenceColumn?: string; displayColumn?: string; }>; // 엑셀에서 매핑할 디테일 테이블 필드 detailExcelFields?: Array<{ columnName: string; columnLabel: string; required: boolean; }>; masterDefaults?: Record; detailDefaults?: Record; } export interface ExcelUploadModalProps { open: boolean; onOpenChange: (open: boolean) => void; tableName: string; uploadMode?: "insert" | "update" | "upsert"; keyColumn?: string; onSuccess?: () => void; userId?: string; // 마스터-디테일 지원 screenId?: number; isMasterDetail?: boolean; masterDetailRelation?: { masterTable: string; detailTable: string; masterKeyColumn: string; detailFkColumn: string; masterColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>; detailColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>; }; // 🆕 마스터-디테일 엑셀 업로드 설정 masterDetailExcelConfig?: MasterDetailExcelConfig; // 🆕 단일 테이블 채번 설정 numberingRuleId?: string; numberingTargetColumn?: string; // 🆕 업로드 후 제어 실행 설정 afterUploadFlows?: Array<{ flowId: string; order: number }>; } interface ColumnMapping { excelColumn: string; systemColumn: string | null; } export const ExcelUploadModal: React.FC = ({ open, onOpenChange, tableName, uploadMode = "insert", keyColumn, onSuccess, userId = "guest", screenId, isMasterDetail = false, masterDetailRelation, masterDetailExcelConfig, // 단일 테이블 채번 설정 numberingRuleId, numberingTargetColumn, // 업로드 후 제어 실행 설정 afterUploadFlows, }) => { const [currentStep, setCurrentStep] = useState(1); // 1단계: 파일 선택 & 미리보기 const [file, setFile] = useState(null); const [sheetNames, setSheetNames] = useState([]); const [selectedSheet, setSelectedSheet] = useState(""); const [isDragOver, setIsDragOver] = useState(false); const fileInputRef = useRef(null); const [detectedRange, setDetectedRange] = useState(""); const [allData, setAllData] = useState[]>([]); const [displayData, setDisplayData] = useState[]>([]); // 2단계: 컬럼 매핑 + 매핑 템플릿 자동 적용 const [isAutoMappingLoaded, setIsAutoMappingLoaded] = useState(false); const [excelColumns, setExcelColumns] = useState([]); const [systemColumns, setSystemColumns] = useState([]); const [columnMappings, setColumnMappings] = useState([]); // 3단계: 확인 const [isUploading, setIsUploading] = useState(false); // 🆕 마스터-디테일 모드: 마스터 필드 입력값 const [masterFieldValues, setMasterFieldValues] = useState>({}); const [entitySearchData, setEntitySearchData] = useState>({}); const [entitySearchLoading, setEntitySearchLoading] = useState>({}); const [entityDisplayColumns, setEntityDisplayColumns] = useState>({}); // 🆕 엔티티 참조 데이터 로드 useEffect(() => { console.log("🔍 엔티티 데이터 로드 체크:", { masterSelectFields: masterDetailExcelConfig?.masterSelectFields, open, isMasterDetail, }); if (!masterDetailExcelConfig?.masterSelectFields) return; const loadEntityData = async () => { const { apiClient } = await import("@/lib/api/client"); const { DynamicFormApi } = await import("@/lib/api/dynamicForm"); for (const field of masterDetailExcelConfig.masterSelectFields!) { console.log("🔍 필드 처리:", field); if (field.inputType === "entity") { setEntitySearchLoading((prev) => ({ ...prev, [field.columnName]: true })); try { let refTable = field.referenceTable; console.log("🔍 초기 refTable:", refTable); let displayCol = field.displayColumn; // referenceTable 또는 displayColumn이 없으면 DB에서 동적으로 조회 if ((!refTable || !displayCol) && masterDetailExcelConfig.masterTable) { console.log("🔍 DB에서 referenceTable/displayColumn 조회 시도:", masterDetailExcelConfig.masterTable); const colResponse = await apiClient.get( `/table-management/tables/${masterDetailExcelConfig.masterTable}/columns` ); console.log("🔍 컬럼 조회 응답:", colResponse.data); if (colResponse.data?.success && colResponse.data?.data?.columns) { const colInfo = colResponse.data.data.columns.find( (c: any) => (c.columnName || c.column_name) === field.columnName ); console.log("🔍 찾은 컬럼 정보:", colInfo); if (colInfo) { if (!refTable) { refTable = colInfo.referenceTable || colInfo.reference_table; console.log("🔍 DB에서 가져온 refTable:", refTable); } if (!displayCol) { displayCol = colInfo.displayColumn || colInfo.display_column; console.log("🔍 DB에서 가져온 displayColumn:", displayCol); } } } } // displayColumn 저장 (Select 렌더링 시 사용) if (displayCol) { setEntityDisplayColumns((prev) => ({ ...prev, [field.columnName]: displayCol })); } if (refTable) { console.log("🔍 엔티티 데이터 조회:", refTable); const response = await DynamicFormApi.getTableData(refTable, { page: 1, pageSize: 1000, }); console.log("🔍 엔티티 데이터 응답:", response); // getTableData는 { success, data: [...] } 형식으로 반환 const rows = response.data?.rows || response.data; if (response.success && rows && Array.isArray(rows)) { setEntitySearchData((prev) => ({ ...prev, [field.columnName]: rows, })); console.log("✅ 엔티티 데이터 로드 성공:", field.columnName, rows.length, "개"); } } else { console.warn("❌ 엔티티 필드의 referenceTable을 찾을 수 없음:", field.columnName); } } catch (error) { console.error("❌ 엔티티 데이터 로드 실패:", field.columnName, error); } finally { setEntitySearchLoading((prev) => ({ ...prev, [field.columnName]: false })); } } } }; if (open && isMasterDetail && masterDetailExcelConfig?.masterSelectFields?.length > 0) { loadEntityData(); } }, [open, isMasterDetail, masterDetailExcelConfig]); // 마스터-디테일 모드에서 마스터 필드 입력 여부 확인 const isSimpleMasterDetailMode = isMasterDetail && masterDetailExcelConfig; const hasMasterSelectFields = isSimpleMasterDetailMode && (masterDetailExcelConfig?.masterSelectFields?.length ?? 0) > 0; // 마스터 필드가 모두 입력되었는지 확인 const isMasterFieldsValid = () => { if (!hasMasterSelectFields) return true; return masterDetailExcelConfig!.masterSelectFields!.every((field) => { if (!field.required) return true; const value = masterFieldValues[field.columnName]; return value !== undefined && value !== null && value !== ""; }); }; // 파일 선택 핸들러 const handleFileChange = async (e: React.ChangeEvent) => { const selectedFile = e.target.files?.[0]; if (!selectedFile) return; await processFile(selectedFile); }; // 파일 처리 공통 함수 (파일 선택 및 드래그 앤 드롭에서 공유) const processFile = async (selectedFile: File) => { const fileExtension = selectedFile.name.split(".").pop()?.toLowerCase(); if (!["xlsx", "xls", "csv"].includes(fileExtension || "")) { toast.error("엑셀 파일만 업로드 가능합니다. (.xlsx, .xls, .csv)"); return; } setFile(selectedFile); try { const sheets = await getExcelSheetNames(selectedFile); setSheetNames(sheets); setSelectedSheet(sheets[0] || ""); const data = await importFromExcel(selectedFile, sheets[0]); setAllData(data); setDisplayData(data); if (data.length > 0) { const columns = Object.keys(data[0]); const lastCol = String.fromCharCode(64 + columns.length); setDetectedRange(`A1:${lastCol}${data.length + 1}`); setExcelColumns(columns); } toast.success(`파일이 선택되었습니다: ${selectedFile.name}`); } catch (error) { console.error("파일 읽기 오류:", error); toast.error("파일을 읽는 중 오류가 발생했습니다."); setFile(null); } }; // 드래그 앤 드롭 핸들러 const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragOver(true); }; const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragOver(false); }; const handleDrop = async (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragOver(false); const droppedFile = e.dataTransfer.files?.[0]; if (droppedFile) { await processFile(droppedFile); } }; // 시트 변경 핸들러 const handleSheetChange = async (sheetName: string) => { setSelectedSheet(sheetName); if (!file) return; try { const data = await importFromExcel(file, sheetName); setAllData(data); setDisplayData(data); if (data.length > 0) { const columns = Object.keys(data[0]); const lastCol = String.fromCharCode(64 + columns.length); setDetectedRange(`A1:${lastCol}${data.length + 1}`); setExcelColumns(columns); } } catch (error) { console.error("시트 읽기 오류:", error); toast.error("시트를 읽는 중 오류가 발생했습니다."); } }; // 테이블 스키마 가져오기 (2단계 진입 시) useEffect(() => { if (currentStep === 2 && tableName) { loadTableSchema(); } }, [currentStep, tableName]); // 테이블 생성 시 자동 생성되는 시스템 컬럼 (매핑에서 제외) const AUTO_GENERATED_COLUMNS = [ "id", "created_date", "updated_date", "writer", "company_code", ]; const loadTableSchema = async () => { try { console.log("🔍 테이블 스키마 로드 시작:", { tableName, isMasterDetail, isSimpleMasterDetailMode }); let allColumns: TableColumn[] = []; // 🆕 마스터-디테일 간단 모드: 디테일 테이블 컬럼만 로드 (마스터 필드는 UI에서 선택) if (isSimpleMasterDetailMode && masterDetailRelation) { const { detailTable, detailFkColumn } = masterDetailRelation; console.log("📊 마스터-디테일 간단 모드 스키마 로드 (디테일만):", { detailTable }); // 디테일 테이블 스키마만 로드 (마스터 정보는 UI에서 선택) const detailResponse = await getTableSchema(detailTable); if (detailResponse.success && detailResponse.data) { // 설정된 detailExcelFields가 있으면 해당 필드만, 없으면 전체 const configuredFields = masterDetailExcelConfig?.detailExcelFields; const detailCols = detailResponse.data.columns .filter((col) => { // 자동 생성 컬럼, FK 컬럼 제외 if (AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase())) return false; if (col.name === detailFkColumn) return false; // 설정된 필드가 있으면 해당 필드만 if (configuredFields && configuredFields.length > 0) { return configuredFields.some((f) => f.columnName === col.name); } return true; }) .map((col) => { // 설정에서 라벨 찾기 const configField = configuredFields?.find((f) => f.columnName === col.name); return { ...col, label: configField?.columnLabel || col.label || col.name, originalName: col.name, sourceTable: detailTable, }; }); allColumns = detailCols; } console.log("✅ 마스터-디테일 간단 모드 컬럼 로드 완료:", allColumns.length); } // 🆕 마스터-디테일 기존 모드: 두 테이블의 컬럼 합치기 else if (isMasterDetail && masterDetailRelation) { const { masterTable, detailTable, detailFkColumn } = masterDetailRelation; console.log("📊 마스터-디테일 스키마 로드:", { masterTable, detailTable }); // 마스터 테이블 스키마 const masterResponse = await getTableSchema(masterTable); if (masterResponse.success && masterResponse.data) { const masterCols = masterResponse.data.columns .filter((col) => !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase())) .map((col) => ({ ...col, // 유니크 키를 위해 테이블명 접두사 추가 name: `${masterTable}.${col.name}`, label: `[마스터] ${col.label || col.name}`, originalName: col.name, sourceTable: masterTable, })); allColumns = [...allColumns, ...masterCols]; } // 디테일 테이블 스키마 (FK 컬럼 제외) const detailResponse = await getTableSchema(detailTable); if (detailResponse.success && detailResponse.data) { const detailCols = detailResponse.data.columns .filter((col) => !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase()) && col.name !== detailFkColumn // FK 컬럼 제외 ) .map((col) => ({ ...col, // 유니크 키를 위해 테이블명 접두사 추가 name: `${detailTable}.${col.name}`, label: `[디테일] ${col.label || col.name}`, originalName: col.name, sourceTable: detailTable, })); allColumns = [...allColumns, ...detailCols]; } console.log("✅ 마스터-디테일 컬럼 로드 완료:", allColumns.length); } else { // 기존 단일 테이블 모드 const response = await getTableSchema(tableName); console.log("📊 테이블 스키마 응답:", response); if (response.success && response.data) { // 자동 생성 컬럼 제외 allColumns = response.data.columns.filter( (col) => !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase()) ); } else { console.error("❌ 테이블 스키마 로드 실패:", response); return; } } console.log("✅ 시스템 컬럼 로드 완료 (자동 생성 컬럼 제외):", allColumns); setSystemColumns(allColumns); // 기존 매핑 템플릿 조회 console.log("🔍 매핑 템플릿 조회 중...", { tableName, excelColumns }); const mappingResponse = await findMappingByColumns(tableName, excelColumns); if (mappingResponse.success && mappingResponse.data) { // 저장된 매핑 템플릿이 있으면 자동 적용 console.log("✅ 기존 매핑 템플릿 발견:", mappingResponse.data); const savedMappings = mappingResponse.data.columnMappings; const appliedMappings: ColumnMapping[] = excelColumns.map((col) => ({ excelColumn: col, systemColumn: savedMappings[col] || null, })); setColumnMappings(appliedMappings); setIsAutoMappingLoaded(true); const matchedCount = appliedMappings.filter((m) => m.systemColumn).length; toast.success(`이전 매핑 템플릿이 적용되었습니다. (${matchedCount}개 컬럼)`); } else { // 매핑 템플릿이 없으면 초기 상태로 설정 console.log("ℹ️ 매핑 템플릿 없음 - 새 엑셀 구조"); const initialMappings: ColumnMapping[] = excelColumns.map((col) => ({ excelColumn: col, systemColumn: null, })); setColumnMappings(initialMappings); setIsAutoMappingLoaded(false); } } catch (error) { console.error("❌ 테이블 스키마 로드 실패:", error); toast.error("테이블 스키마를 불러올 수 없습니다."); } }; // 자동 매핑 - 컬럼명과 라벨 모두 비교 const handleAutoMapping = () => { const newMappings = excelColumns.map((excelCol) => { const normalizedExcelCol = excelCol.toLowerCase().trim(); // [마스터], [디테일] 접두사 제거 후 비교 const cleanExcelCol = normalizedExcelCol.replace(/^\[(마스터|디테일)\]\s*/i, ""); // 1. 먼저 라벨로 매칭 시도 (접두사 제거 후) let matchedSystemCol = systemColumns.find((sysCol) => { if (!sysCol.label) return false; // [마스터], [디테일] 접두사 제거 후 비교 const cleanLabel = sysCol.label.toLowerCase().trim().replace(/^\[(마스터|디테일)\]\s*/i, ""); return cleanLabel === normalizedExcelCol || cleanLabel === cleanExcelCol; }); // 2. 라벨로 매칭되지 않으면 컬럼명으로 매칭 시도 if (!matchedSystemCol) { matchedSystemCol = systemColumns.find((sysCol) => { // 마스터-디테일 모드: originalName이 있으면 사용 const originalName = (sysCol as any).originalName; const colName = originalName || sysCol.name; return colName.toLowerCase().trim() === normalizedExcelCol || colName.toLowerCase().trim() === cleanExcelCol; }); } // 3. 여전히 매칭 안되면 전체 이름(테이블.컬럼)에서 컬럼 부분만 추출해서 비교 if (!matchedSystemCol) { matchedSystemCol = systemColumns.find((sysCol) => { // 테이블.컬럼 형식에서 컬럼만 추출 const nameParts = sysCol.name.split("."); const colNameOnly = nameParts.length > 1 ? nameParts[1] : nameParts[0]; return colNameOnly.toLowerCase().trim() === normalizedExcelCol || colNameOnly.toLowerCase().trim() === cleanExcelCol; }); } return { excelColumn: excelCol, systemColumn: matchedSystemCol ? matchedSystemCol.name : null, }; }); setColumnMappings(newMappings); const matchedCount = newMappings.filter((m) => m.systemColumn).length; toast.success(`${matchedCount}개 컬럼이 자동 매핑되었습니다.`); }; // 컬럼 매핑 변경 const handleMappingChange = (excelColumn: string, systemColumn: string | null) => { setColumnMappings((prev) => prev.map((mapping) => mapping.excelColumn === excelColumn ? { ...mapping, systemColumn } : mapping ) ); }; // 다음 단계 const handleNext = () => { if (currentStep === 1 && !file) { toast.error("파일을 선택해주세요."); return; } if (currentStep === 1 && displayData.length === 0) { toast.error("데이터가 없습니다."); return; } // 🆕 마스터-디테일 간단 모드: 마스터 필드 유효성 검사 if (currentStep === 1 && hasMasterSelectFields && !isMasterFieldsValid()) { toast.error("마스터 정보를 모두 입력해주세요."); return; } // 1단계 → 2단계 전환 시: 빈 헤더 열 제외 if (currentStep === 1) { // 빈 헤더가 아닌 열만 필터링 const validColumnIndices: number[] = []; const validColumns: string[] = []; excelColumns.forEach((col, index) => { if (col && col.trim() !== "") { validColumnIndices.push(index); validColumns.push(col); } }); // 빈 헤더 열이 있었다면 데이터에서도 해당 열 제거 if (validColumns.length < excelColumns.length) { const removedCount = excelColumns.length - validColumns.length; // 새로운 데이터: 유효한 열만 포함 const cleanedData = displayData.map((row) => { const newRow: Record = {}; validColumns.forEach((colName) => { newRow[colName] = row[colName]; }); return newRow; }); setExcelColumns(validColumns); setDisplayData(cleanedData); setAllData(cleanedData); if (removedCount > 0) { toast.info(`빈 헤더 ${removedCount}개 열이 제외되었습니다.`); } } } setCurrentStep((prev) => Math.min(prev + 1, 3)); }; // 이전 단계 const handlePrevious = () => { setCurrentStep((prev) => Math.max(prev - 1, 1)); }; // 업로드 핸들러 const handleUpload = async () => { if (!file || !tableName) { toast.error("필수 정보가 누락되었습니다."); return; } setIsUploading(true); try { // allData를 사용하여 전체 데이터 업로드 const mappedData = allData.map((row) => { const mappedRow: Record = {}; columnMappings.forEach((mapping) => { if (mapping.systemColumn) { // 마스터-디테일 모드: 테이블.컬럼 형식에서 컬럼명만 추출 let colName = mapping.systemColumn; if (isMasterDetail && colName.includes(".")) { colName = colName.split(".")[1]; } mappedRow[colName] = row[mapping.excelColumn]; } }); return mappedRow; }); // 빈 행 필터링: 모든 값이 비어있거나 undefined/null인 행 제외 const filteredData = mappedData.filter((row) => { const values = Object.values(row); return values.some((value) => { if (value === undefined || value === null) return false; if (typeof value === "string" && value.trim() === "") return false; return true; }); }); console.log( `📊 엑셀 업로드: 전체 ${mappedData.length}행 중 유효한 ${filteredData.length}행` ); // 🆕 마스터-디테일 간단 모드 처리 (마스터 필드 선택 + 채번) if (isSimpleMasterDetailMode && screenId && masterDetailRelation) { console.log("📊 마스터-디테일 간단 모드 업로드:", { masterDetailRelation, masterFieldValues, numberingRuleId: masterDetailExcelConfig?.numberingRuleId, }); const uploadResult = await DynamicFormApi.uploadMasterDetailSimple( screenId, filteredData, masterFieldValues, masterDetailExcelConfig?.numberingRuleId || undefined, masterDetailExcelConfig?.afterUploadFlowId || undefined, // 하위 호환성 masterDetailExcelConfig?.afterUploadFlows || undefined // 다중 제어 ); if (uploadResult.success && uploadResult.data) { const { masterInserted, detailInserted, generatedKey, errors } = uploadResult.data; toast.success( `마스터 ${masterInserted}건(${generatedKey || ""}), 디테일 ${detailInserted}건 처리되었습니다.` + (errors?.length > 0 ? ` (오류: ${errors.length}건)` : "") ); // 매핑 템플릿 저장 await saveMappingTemplateInternal(); onSuccess?.(); } else { toast.error(uploadResult.message || "마스터-디테일 업로드에 실패했습니다."); } } // 🆕 마스터-디테일 기존 모드 처리 else if (isMasterDetail && screenId && masterDetailRelation) { console.log("📊 마스터-디테일 업로드 모드:", masterDetailRelation); const uploadResult = await DynamicFormApi.uploadMasterDetailData( screenId, filteredData ); if (uploadResult.success && uploadResult.data) { const { masterInserted, masterUpdated, detailInserted, errors } = uploadResult.data; toast.success( `마스터 ${masterInserted + masterUpdated}건, 디테일 ${detailInserted}건 처리되었습니다.` + (errors.length > 0 ? ` (오류: ${errors.length}건)` : "") ); // 매핑 템플릿 저장 await saveMappingTemplateInternal(); onSuccess?.(); } else { toast.error(uploadResult.message || "마스터-디테일 업로드에 실패했습니다."); } } else { // 기존 단일 테이블 업로드 로직 let successCount = 0; let failCount = 0; // 단일 테이블 채번 설정 확인 const hasNumbering = numberingRuleId && numberingTargetColumn; for (const row of filteredData) { try { let dataToSave = { ...row }; // 채번 적용: 각 행마다 채번 API 호출 if (hasNumbering && uploadMode === "insert") { try { const { apiClient } = await import("@/lib/api/client"); const numberingResponse = await apiClient.post(`/numbering-rules/${numberingRuleId}/allocate`); const generatedCode = numberingResponse.data?.data?.generatedCode || numberingResponse.data?.data?.code; if (numberingResponse.data?.success && generatedCode) { dataToSave[numberingTargetColumn] = generatedCode; } } catch (numError) { console.error("채번 오류:", numError); } } if (uploadMode === "insert") { const formData = { screenId: 0, tableName, data: dataToSave }; const result = await DynamicFormApi.saveFormData(formData); if (result.success) { successCount++; } else { failCount++; } } } catch (error) { failCount++; } } // 🆕 업로드 후 제어 실행 if (afterUploadFlows && afterUploadFlows.length > 0 && successCount > 0) { console.log("🔄 업로드 후 제어 실행:", afterUploadFlows); try { const { apiClient } = await import("@/lib/api/client"); // 순서대로 실행 const sortedFlows = [...afterUploadFlows].sort((a, b) => a.order - b.order); for (const flow of sortedFlows) { await apiClient.post(`/dataflow/node-flows/${flow.flowId}/execute`, { sourceData: { tableName, uploadedCount: successCount }, }); console.log(`✅ 제어 실행 완료: flowId=${flow.flowId}`); } } catch (controlError) { console.error("제어 실행 오류:", controlError); } } if (successCount > 0) { toast.success( `${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}` ); // 매핑 템플릿 저장 await saveMappingTemplateInternal(); onSuccess?.(); } else { toast.error("업로드에 실패했습니다."); } } } catch (error) { console.error("❌ 엑셀 업로드 실패:", error); toast.error("엑셀 업로드 중 오류가 발생했습니다."); } finally { setIsUploading(false); } }; // 매핑 템플릿 저장 헬퍼 함수 const saveMappingTemplateInternal = async () => { try { const mappingsToSave: Record = {}; columnMappings.forEach((mapping) => { mappingsToSave[mapping.excelColumn] = mapping.systemColumn; }); console.log("💾 매핑 템플릿 저장 중...", { tableName, excelColumns, mappingsToSave, }); const saveResult = await saveMappingTemplate( tableName, excelColumns, mappingsToSave ); if (saveResult.success) { console.log("✅ 매핑 템플릿 저장 완료:", saveResult.data); } else { console.warn("⚠️ 매핑 템플릿 저장 실패:", saveResult.error); } } catch (error) { console.warn("⚠️ 매핑 템플릿 저장 중 오류:", error); } }; // 모달 닫기 시 초기화 useEffect(() => { if (!open) { setCurrentStep(1); setFile(null); setSheetNames([]); setSelectedSheet(""); setIsAutoMappingLoaded(false); setDetectedRange(""); setAllData([]); setDisplayData([]); setExcelColumns([]); setSystemColumns([]); setColumnMappings([]); // 🆕 마스터-디테일 모드 초기화 setMasterFieldValues({}); } }, [open]); return ( 엑셀 데이터 업로드 {isMasterDetail && ( 마스터-디테일 )} {isMasterDetail && masterDetailRelation ? ( <> 마스터({masterDetailRelation.masterTable}) + 디테일({masterDetailRelation.detailTable}) 구조입니다. 마스터 데이터는 중복 입력 시 병합됩니다. ) : ( "엑셀 파일을 선택하고 컬럼을 매핑하여 데이터를 업로드하세요." )} {/* 스텝 인디케이터 (3단계) */}
{[ { num: 1, label: "파일 선택" }, { num: 2, label: "컬럼 매핑" }, { num: 3, label: "확인" }, ].map((step, index) => (
step.num ? "bg-success text-white" : "bg-muted text-muted-foreground" )} > {currentStep > step.num ? ( ) : ( step.num )}
{step.label}
{index < 2 && (
step.num ? "bg-success" : "bg-muted" )} /> )} ))}
{/* 스텝별 컨텐츠 */}
{/* 1단계: 파일 선택 & 미리보기 (통합) */} {currentStep === 1 && (
{/* 🆕 마스터-디테일 간단 모드: 마스터 필드 입력 */} {hasMasterSelectFields && (
{masterDetailExcelConfig?.masterSelectFields?.map((field) => (
{field.inputType === "entity" ? ( ) : field.inputType === "date" ? ( setMasterFieldValues((prev) => ({ ...prev, [field.columnName]: e.target.value, })) } className="h-9 w-full rounded-md border px-3 text-xs" /> ) : ( setMasterFieldValues((prev) => ({ ...prev, [field.columnName]: e.target.value, })) } placeholder={field.columnLabel} className="h-9 w-full rounded-md border px-3 text-xs" /> )}
))}
)} {/* 파일 선택 영역 */}
fileInputRef.current?.click()} className={cn( "mt-2 flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-4 transition-colors", isDragOver ? "border-primary bg-primary/5" : file ? "border-green-500 bg-green-50" : "border-muted-foreground/25 hover:border-primary hover:bg-muted/50" )} > {file ? (

{file.name}

클릭하여 다른 파일 선택

) : ( <>

{isDragOver ? "파일을 놓으세요" : "파일을 드래그하거나 클릭하여 선택"}

지원 형식: .xlsx, .xls, .csv

)}
{/* 파일이 선택된 경우에만 미리보기 표시 */} {file && displayData.length > 0 && ( <> {/* 시트 선택 */}
{displayData.length}개 행 · 셀을 클릭하여 편집, Tab/Enter로 이동
{/* 엑셀처럼 편집 가능한 스프레드시트 */} { setExcelColumns(newColumns); // 범위 재계산 const lastCol = newColumns.length > 0 ? String.fromCharCode(64 + newColumns.length) : "A"; setDetectedRange(`A1:${lastCol}${displayData.length + 1}`); }} onDataChange={(newData) => { setDisplayData(newData); setAllData(newData); // 범위 재계산 const lastCol = excelColumns.length > 0 ? String.fromCharCode(64 + excelColumns.length) : "A"; setDetectedRange(`A1:${lastCol}${newData.length + 1}`); }} maxHeight="320px" /> )}
)} {/* 2단계: 컬럼 매핑 */} {currentStep === 2 && (
{/* 상단: 제목 + 자동 매핑 버튼 */}

컬럼 매핑 설정

{/* 매핑 리스트 */}
엑셀 컬럼
시스템 컬럼
{columnMappings.map((mapping, index) => (
{mapping.excelColumn}
))}
{/* 매핑 자동 저장 안내 */} {isAutoMappingLoaded ? (

이전 매핑이 자동 적용됨

동일한 엑셀 구조가 감지되어 이전에 저장된 매핑이 적용되었습니다. 수정하면 업로드 시 자동 저장됩니다.

) : (

새로운 엑셀 구조

이 엑셀 구조는 처음입니다. 매핑을 설정하면 다음에 같은 구조의 엑셀에 자동 적용됩니다.

)}
)} {/* 3단계: 확인 */} {currentStep === 3 && (

업로드 요약

파일: {file?.name}

시트: {selectedSheet}

데이터 행: {allData.length}개

테이블: {tableName}

모드:{" "} {uploadMode === "insert" ? "신규 등록" : uploadMode === "update" ? "업데이트" : "Upsert"}

컬럼 매핑

{columnMappings .filter((m) => m.systemColumn) .map((mapping, index) => { const col = systemColumns.find( (c) => c.name === mapping.systemColumn ); return (

{mapping.excelColumn} →{" "} {col?.label || mapping.systemColumn}

); })} {columnMappings.filter((m) => m.systemColumn).length === 0 && (

매핑된 컬럼이 없습니다.

)}

주의사항

업로드를 진행하면 데이터가 데이터베이스에 저장됩니다. 계속하시겠습니까?

)}
{currentStep < 3 ? ( ) : ( )}
); };