ERP-node/frontend/components/common/ExcelUploadModal.tsx

1332 lines
52 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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<string, any>;
detailDefaults?: Record<string, any>;
}
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<ExcelUploadModalProps> = ({
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<File | null>(null);
const [sheetNames, setSheetNames] = useState<string[]>([]);
const [selectedSheet, setSelectedSheet] = useState<string>("");
const [isDragOver, setIsDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [detectedRange, setDetectedRange] = useState<string>("");
const [allData, setAllData] = useState<Record<string, any>[]>([]);
const [displayData, setDisplayData] = useState<Record<string, any>[]>([]);
// 2단계: 컬럼 매핑 + 매핑 템플릿 자동 적용
const [isAutoMappingLoaded, setIsAutoMappingLoaded] = useState(false);
const [excelColumns, setExcelColumns] = useState<string[]>([]);
const [systemColumns, setSystemColumns] = useState<TableColumn[]>([]);
const [columnMappings, setColumnMappings] = useState<ColumnMapping[]>([]);
// 3단계: 확인
const [isUploading, setIsUploading] = useState(false);
// 🆕 마스터-디테일 모드: 마스터 필드 입력값
const [masterFieldValues, setMasterFieldValues] = useState<Record<string, any>>({});
const [entitySearchData, setEntitySearchData] = useState<Record<string, any[]>>({});
const [entitySearchLoading, setEntitySearchLoading] = useState<Record<string, boolean>>({});
const [entityDisplayColumns, setEntityDisplayColumns] = useState<Record<string, string>>({});
// 🆕 엔티티 참조 데이터 로드
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<HTMLInputElement>) => {
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<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
};
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
};
const handleDrop = async (e: React.DragEvent<HTMLDivElement>) => {
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<string, any> = {};
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<string, any> = {};
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<string, string | null> = {};
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-h-[95vh] max-w-[95vw] sm:max-w-[1200px]"
style={{
width: "1000px",
height: "700px",
minWidth: "700px",
minHeight: "500px",
maxWidth: "1400px",
maxHeight: "900px",
}}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
<FileSpreadsheet className="h-5 w-5" />
{isMasterDetail && (
<span className="ml-2 rounded bg-blue-100 px-2 py-0.5 text-xs font-normal text-blue-700">
-
</span>
)}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{isMasterDetail && masterDetailRelation ? (
<>
({masterDetailRelation.masterTable}) + ({masterDetailRelation.detailTable}) .
.
</>
) : (
"엑셀 파일을 선택하고 컬럼을 매핑하여 데이터를 업로드하세요."
)}
</DialogDescription>
</DialogHeader>
{/* 스텝 인디케이터 (3단계) */}
<div className="flex items-center justify-between">
{[
{ num: 1, label: "파일 선택" },
{ num: 2, label: "컬럼 매핑" },
{ num: 3, label: "확인" },
].map((step, index) => (
<React.Fragment key={step.num}>
<div className="flex flex-col items-center gap-1">
<div
className={cn(
"flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium transition-colors sm:h-10 sm:w-10",
currentStep === step.num
? "bg-primary text-primary-foreground"
: currentStep > step.num
? "bg-success text-white"
: "bg-muted text-muted-foreground"
)}
>
{currentStep > step.num ? (
<CheckCircle2 className="h-4 w-4 sm:h-5 sm:w-5" />
) : (
step.num
)}
</div>
<span
className={cn(
"text-[10px] font-medium sm:text-xs",
currentStep === step.num ? "text-primary" : "text-muted-foreground"
)}
>
{step.label}
</span>
</div>
{index < 2 && (
<div
className={cn(
"h-0.5 flex-1 transition-colors",
currentStep > step.num ? "bg-success" : "bg-muted"
)}
/>
)}
</React.Fragment>
))}
</div>
{/* 스텝별 컨텐츠 */}
<div className="max-h-[calc(95vh-200px)] space-y-4 overflow-y-auto">
{/* 1단계: 파일 선택 & 미리보기 (통합) */}
{currentStep === 1 && (
<div className="space-y-4">
{/* 🆕 마스터-디테일 간단 모드: 마스터 필드 입력 */}
{hasMasterSelectFields && (
<div className="grid gap-3 sm:grid-cols-2">
{masterDetailExcelConfig?.masterSelectFields?.map((field) => (
<div key={field.columnName} className="space-y-1">
<Label className="text-xs">
{field.columnLabel}
{field.required && <span className="ml-1 text-destructive">*</span>}
</Label>
{field.inputType === "entity" ? (
<Select
value={masterFieldValues[field.columnName]?.toString() || ""}
onValueChange={(value) =>
setMasterFieldValues((prev) => ({
...prev,
[field.columnName]: value,
}))
}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue placeholder={`${field.columnLabel} 선택`} />
</SelectTrigger>
<SelectContent>
{entitySearchLoading[field.columnName] ? (
<SelectItem value="loading" disabled>
...
</SelectItem>
) : (
entitySearchData[field.columnName]?.map((item: any) => {
const keyValue = item[field.referenceColumn || "id"];
// displayColumn: 저장된 값 → DB에서 조회한 값 → referenceColumn → id
const displayColName =
field.displayColumn ||
entityDisplayColumns[field.columnName] ||
field.referenceColumn ||
"id";
const displayValue = item[displayColName] || keyValue;
return (
<SelectItem
key={keyValue}
value={keyValue?.toString()}
className="text-xs"
>
{displayValue}
</SelectItem>
);
})
)}
</SelectContent>
</Select>
) : field.inputType === "date" ? (
<input
type="date"
value={masterFieldValues[field.columnName] || ""}
onChange={(e) =>
setMasterFieldValues((prev) => ({
...prev,
[field.columnName]: e.target.value,
}))
}
className="h-9 w-full rounded-md border px-3 text-xs"
/>
) : (
<input
type="text"
value={masterFieldValues[field.columnName] || ""}
onChange={(e) =>
setMasterFieldValues((prev) => ({
...prev,
[field.columnName]: e.target.value,
}))
}
placeholder={field.columnLabel}
className="h-9 w-full rounded-md border px-3 text-xs"
/>
)}
</div>
))}
</div>
)}
{/* 파일 선택 영역 */}
<div>
<Label htmlFor="file-upload" className="text-xs sm:text-sm">
*
</Label>
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => 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 ? (
<div className="flex items-center gap-3">
<FileSpreadsheet className="h-8 w-8 text-green-600" />
<div>
<p className="text-sm font-medium text-green-700">{file.name}</p>
<p className="text-xs text-muted-foreground">
</p>
</div>
</div>
) : (
<>
<Upload
className={cn(
"mb-2 h-8 w-8",
isDragOver ? "text-primary" : "text-muted-foreground"
)}
/>
<p
className={cn(
"text-sm font-medium",
isDragOver ? "text-primary" : "text-muted-foreground"
)}
>
{isDragOver
? "파일을 놓으세요"
: "파일을 드래그하거나 클릭하여 선택"}
</p>
<p className="mt-1 text-xs text-muted-foreground">
형식: .xlsx, .xls, .csv
</p>
</>
)}
<input
ref={fileInputRef}
id="file-upload"
type="file"
accept=".xlsx,.xls,.csv"
onChange={handleFileChange}
className="hidden"
/>
</div>
</div>
{/* 파일이 선택된 경우에만 미리보기 표시 */}
{file && displayData.length > 0 && (
<>
{/* 시트 선택 */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Label className="text-xs text-muted-foreground sm:text-sm">
:
</Label>
<Select value={selectedSheet} onValueChange={handleSheetChange}>
<SelectTrigger className="h-8 w-[140px] text-xs sm:h-9 sm:w-[180px] sm:text-sm">
<SelectValue placeholder="Sheet1" />
</SelectTrigger>
<SelectContent>
{sheetNames.map((sheetName) => (
<SelectItem
key={sheetName}
value={sheetName}
className="text-xs sm:text-sm"
>
{sheetName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<span className="text-xs text-muted-foreground">
{displayData.length} · , Tab/Enter로
</span>
</div>
{/* 엑셀처럼 편집 가능한 스프레드시트 */}
<EditableSpreadsheet
columns={excelColumns}
data={displayData}
onColumnsChange={(newColumns) => {
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"
/>
</>
)}
</div>
)}
{/* 2단계: 컬럼 매핑 */}
{currentStep === 2 && (
<div className="space-y-4">
{/* 상단: 제목 + 자동 매핑 버튼 */}
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold sm:text-base"> </h3>
<Button
type="button"
variant="default"
size="sm"
onClick={handleAutoMapping}
className="h-8 text-xs sm:h-9 sm:text-sm"
>
<Zap className="mr-2 h-4 w-4" />
</Button>
</div>
{/* 매핑 리스트 */}
<div className="space-y-2">
<div className="grid grid-cols-[1fr_auto_1fr] gap-2 text-[10px] font-medium text-muted-foreground sm:text-xs">
<div> </div>
<div></div>
<div> </div>
</div>
<div className="max-h-[350px] space-y-2 overflow-y-auto">
{columnMappings.map((mapping, index) => (
<div
key={index}
className="grid grid-cols-[1fr_auto_1fr] items-center gap-2"
>
<div className="rounded-md border border-border bg-muted px-3 py-2 text-xs font-medium sm:text-sm">
{mapping.excelColumn}
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
<Select
value={mapping.systemColumn || "none"}
onValueChange={(value) =>
handleMappingChange(
mapping.excelColumn,
value === "none" ? null : value
)
}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="매핑 안함">
{mapping.systemColumn
? (() => {
const col = systemColumns.find(
(c) => c.name === mapping.systemColumn
);
return col?.label || mapping.systemColumn;
})()
: "매핑 안함"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="none" className="text-xs sm:text-sm">
</SelectItem>
{systemColumns.map((col) => (
<SelectItem
key={col.name}
value={col.name}
className="text-xs sm:text-sm"
>
{col.label || col.name} ({col.type})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
</div>
{/* 매핑 자동 저장 안내 */}
{isAutoMappingLoaded ? (
<div className="rounded-md border border-success bg-success/10 p-3">
<div className="flex items-start gap-2">
<CheckCircle2 className="mt-0.5 h-4 w-4 text-success" />
<div className="text-[10px] text-success sm:text-xs">
<p className="font-medium"> </p>
<p className="mt-1">
.
.
</p>
</div>
</div>
</div>
) : (
<div className="rounded-md border border-muted bg-muted/30 p-3">
<div className="flex items-start gap-2">
<Zap className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="text-[10px] text-muted-foreground sm:text-xs">
<p className="font-medium"> </p>
<p className="mt-1">
.
.
</p>
</div>
</div>
</div>
)}
</div>
)}
{/* 3단계: 확인 */}
{currentStep === 3 && (
<div className="space-y-4">
<div className="rounded-md border border-border bg-muted/50 p-4">
<h3 className="text-sm font-medium sm:text-base"> </h3>
<div className="mt-2 space-y-1 text-[10px] text-muted-foreground sm:text-xs">
<p>
<span className="font-medium">:</span> {file?.name}
</p>
<p>
<span className="font-medium">:</span> {selectedSheet}
</p>
<p>
<span className="font-medium"> :</span> {allData.length}
</p>
<p>
<span className="font-medium">:</span> {tableName}
</p>
<p>
<span className="font-medium">:</span>{" "}
{uploadMode === "insert"
? "신규 등록"
: uploadMode === "update"
? "업데이트"
: "Upsert"}
</p>
</div>
</div>
<div className="rounded-md border border-border bg-muted/50 p-4">
<h3 className="text-sm font-medium sm:text-base"> </h3>
<div className="mt-2 space-y-1 text-[10px] text-muted-foreground sm:text-xs">
{columnMappings
.filter((m) => m.systemColumn)
.map((mapping, index) => {
const col = systemColumns.find(
(c) => c.name === mapping.systemColumn
);
return (
<p key={index}>
<span className="font-medium">{mapping.excelColumn}</span> {" "}
{col?.label || mapping.systemColumn}
</p>
);
})}
{columnMappings.filter((m) => m.systemColumn).length === 0 && (
<p className="text-destructive"> .</p>
)}
</div>
</div>
<div className="rounded-md border border-warning bg-warning/10 p-3">
<div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-4 w-4 text-warning" />
<div className="text-[10px] text-warning sm:text-xs">
<p className="font-medium"></p>
<p className="mt-1">
.
?
</p>
</div>
</div>
</div>
</div>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={currentStep === 1 ? () => onOpenChange(false) : handlePrevious}
disabled={isUploading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{currentStep === 1 ? "취소" : "이전"}
</Button>
{currentStep < 3 ? (
<Button
onClick={handleNext}
disabled={isUploading || (currentStep === 1 && !file)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
) : (
<Button
onClick={handleUpload}
disabled={
isUploading || columnMappings.filter((m) => m.systemColumn).length === 0
}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isUploading ? "업로드 중..." : "업로드"}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
};