2025-11-04 09:41:58 +09:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
2025-11-04 18:31:26 +09:00
|
|
|
|
import React, { useState, useRef, useEffect } from "react";
|
2025-11-04 09:41:58 +09:00
|
|
|
|
import {
|
2025-12-05 10:46:10 +09:00
|
|
|
|
Dialog,
|
|
|
|
|
|
DialogContent,
|
|
|
|
|
|
DialogHeader,
|
|
|
|
|
|
DialogTitle,
|
|
|
|
|
|
DialogDescription,
|
|
|
|
|
|
DialogFooter,
|
|
|
|
|
|
} from "@/components/ui/dialog";
|
2025-11-04 09:41:58 +09:00
|
|
|
|
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";
|
2025-11-04 18:31:26 +09:00
|
|
|
|
import {
|
|
|
|
|
|
Upload,
|
|
|
|
|
|
FileSpreadsheet,
|
|
|
|
|
|
AlertCircle,
|
|
|
|
|
|
CheckCircle2,
|
|
|
|
|
|
ArrowRight,
|
|
|
|
|
|
Zap,
|
|
|
|
|
|
} from "lucide-react";
|
2025-11-04 09:41:58 +09:00
|
|
|
|
import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport";
|
|
|
|
|
|
import { DynamicFormApi } from "@/lib/api/dynamicForm";
|
2025-11-04 18:31:26 +09:00
|
|
|
|
import { getTableSchema, TableColumn } from "@/lib/api/tableSchema";
|
|
|
|
|
|
import { cn } from "@/lib/utils";
|
2026-01-08 11:45:39 +09:00
|
|
|
|
import { findMappingByColumns, saveMappingTemplate } from "@/lib/api/excelMapping";
|
2026-01-08 12:04:31 +09:00
|
|
|
|
import { EditableSpreadsheet } from "./EditableSpreadsheet";
|
2025-11-04 09:41:58 +09:00
|
|
|
|
|
2026-01-09 15:32:02 +09:00
|
|
|
|
// 마스터-디테일 엑셀 업로드 설정 (버튼 설정에서 설정)
|
|
|
|
|
|
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>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-04 09:41:58 +09:00
|
|
|
|
export interface ExcelUploadModalProps {
|
|
|
|
|
|
open: boolean;
|
|
|
|
|
|
onOpenChange: (open: boolean) => void;
|
|
|
|
|
|
tableName: string;
|
|
|
|
|
|
uploadMode?: "insert" | "update" | "upsert";
|
|
|
|
|
|
keyColumn?: string;
|
|
|
|
|
|
onSuccess?: () => void;
|
2025-11-05 16:36:32 +09:00
|
|
|
|
userId?: string;
|
2026-01-09 11:21:16 +09:00
|
|
|
|
// 마스터-디테일 지원
|
|
|
|
|
|
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 }>;
|
|
|
|
|
|
};
|
2026-01-09 15:32:02 +09:00
|
|
|
|
// 🆕 마스터-디테일 엑셀 업로드 설정
|
|
|
|
|
|
masterDetailExcelConfig?: MasterDetailExcelConfig;
|
2026-01-09 17:56:48 +09:00
|
|
|
|
// 🆕 단일 테이블 채번 설정
|
|
|
|
|
|
numberingRuleId?: string;
|
|
|
|
|
|
numberingTargetColumn?: string;
|
|
|
|
|
|
// 🆕 업로드 후 제어 실행 설정
|
|
|
|
|
|
afterUploadFlows?: Array<{ flowId: string; order: number }>;
|
2025-11-04 09:41:58 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-04 18:31:26 +09:00
|
|
|
|
interface ColumnMapping {
|
|
|
|
|
|
excelColumn: string;
|
|
|
|
|
|
systemColumn: string | null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-04 09:41:58 +09:00
|
|
|
|
export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|
|
|
|
|
open,
|
|
|
|
|
|
onOpenChange,
|
|
|
|
|
|
tableName,
|
|
|
|
|
|
uploadMode = "insert",
|
|
|
|
|
|
keyColumn,
|
|
|
|
|
|
onSuccess,
|
2025-11-05 16:36:32 +09:00
|
|
|
|
userId = "guest",
|
2026-01-09 11:21:16 +09:00
|
|
|
|
screenId,
|
|
|
|
|
|
isMasterDetail = false,
|
|
|
|
|
|
masterDetailRelation,
|
2026-01-09 15:32:02 +09:00
|
|
|
|
masterDetailExcelConfig,
|
2026-01-09 17:56:48 +09:00
|
|
|
|
// 단일 테이블 채번 설정
|
|
|
|
|
|
numberingRuleId,
|
|
|
|
|
|
numberingTargetColumn,
|
|
|
|
|
|
// 업로드 후 제어 실행 설정
|
|
|
|
|
|
afterUploadFlows,
|
2025-11-04 09:41:58 +09:00
|
|
|
|
}) => {
|
2025-11-04 18:31:26 +09:00
|
|
|
|
const [currentStep, setCurrentStep] = useState(1);
|
|
|
|
|
|
|
2026-01-08 11:51:02 +09:00
|
|
|
|
// 1단계: 파일 선택 & 미리보기
|
2025-11-04 09:41:58 +09:00
|
|
|
|
const [file, setFile] = useState<File | null>(null);
|
|
|
|
|
|
const [sheetNames, setSheetNames] = useState<string[]>([]);
|
|
|
|
|
|
const [selectedSheet, setSelectedSheet] = useState<string>("");
|
2026-01-08 11:45:39 +09:00
|
|
|
|
const [isDragOver, setIsDragOver] = useState(false);
|
2025-11-04 09:41:58 +09:00
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
2025-11-04 18:31:26 +09:00
|
|
|
|
const [detectedRange, setDetectedRange] = useState<string>("");
|
|
|
|
|
|
const [allData, setAllData] = useState<Record<string, any>[]>([]);
|
|
|
|
|
|
const [displayData, setDisplayData] = useState<Record<string, any>[]>([]);
|
|
|
|
|
|
|
2026-01-08 11:51:02 +09:00
|
|
|
|
// 2단계: 컬럼 매핑 + 매핑 템플릿 자동 적용
|
|
|
|
|
|
const [isAutoMappingLoaded, setIsAutoMappingLoaded] = useState(false);
|
2025-11-04 18:31:26 +09:00
|
|
|
|
const [excelColumns, setExcelColumns] = useState<string[]>([]);
|
|
|
|
|
|
const [systemColumns, setSystemColumns] = useState<TableColumn[]>([]);
|
|
|
|
|
|
const [columnMappings, setColumnMappings] = useState<ColumnMapping[]>([]);
|
|
|
|
|
|
|
2026-01-08 11:51:02 +09:00
|
|
|
|
// 3단계: 확인
|
2025-11-04 18:31:26 +09:00
|
|
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
|
|
|
|
|
2026-01-09 15:32:02 +09:00
|
|
|
|
// 🆕 마스터-디테일 모드: 마스터 필드 입력값
|
|
|
|
|
|
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 !== "";
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-04 09:41:58 +09:00
|
|
|
|
// 파일 선택 핸들러
|
|
|
|
|
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
|
|
|
const selectedFile = e.target.files?.[0];
|
|
|
|
|
|
if (!selectedFile) return;
|
2026-01-08 11:45:39 +09:00
|
|
|
|
await processFile(selectedFile);
|
|
|
|
|
|
};
|
2025-11-04 09:41:58 +09:00
|
|
|
|
|
2026-01-08 11:45:39 +09:00
|
|
|
|
// 파일 처리 공통 함수 (파일 선택 및 드래그 앤 드롭에서 공유)
|
|
|
|
|
|
const processFile = async (selectedFile: File) => {
|
2025-11-04 09:41:58 +09:00
|
|
|
|
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]);
|
2025-11-04 18:31:26 +09:00
|
|
|
|
setAllData(data);
|
2026-01-08 11:45:39 +09:00
|
|
|
|
setDisplayData(data);
|
2025-11-04 18:31:26 +09:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
2025-11-04 09:41:58 +09:00
|
|
|
|
|
|
|
|
|
|
toast.success(`파일이 선택되었습니다: ${selectedFile.name}`);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("파일 읽기 오류:", error);
|
|
|
|
|
|
toast.error("파일을 읽는 중 오류가 발생했습니다.");
|
|
|
|
|
|
setFile(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-08 11:45:39 +09:00
|
|
|
|
// 드래그 앤 드롭 핸들러
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-04 09:41:58 +09:00
|
|
|
|
// 시트 변경 핸들러
|
|
|
|
|
|
const handleSheetChange = async (sheetName: string) => {
|
|
|
|
|
|
setSelectedSheet(sheetName);
|
|
|
|
|
|
if (!file) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await importFromExcel(file, sheetName);
|
2025-11-04 18:31:26 +09:00
|
|
|
|
setAllData(data);
|
2026-01-08 11:51:02 +09:00
|
|
|
|
setDisplayData(data);
|
2025-11-04 18:31:26 +09:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
2025-11-04 09:41:58 +09:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("시트 읽기 오류:", error);
|
|
|
|
|
|
toast.error("시트를 읽는 중 오류가 발생했습니다.");
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-08 11:51:02 +09:00
|
|
|
|
// 테이블 스키마 가져오기 (2단계 진입 시)
|
2025-11-04 18:31:26 +09:00
|
|
|
|
useEffect(() => {
|
2026-01-08 11:51:02 +09:00
|
|
|
|
if (currentStep === 2 && tableName) {
|
2025-11-04 18:31:26 +09:00
|
|
|
|
loadTableSchema();
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [currentStep, tableName]);
|
|
|
|
|
|
|
2026-01-08 11:45:39 +09:00
|
|
|
|
// 테이블 생성 시 자동 생성되는 시스템 컬럼 (매핑에서 제외)
|
|
|
|
|
|
const AUTO_GENERATED_COLUMNS = [
|
2026-01-08 11:51:02 +09:00
|
|
|
|
"id",
|
|
|
|
|
|
"created_date",
|
|
|
|
|
|
"updated_date",
|
|
|
|
|
|
"writer",
|
|
|
|
|
|
"company_code",
|
2026-01-08 11:45:39 +09:00
|
|
|
|
];
|
|
|
|
|
|
|
2025-11-04 18:31:26 +09:00
|
|
|
|
const loadTableSchema = async () => {
|
|
|
|
|
|
try {
|
2026-01-09 15:32:02 +09:00
|
|
|
|
console.log("🔍 테이블 스키마 로드 시작:", { tableName, isMasterDetail, isSimpleMasterDetailMode });
|
2026-01-08 11:51:02 +09:00
|
|
|
|
|
2026-01-09 11:21:16 +09:00
|
|
|
|
let allColumns: TableColumn[] = [];
|
2026-01-08 11:51:02 +09:00
|
|
|
|
|
2026-01-09 15:32:02 +09:00
|
|
|
|
// 🆕 마스터-디테일 간단 모드: 디테일 테이블 컬럼만 로드 (마스터 필드는 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) {
|
2026-01-09 11:21:16 +09:00
|
|
|
|
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];
|
|
|
|
|
|
}
|
2026-01-08 11:51:02 +09:00
|
|
|
|
|
2026-01-09 11:21:16 +09:00
|
|
|
|
// 디테일 테이블 스키마 (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())
|
|
|
|
|
|
);
|
2026-01-08 11:45:39 +09:00
|
|
|
|
} else {
|
2026-01-09 11:21:16 +09:00
|
|
|
|
console.error("❌ 테이블 스키마 로드 실패:", response);
|
|
|
|
|
|
return;
|
2026-01-08 11:45:39 +09:00
|
|
|
|
}
|
2026-01-09 11:21:16 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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}개 컬럼)`);
|
2025-11-04 18:31:26 +09:00
|
|
|
|
} else {
|
2026-01-09 11:21:16 +09:00
|
|
|
|
// 매핑 템플릿이 없으면 초기 상태로 설정
|
|
|
|
|
|
console.log("ℹ️ 매핑 템플릿 없음 - 새 엑셀 구조");
|
|
|
|
|
|
const initialMappings: ColumnMapping[] = excelColumns.map((col) => ({
|
|
|
|
|
|
excelColumn: col,
|
|
|
|
|
|
systemColumn: null,
|
|
|
|
|
|
}));
|
|
|
|
|
|
setColumnMappings(initialMappings);
|
|
|
|
|
|
setIsAutoMappingLoaded(false);
|
2025-11-04 18:31:26 +09:00
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("❌ 테이블 스키마 로드 실패:", error);
|
|
|
|
|
|
toast.error("테이블 스키마를 불러올 수 없습니다.");
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-17 12:01:16 +09:00
|
|
|
|
// 자동 매핑 - 컬럼명과 라벨 모두 비교
|
2025-11-04 18:31:26 +09:00
|
|
|
|
const handleAutoMapping = () => {
|
|
|
|
|
|
const newMappings = excelColumns.map((excelCol) => {
|
2025-12-17 12:01:16 +09:00
|
|
|
|
const normalizedExcelCol = excelCol.toLowerCase().trim();
|
2026-01-09 11:21:16 +09:00
|
|
|
|
// [마스터], [디테일] 접두사 제거 후 비교
|
|
|
|
|
|
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;
|
|
|
|
|
|
});
|
2025-11-04 18:31:26 +09:00
|
|
|
|
|
2025-12-17 12:01:16 +09:00
|
|
|
|
// 2. 라벨로 매칭되지 않으면 컬럼명으로 매칭 시도
|
|
|
|
|
|
if (!matchedSystemCol) {
|
2026-01-09 11:21:16 +09:00
|
|
|
|
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;
|
|
|
|
|
|
});
|
2025-12-17 12:01:16 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-04 18:31:26 +09:00
|
|
|
|
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) =>
|
2026-01-08 11:51:02 +09:00
|
|
|
|
mapping.excelColumn === excelColumn ? { ...mapping, systemColumn } : mapping
|
2025-11-04 18:31:26 +09:00
|
|
|
|
)
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 다음 단계
|
|
|
|
|
|
const handleNext = () => {
|
|
|
|
|
|
if (currentStep === 1 && !file) {
|
2025-11-04 09:41:58 +09:00
|
|
|
|
toast.error("파일을 선택해주세요.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-08 11:51:02 +09:00
|
|
|
|
if (currentStep === 1 && displayData.length === 0) {
|
2025-11-04 18:31:26 +09:00
|
|
|
|
toast.error("데이터가 없습니다.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 15:32:02 +09:00
|
|
|
|
// 🆕 마스터-디테일 간단 모드: 마스터 필드 유효성 검사
|
|
|
|
|
|
if (currentStep === 1 && hasMasterSelectFields && !isMasterFieldsValid()) {
|
|
|
|
|
|
toast.error("마스터 정보를 모두 입력해주세요.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-08 12:04:31 +09:00
|
|
|
|
// 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}개 열이 제외되었습니다.`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-08 11:51:02 +09:00
|
|
|
|
setCurrentStep((prev) => Math.min(prev + 1, 3));
|
2025-11-04 18:31:26 +09:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 이전 단계
|
|
|
|
|
|
const handlePrevious = () => {
|
|
|
|
|
|
setCurrentStep((prev) => Math.max(prev - 1, 1));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 업로드 핸들러
|
|
|
|
|
|
const handleUpload = async () => {
|
|
|
|
|
|
if (!file || !tableName) {
|
|
|
|
|
|
toast.error("필수 정보가 누락되었습니다.");
|
2025-11-04 09:41:58 +09:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setIsUploading(true);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-01-08 11:51:02 +09:00
|
|
|
|
// allData를 사용하여 전체 데이터 업로드
|
2025-12-17 15:00:15 +09:00
|
|
|
|
const mappedData = allData.map((row) => {
|
2025-11-04 18:31:26 +09:00
|
|
|
|
const mappedRow: Record<string, any> = {};
|
|
|
|
|
|
columnMappings.forEach((mapping) => {
|
|
|
|
|
|
if (mapping.systemColumn) {
|
2026-01-09 11:21:16 +09:00
|
|
|
|
// 마스터-디테일 모드: 테이블.컬럼 형식에서 컬럼명만 추출
|
|
|
|
|
|
let colName = mapping.systemColumn;
|
|
|
|
|
|
if (isMasterDetail && colName.includes(".")) {
|
|
|
|
|
|
colName = colName.split(".")[1];
|
|
|
|
|
|
}
|
|
|
|
|
|
mappedRow[colName] = row[mapping.excelColumn];
|
2025-11-04 18:31:26 +09:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
return mappedRow;
|
2025-11-04 09:41:58 +09:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-01-08 11:09:40 +09:00
|
|
|
|
// 빈 행 필터링: 모든 값이 비어있거나 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;
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-01-08 11:51:02 +09:00
|
|
|
|
console.log(
|
|
|
|
|
|
`📊 엑셀 업로드: 전체 ${mappedData.length}행 중 유효한 ${filteredData.length}행`
|
|
|
|
|
|
);
|
2026-01-08 11:09:40 +09:00
|
|
|
|
|
2026-01-09 15:32:02 +09:00
|
|
|
|
// 🆕 마스터-디테일 간단 모드 처리 (마스터 필드 선택 + 채번)
|
|
|
|
|
|
if (isSimpleMasterDetailMode && screenId && masterDetailRelation) {
|
|
|
|
|
|
console.log("📊 마스터-디테일 간단 모드 업로드:", {
|
|
|
|
|
|
masterDetailRelation,
|
|
|
|
|
|
masterFieldValues,
|
|
|
|
|
|
numberingRuleId: masterDetailExcelConfig?.numberingRuleId,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const uploadResult = await DynamicFormApi.uploadMasterDetailSimple(
|
|
|
|
|
|
screenId,
|
|
|
|
|
|
filteredData,
|
|
|
|
|
|
masterFieldValues,
|
2026-01-09 15:46:09 +09:00
|
|
|
|
masterDetailExcelConfig?.numberingRuleId || undefined,
|
|
|
|
|
|
masterDetailExcelConfig?.afterUploadFlowId || undefined, // 하위 호환성
|
|
|
|
|
|
masterDetailExcelConfig?.afterUploadFlows || undefined // 다중 제어
|
2026-01-09 15:32:02 +09:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-01-09 11:21:16 +09:00
|
|
|
|
console.log("📊 마스터-디테일 업로드 모드:", masterDetailRelation);
|
2025-11-04 09:41:58 +09:00
|
|
|
|
|
2026-01-09 11:21:16 +09:00
|
|
|
|
const uploadResult = await DynamicFormApi.uploadMasterDetailData(
|
|
|
|
|
|
screenId,
|
|
|
|
|
|
filteredData
|
2025-11-04 18:31:26 +09:00
|
|
|
|
);
|
2026-01-08 11:45:39 +09:00
|
|
|
|
|
2026-01-09 11:21:16 +09:00
|
|
|
|
if (uploadResult.success && uploadResult.data) {
|
|
|
|
|
|
const { masterInserted, masterUpdated, detailInserted, errors } = uploadResult.data;
|
|
|
|
|
|
|
|
|
|
|
|
toast.success(
|
|
|
|
|
|
`마스터 ${masterInserted + masterUpdated}건, 디테일 ${detailInserted}건 처리되었습니다.` +
|
|
|
|
|
|
(errors.length > 0 ? ` (오류: ${errors.length}건)` : "")
|
2026-01-08 11:51:02 +09:00
|
|
|
|
);
|
|
|
|
|
|
|
2026-01-09 11:21:16 +09:00
|
|
|
|
// 매핑 템플릿 저장
|
|
|
|
|
|
await saveMappingTemplateInternal();
|
|
|
|
|
|
|
|
|
|
|
|
onSuccess?.();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
toast.error(uploadResult.message || "마스터-디테일 업로드에 실패했습니다.");
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 기존 단일 테이블 업로드 로직
|
|
|
|
|
|
let successCount = 0;
|
|
|
|
|
|
let failCount = 0;
|
|
|
|
|
|
|
2026-01-09 18:22:50 +09:00
|
|
|
|
// 단일 테이블 채번 설정 확인
|
2026-01-09 17:56:48 +09:00
|
|
|
|
const hasNumbering = numberingRuleId && numberingTargetColumn;
|
|
|
|
|
|
|
2026-01-09 11:21:16 +09:00
|
|
|
|
for (const row of filteredData) {
|
|
|
|
|
|
try {
|
2026-01-09 17:56:48 +09:00
|
|
|
|
let dataToSave = { ...row };
|
|
|
|
|
|
|
2026-01-09 18:22:50 +09:00
|
|
|
|
// 채번 적용: 각 행마다 채번 API 호출
|
2026-01-09 17:56:48 +09:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 11:21:16 +09:00
|
|
|
|
if (uploadMode === "insert") {
|
2026-01-09 17:56:48 +09:00
|
|
|
|
const formData = { screenId: 0, tableName, data: dataToSave };
|
2026-01-09 11:21:16 +09:00
|
|
|
|
const result = await DynamicFormApi.saveFormData(formData);
|
|
|
|
|
|
if (result.success) {
|
|
|
|
|
|
successCount++;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
failCount++;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
failCount++;
|
2026-01-08 11:45:39 +09:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 17:56:48 +09:00
|
|
|
|
// 🆕 업로드 후 제어 실행
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 11:21:16 +09:00
|
|
|
|
if (successCount > 0) {
|
|
|
|
|
|
toast.success(
|
|
|
|
|
|
`${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}`
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// 매핑 템플릿 저장
|
|
|
|
|
|
await saveMappingTemplateInternal();
|
|
|
|
|
|
|
|
|
|
|
|
onSuccess?.();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
toast.error("업로드에 실패했습니다.");
|
|
|
|
|
|
}
|
2025-11-04 09:41:58 +09:00
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("❌ 엑셀 업로드 실패:", error);
|
|
|
|
|
|
toast.error("엑셀 업로드 중 오류가 발생했습니다.");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsUploading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-09 11:21:16 +09:00
|
|
|
|
// 매핑 템플릿 저장 헬퍼 함수
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-04 18:31:26 +09:00
|
|
|
|
// 모달 닫기 시 초기화
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!open) {
|
|
|
|
|
|
setCurrentStep(1);
|
|
|
|
|
|
setFile(null);
|
|
|
|
|
|
setSheetNames([]);
|
|
|
|
|
|
setSelectedSheet("");
|
2026-01-08 11:45:39 +09:00
|
|
|
|
setIsAutoMappingLoaded(false);
|
2025-11-04 18:31:26 +09:00
|
|
|
|
setDetectedRange("");
|
|
|
|
|
|
setAllData([]);
|
|
|
|
|
|
setDisplayData([]);
|
|
|
|
|
|
setExcelColumns([]);
|
|
|
|
|
|
setSystemColumns([]);
|
|
|
|
|
|
setColumnMappings([]);
|
2026-01-09 15:32:02 +09:00
|
|
|
|
// 🆕 마스터-디테일 모드 초기화
|
|
|
|
|
|
setMasterFieldValues({});
|
2025-11-04 18:31:26 +09:00
|
|
|
|
}
|
|
|
|
|
|
}, [open]);
|
|
|
|
|
|
|
2025-11-04 09:41:58 +09:00
|
|
|
|
return (
|
2025-12-05 10:46:10 +09:00
|
|
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
|
|
|
|
<DialogContent
|
2025-11-05 16:36:32 +09:00
|
|
|
|
className="max-h-[95vh] max-w-[95vw] sm:max-w-[1200px]"
|
2025-12-05 10:46:10 +09:00
|
|
|
|
style={{
|
|
|
|
|
|
width: "1000px",
|
|
|
|
|
|
height: "700px",
|
|
|
|
|
|
minWidth: "700px",
|
|
|
|
|
|
minHeight: "500px",
|
|
|
|
|
|
maxWidth: "1400px",
|
|
|
|
|
|
maxHeight: "900px",
|
|
|
|
|
|
}}
|
2025-11-05 16:36:32 +09:00
|
|
|
|
>
|
2025-12-05 10:46:10 +09:00
|
|
|
|
<DialogHeader>
|
|
|
|
|
|
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
2025-11-04 18:31:26 +09:00
|
|
|
|
<FileSpreadsheet className="h-5 w-5" />
|
|
|
|
|
|
엑셀 데이터 업로드
|
2026-01-09 11:21:16 +09:00
|
|
|
|
{isMasterDetail && (
|
|
|
|
|
|
<span className="ml-2 rounded bg-blue-100 px-2 py-0.5 text-xs font-normal text-blue-700">
|
|
|
|
|
|
마스터-디테일
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
2025-12-05 10:46:10 +09:00
|
|
|
|
</DialogTitle>
|
|
|
|
|
|
<DialogDescription className="text-xs sm:text-sm">
|
2026-01-09 11:21:16 +09:00
|
|
|
|
{isMasterDetail && masterDetailRelation ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
마스터({masterDetailRelation.masterTable}) + 디테일({masterDetailRelation.detailTable}) 구조입니다.
|
|
|
|
|
|
마스터 데이터는 중복 입력 시 병합됩니다.
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
"엑셀 파일을 선택하고 컬럼을 매핑하여 데이터를 업로드하세요."
|
|
|
|
|
|
)}
|
2025-12-05 10:46:10 +09:00
|
|
|
|
</DialogDescription>
|
|
|
|
|
|
</DialogHeader>
|
2025-11-04 09:41:58 +09:00
|
|
|
|
|
2026-01-08 11:51:02 +09:00
|
|
|
|
{/* 스텝 인디케이터 (3단계) */}
|
2025-11-04 18:31:26 +09:00
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
{[
|
|
|
|
|
|
{ num: 1, label: "파일 선택" },
|
2026-01-08 11:51:02 +09:00
|
|
|
|
{ num: 2, label: "컬럼 매핑" },
|
|
|
|
|
|
{ num: 3, label: "확인" },
|
2025-11-04 18:31:26 +09:00
|
|
|
|
].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",
|
2026-01-08 11:51:02 +09:00
|
|
|
|
currentStep === step.num ? "text-primary" : "text-muted-foreground"
|
2025-11-04 18:31:26 +09:00
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{step.label}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2026-01-08 11:51:02 +09:00
|
|
|
|
{index < 2 && (
|
2025-11-04 18:31:26 +09:00
|
|
|
|
<div
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"h-0.5 flex-1 transition-colors",
|
|
|
|
|
|
currentStep > step.num ? "bg-success" : "bg-muted"
|
|
|
|
|
|
)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</React.Fragment>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
2025-11-04 09:41:58 +09:00
|
|
|
|
|
2025-11-04 18:31:26 +09:00
|
|
|
|
{/* 스텝별 컨텐츠 */}
|
|
|
|
|
|
<div className="max-h-[calc(95vh-200px)] space-y-4 overflow-y-auto">
|
2026-01-08 11:51:02 +09:00
|
|
|
|
{/* 1단계: 파일 선택 & 미리보기 (통합) */}
|
2025-11-04 18:31:26 +09:00
|
|
|
|
{currentStep === 1 && (
|
|
|
|
|
|
<div className="space-y-4">
|
2026-01-09 15:32:02 +09:00
|
|
|
|
{/* 🆕 마스터-디테일 간단 모드: 마스터 필드 입력 */}
|
|
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-01-08 11:51:02 +09:00
|
|
|
|
{/* 파일 선택 영역 */}
|
2025-11-04 18:31:26 +09:00
|
|
|
|
<div>
|
|
|
|
|
|
<Label htmlFor="file-upload" className="text-xs sm:text-sm">
|
|
|
|
|
|
파일 선택 *
|
|
|
|
|
|
</Label>
|
2026-01-08 11:45:39 +09:00
|
|
|
|
<div
|
|
|
|
|
|
onDragOver={handleDragOver}
|
|
|
|
|
|
onDragLeave={handleDragLeave}
|
|
|
|
|
|
onDrop={handleDrop}
|
|
|
|
|
|
onClick={() => fileInputRef.current?.click()}
|
|
|
|
|
|
className={cn(
|
2026-01-08 11:51:02 +09:00
|
|
|
|
"mt-2 flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-4 transition-colors",
|
2026-01-08 11:45:39 +09:00
|
|
|
|
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 ? (
|
2026-01-08 11:51:02 +09:00
|
|
|
|
<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>
|
2026-01-08 11:45:39 +09:00
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
2026-01-08 11:51:02 +09:00
|
|
|
|
<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
|
|
|
|
|
|
? "파일을 놓으세요"
|
|
|
|
|
|
: "파일을 드래그하거나 클릭하여 선택"}
|
2026-01-08 11:45:39 +09:00
|
|
|
|
</p>
|
|
|
|
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
|
|
|
|
지원 형식: .xlsx, .xls, .csv
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2025-11-04 18:31:26 +09:00
|
|
|
|
<input
|
|
|
|
|
|
ref={fileInputRef}
|
|
|
|
|
|
id="file-upload"
|
|
|
|
|
|
type="file"
|
|
|
|
|
|
accept=".xlsx,.xls,.csv"
|
|
|
|
|
|
onChange={handleFileChange}
|
|
|
|
|
|
className="hidden"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2025-11-04 09:41:58 +09:00
|
|
|
|
</div>
|
2025-11-04 18:31:26 +09:00
|
|
|
|
|
2026-01-08 11:51:02 +09:00
|
|
|
|
{/* 파일이 선택된 경우에만 미리보기 표시 */}
|
|
|
|
|
|
{file && displayData.length > 0 && (
|
|
|
|
|
|
<>
|
2026-01-08 12:04:31 +09:00
|
|
|
|
{/* 시트 선택 */}
|
|
|
|
|
|
<div className="flex items-center gap-3">
|
2026-01-08 11:51:02 +09:00
|
|
|
|
<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>
|
2026-01-08 12:04:31 +09:00
|
|
|
|
<span className="text-xs text-muted-foreground">
|
|
|
|
|
|
{displayData.length}개 행 · 셀을 클릭하여 편집, Tab/Enter로 이동
|
|
|
|
|
|
</span>
|
2026-01-08 11:51:02 +09:00
|
|
|
|
</div>
|
2025-11-04 18:31:26 +09:00
|
|
|
|
|
2026-01-08 12:04:31 +09:00
|
|
|
|
{/* 엑셀처럼 편집 가능한 스프레드시트 */}
|
|
|
|
|
|
<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"
|
|
|
|
|
|
/>
|
2026-01-08 11:51:02 +09:00
|
|
|
|
</>
|
2025-11-04 18:31:26 +09:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-01-08 11:51:02 +09:00
|
|
|
|
{/* 2단계: 컬럼 매핑 */}
|
|
|
|
|
|
{currentStep === 2 && (
|
2025-12-17 15:00:15 +09:00
|
|
|
|
<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>
|
2025-11-04 18:31:26 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-12-17 15:00:15 +09:00
|
|
|
|
{/* 매핑 리스트 */}
|
2025-11-04 18:31:26 +09:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-01-08 11:51:02 +09:00
|
|
|
|
<div className="max-h-[350px] space-y-2 overflow-y-auto">
|
2025-11-04 18:31:26 +09:00
|
|
|
|
{columnMappings.map((mapping, index) => (
|
2026-01-08 11:51:02 +09:00
|
|
|
|
<div
|
|
|
|
|
|
key={index}
|
|
|
|
|
|
className="grid grid-cols-[1fr_auto_1fr] items-center gap-2"
|
|
|
|
|
|
>
|
2025-11-04 18:31:26 +09:00
|
|
|
|
<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">
|
2025-12-17 12:01:16 +09:00
|
|
|
|
<SelectValue placeholder="매핑 안함">
|
|
|
|
|
|
{mapping.systemColumn
|
|
|
|
|
|
? (() => {
|
2026-01-08 11:51:02 +09:00
|
|
|
|
const col = systemColumns.find(
|
|
|
|
|
|
(c) => c.name === mapping.systemColumn
|
|
|
|
|
|
);
|
2025-12-17 12:01:16 +09:00
|
|
|
|
return col?.label || mapping.systemColumn;
|
|
|
|
|
|
})()
|
|
|
|
|
|
: "매핑 안함"}
|
|
|
|
|
|
</SelectValue>
|
2025-11-04 18:31:26 +09:00
|
|
|
|
</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"
|
|
|
|
|
|
>
|
2025-12-17 12:01:16 +09:00
|
|
|
|
{col.label || col.name} ({col.type})
|
2025-11-04 18:31:26 +09:00
|
|
|
|
</SelectItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-01-08 11:45:39 +09:00
|
|
|
|
|
|
|
|
|
|
{/* 매핑 자동 저장 안내 */}
|
|
|
|
|
|
{isAutoMappingLoaded ? (
|
2026-01-08 11:51:02 +09:00
|
|
|
|
<div className="rounded-md border border-success bg-success/10 p-3">
|
2026-01-08 11:45:39 +09:00
|
|
|
|
<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">
|
|
|
|
|
|
동일한 엑셀 구조가 감지되어 이전에 저장된 매핑이 적용되었습니다.
|
2026-01-08 11:51:02 +09:00
|
|
|
|
수정하면 업로드 시 자동 저장됩니다.
|
2026-01-08 11:45:39 +09:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
2026-01-08 11:51:02 +09:00
|
|
|
|
<div className="rounded-md border border-muted bg-muted/30 p-3">
|
2026-01-08 11:45:39 +09:00
|
|
|
|
<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">
|
2026-01-08 11:51:02 +09:00
|
|
|
|
이 엑셀 구조는 처음입니다. 매핑을 설정하면 다음에 같은 구조의
|
|
|
|
|
|
엑셀에 자동 적용됩니다.
|
2026-01-08 11:45:39 +09:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-11-04 18:31:26 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-01-08 11:51:02 +09:00
|
|
|
|
{/* 3단계: 확인 */}
|
|
|
|
|
|
{currentStep === 3 && (
|
2025-11-04 18:31:26 +09:00
|
|
|
|
<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>
|
2025-12-17 15:00:15 +09:00
|
|
|
|
<span className="font-medium">데이터 행:</span> {allData.length}개
|
2025-11-04 18:31:26 +09:00
|
|
|
|
</p>
|
|
|
|
|
|
<p>
|
|
|
|
|
|
<span className="font-medium">테이블:</span> {tableName}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p>
|
|
|
|
|
|
<span className="font-medium">모드:</span>{" "}
|
|
|
|
|
|
{uploadMode === "insert"
|
2026-01-08 11:51:02 +09:00
|
|
|
|
? "신규 등록"
|
2025-11-04 18:31:26 +09:00
|
|
|
|
: 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)
|
2026-01-08 11:51:02 +09:00
|
|
|
|
.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>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
2025-11-04 18:31:26 +09:00
|
|
|
|
{columnMappings.filter((m) => m.systemColumn).length === 0 && (
|
|
|
|
|
|
<p className="text-destructive">매핑된 컬럼이 없습니다.</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2025-11-04 09:41:58 +09:00
|
|
|
|
</div>
|
2025-11-04 18:31:26 +09:00
|
|
|
|
|
|
|
|
|
|
<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">
|
2026-01-08 11:51:02 +09:00
|
|
|
|
업로드를 진행하면 데이터가 데이터베이스에 저장됩니다.
|
|
|
|
|
|
계속하시겠습니까?
|
2025-11-04 18:31:26 +09:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-11-04 09:41:58 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-12-05 10:46:10 +09:00
|
|
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
2025-11-04 09:41:58 +09:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
2025-11-04 18:31:26 +09:00
|
|
|
|
onClick={currentStep === 1 ? () => onOpenChange(false) : handlePrevious}
|
2025-11-04 09:41:58 +09:00
|
|
|
|
disabled={isUploading}
|
|
|
|
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
|
|
|
|
>
|
2025-11-04 18:31:26 +09:00
|
|
|
|
{currentStep === 1 ? "취소" : "이전"}
|
2025-11-04 09:41:58 +09:00
|
|
|
|
</Button>
|
2026-01-08 11:51:02 +09:00
|
|
|
|
{currentStep < 3 ? (
|
2025-11-04 18:31:26 +09:00
|
|
|
|
<Button
|
|
|
|
|
|
onClick={handleNext}
|
2026-01-08 11:51:02 +09:00
|
|
|
|
disabled={isUploading || (currentStep === 1 && !file)}
|
2025-11-04 18:31:26 +09:00
|
|
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
|
|
|
|
>
|
|
|
|
|
|
다음
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
onClick={handleUpload}
|
2026-01-08 11:51:02 +09:00
|
|
|
|
disabled={
|
|
|
|
|
|
isUploading || columnMappings.filter((m) => m.systemColumn).length === 0
|
|
|
|
|
|
}
|
2025-11-04 18:31:26 +09:00
|
|
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
|
|
|
|
>
|
2026-01-08 11:51:02 +09:00
|
|
|
|
{isUploading ? "업로드 중..." : "업로드"}
|
2025-11-04 18:31:26 +09:00
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
2025-12-05 10:46:10 +09:00
|
|
|
|
</DialogFooter>
|
|
|
|
|
|
</DialogContent>
|
|
|
|
|
|
</Dialog>
|
2025-11-04 09:41:58 +09:00
|
|
|
|
);
|
|
|
|
|
|
};
|