"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 ExcelUploadModalProps { open: boolean; onOpenChange: (open: boolean) => void; tableName: string; uploadMode?: "insert" | "update" | "upsert"; keyColumn?: string; onSuccess?: () => void; userId?: string; } interface ColumnMapping { excelColumn: string; systemColumn: string | null; } export const ExcelUploadModal: React.FC = ({ open, onOpenChange, tableName, uploadMode = "insert", keyColumn, onSuccess, userId = "guest", }) => { 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 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 }); const response = await getTableSchema(tableName); console.log("📊 테이블 스키마 응답:", response); if (response.success && response.data) { // 자동 생성 컬럼 제외 const filteredColumns = response.data.columns.filter( (col) => !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase()) ); console.log("✅ 시스템 컬럼 로드 완료 (자동 생성 컬럼 제외):", filteredColumns); setSystemColumns(filteredColumns); // 기존 매핑 템플릿 조회 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); } } else { console.error("❌ 테이블 스키마 로드 실패:", response); } } catch (error) { console.error("❌ 테이블 스키마 로드 실패:", error); toast.error("테이블 스키마를 불러올 수 없습니다."); } }; // 자동 매핑 - 컬럼명과 라벨 모두 비교 const handleAutoMapping = () => { const newMappings = excelColumns.map((excelCol) => { const normalizedExcelCol = excelCol.toLowerCase().trim(); // 1. 먼저 라벨로 매칭 시도 let matchedSystemCol = systemColumns.find( (sysCol) => sysCol.label && sysCol.label.toLowerCase().trim() === normalizedExcelCol ); // 2. 라벨로 매칭되지 않으면 컬럼명으로 매칭 시도 if (!matchedSystemCol) { matchedSystemCol = systemColumns.find( (sysCol) => sysCol.name.toLowerCase().trim() === normalizedExcelCol ); } 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; } // 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) { mappedRow[mapping.systemColumn] = 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}행` ); let successCount = 0; let failCount = 0; for (const row of filteredData) { try { if (uploadMode === "insert") { const formData = { screenId: 0, tableName, data: row }; const result = await DynamicFormApi.saveFormData(formData); if (result.success) { successCount++; } else { failCount++; } } } catch (error) { failCount++; } } if (successCount > 0) { toast.success( `${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}` ); // 매핑 템플릿 저장 (UPSERT - 자동 저장) 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); } onSuccess?.(); } else { toast.error("업로드에 실패했습니다."); } } catch (error) { console.error("❌ 엑셀 업로드 실패:", error); toast.error("엑셀 업로드 중 오류가 발생했습니다."); } finally { setIsUploading(false); } }; // 모달 닫기 시 초기화 useEffect(() => { if (!open) { setCurrentStep(1); setFile(null); setSheetNames([]); setSelectedSheet(""); setIsAutoMappingLoaded(false); setDetectedRange(""); setAllData([]); setDisplayData([]); setExcelColumns([]); setSystemColumns([]); setColumnMappings([]); } }, [open]); return ( 엑셀 데이터 업로드 엑셀 파일을 선택하고 컬럼을 매핑하여 데이터를 업로드하세요. {/* 스텝 인디케이터 (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 && (
{/* 파일 선택 영역 */}
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 ? ( ) : ( )}
); };