"use client"; /** * INSERT 액션 노드 속성 편집 (개선 버전) */ import { useEffect, useState } from "react"; import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight, Database, Globe, Link2 } from "lucide-react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Checkbox } from "@/components/ui/checkbox"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { cn } from "@/lib/utils"; import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; import { tableTypeApi } from "@/lib/api/screen"; import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections"; import type { InsertActionNodeData } from "@/types/node-editor"; import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections"; interface InsertActionPropertiesProps { nodeId: string; data: InsertActionNodeData; } interface TableOption { tableName: string; displayName: string; description: string; label: string; } interface ColumnInfo { columnName: string; columnLabel?: string; dataType: string; isNullable: boolean; } export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesProps) { const { updateNode, nodes, edges, getExternalConnectionsCache } = useFlowEditorStore(); // 🔥 타겟 타입 상태 const [targetType, setTargetType] = useState<"internal" | "external" | "api">(data.targetType || "internal"); const [displayName, setDisplayName] = useState(data.displayName || data.targetTable); const [targetTable, setTargetTable] = useState(data.targetTable); const [fieldMappings, setFieldMappings] = useState(data.fieldMappings || []); const [batchSize, setBatchSize] = useState(data.options?.batchSize?.toString() || ""); const [ignoreErrors, setIgnoreErrors] = useState(data.options?.ignoreErrors || false); const [ignoreDuplicates, setIgnoreDuplicates] = useState(data.options?.ignoreDuplicates || false); // 내부 DB 테이블 관련 상태 const [tables, setTables] = useState([]); const [tablesLoading, setTablesLoading] = useState(false); const [tablesOpen, setTablesOpen] = useState(false); // 컬럼 관련 상태 const [targetColumns, setTargetColumns] = useState([]); const [columnsLoading, setColumnsLoading] = useState(false); // 소스 필드 목록 (연결된 입력 노드에서 가져오기) const [sourceFields, setSourceFields] = useState>([]); // REST API 소스 노드 연결 여부 const [hasRestAPISource, setHasRestAPISource] = useState(false); // 🔥 외부 DB 관련 상태 const [externalConnections, setExternalConnections] = useState([]); const [externalConnectionsLoading, setExternalConnectionsLoading] = useState(false); const [selectedExternalConnectionId, setSelectedExternalConnectionId] = useState( data.externalConnectionId, ); const [externalTables, setExternalTables] = useState([]); const [externalTablesLoading, setExternalTablesLoading] = useState(false); const [externalTargetTable, setExternalTargetTable] = useState(data.externalTargetTable); const [externalColumns, setExternalColumns] = useState([]); const [externalColumnsLoading, setExternalColumnsLoading] = useState(false); // 🔥 REST API 관련 상태 const [apiEndpoint, setApiEndpoint] = useState(data.apiEndpoint || ""); const [apiMethod, setApiMethod] = useState<"POST" | "PUT" | "PATCH">(data.apiMethod || "POST"); const [apiAuthType, setApiAuthType] = useState<"none" | "basic" | "bearer" | "apikey">(data.apiAuthType || "none"); const [apiAuthConfig, setApiAuthConfig] = useState(data.apiAuthConfig || {}); const [apiHeaders, setApiHeaders] = useState>(data.apiHeaders || {}); const [apiBodyTemplate, setApiBodyTemplate] = useState(data.apiBodyTemplate || ""); // 데이터 변경 시 로컬 상태 업데이트 useEffect(() => { setDisplayName(data.displayName || data.targetTable); setTargetTable(data.targetTable); setFieldMappings(data.fieldMappings || []); setBatchSize(data.options?.batchSize?.toString() || ""); setIgnoreErrors(data.options?.ignoreErrors || false); setIgnoreDuplicates(data.options?.ignoreDuplicates || false); }, [data]); // 내부 DB 테이블 목록 로딩 useEffect(() => { if (targetType === "internal") { loadTables(); } }, [targetType]); // 타겟 테이블 변경 시 컬럼 로딩 (내부 DB) useEffect(() => { if (targetType === "internal" && targetTable) { loadColumns(targetTable); } }, [targetType, targetTable]); // 🔥 외부 커넥션 로드 (캐시 우선) useEffect(() => { if (targetType === "external") { loadExternalConnections(); } }, [targetType]); // 🔥 외부 커넥션 변경 시 테이블 로드 useEffect(() => { if (targetType === "external" && selectedExternalConnectionId) { loadExternalTables(selectedExternalConnectionId); } }, [targetType, selectedExternalConnectionId]); // 🔥 외부 테이블 변경 시 컬럼 로드 useEffect(() => { if (targetType === "external" && selectedExternalConnectionId && externalTargetTable) { loadExternalColumns(selectedExternalConnectionId, externalTargetTable); } }, [targetType, selectedExternalConnectionId, externalTargetTable]); // 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색) useEffect(() => { const getAllSourceFields = ( targetNodeId: string, visitedNodes = new Set(), sourcePath: string[] = [], // 🔥 소스 경로 추적 ): { fields: Array<{ name: string; label?: string; sourcePath?: string[] }>; hasRestAPI: boolean } => { if (visitedNodes.has(targetNodeId)) { console.log(`⚠️ 순환 참조 감지: ${targetNodeId} (이미 방문함)`); return { fields: [], hasRestAPI: false }; } visitedNodes.add(targetNodeId); const inputEdges = edges.filter((edge) => edge.target === targetNodeId); const sourceNodeIds = inputEdges.map((edge) => edge.source); const sourceNodes = nodes.filter((node) => sourceNodeIds.includes(node.id)); // 🔥 다중 소스 감지 if (sourceNodes.length > 1) { console.log(`⚠️ 다중 소스 감지: ${sourceNodes.length}개 노드 연결됨`); console.log(" 소스 노드들:", sourceNodes.map((n) => `${n.id}(${n.type})`).join(", ")); } const fields: Array<{ name: string; label?: string; sourcePath?: string[] }> = []; let foundRestAPI = false; sourceNodes.forEach((node) => { console.log(`🔍 노드 ${node.id} 타입: ${node.type}`); // 🔥 현재 노드를 경로에 추가 const currentPath = [...sourcePath, `${node.id}(${node.type})`]; // 1️⃣ 데이터 변환 노드: 변환된 필드 + 상위 노드의 원본 필드 if (node.type === "dataTransform") { console.log("✅ 데이터 변환 노드 발견"); // 상위 노드의 원본 필드 먼저 수집 const upperResult = getAllSourceFields(node.id, visitedNodes, currentPath); const upperFields = upperResult.fields; foundRestAPI = foundRestAPI || upperResult.hasRestAPI; console.log(` 📤 상위 노드에서 ${upperFields.length}개 필드 가져옴`); // 변환된 필드 추가 (in-place 변환 고려) if ((node.data as any).transformations && Array.isArray((node.data as any).transformations)) { console.log(` 📊 ${(node.data as any).transformations.length}개 변환 발견`); const inPlaceFields = new Set(); (node.data as any).transformations.forEach((transform: any) => { const targetField = transform.targetField || transform.sourceField; const isInPlace = !transform.targetField || transform.targetField === transform.sourceField; console.log(` 🔹 변환: ${transform.sourceField} → ${targetField} ${isInPlace ? "(in-place)" : ""}`); if (isInPlace) { inPlaceFields.add(transform.sourceField); } else if (targetField) { fields.push({ name: targetField, label: transform.targetFieldLabel || targetField, sourcePath: currentPath, }); } }); // 상위 필드 추가 upperFields.forEach((field) => { if (!inPlaceFields.has(field.name)) { fields.push(field); } else { fields.push(field); } }); } else { fields.push(...upperFields); } } // 2️⃣ REST API 소스 노드 else if (node.type === "restAPISource") { console.log("✅ REST API 소스 노드 발견"); foundRestAPI = true; const responseFields = (node.data as any).responseFields; if (responseFields && Array.isArray(responseFields)) { console.log(`✅ REST API 노드에서 ${responseFields.length}개 필드 발견`); responseFields.forEach((field: any) => { const fieldName = field.name || field.fieldName; const fieldLabel = field.label || field.displayName; if (fieldName) { fields.push({ name: fieldName, label: fieldLabel, sourcePath: currentPath, }); } }); } else { console.log("⚠️ REST API 노드에 responseFields 없음"); } } // 3️⃣ 테이블/외부DB 소스 노드 else if (node.type === "tableSource" || node.type === "externalDBSource") { const nodeFields = (node.data as any).fields || (node.data as any).outputFields; const displayName = (node.data as any).displayName || (node.data as any).tableName || node.id; if (nodeFields && Array.isArray(nodeFields)) { console.log(`✅ ${node.type}[${displayName}] 노드에서 ${nodeFields.length}개 필드 발견`); nodeFields.forEach((field: any) => { const fieldName = field.name || field.fieldName || field.column_name; const fieldLabel = field.label || field.displayName || field.label_ko; if (fieldName) { // 🔥 다중 소스인 경우 필드명에 소스 표시 const displayLabel = sourceNodes.length > 1 ? `${fieldLabel || fieldName} [${displayName}]` : fieldLabel || fieldName; fields.push({ name: fieldName, label: displayLabel, sourcePath: currentPath, }); } }); } else { console.log(`⚠️ ${node.type} 노드에 필드 정의 없음 → 상위 노드 탐색`); // 필드가 없으면 상위 노드로 계속 탐색 const upperResult = getAllSourceFields(node.id, visitedNodes, currentPath); fields.push(...upperResult.fields); foundRestAPI = foundRestAPI || upperResult.hasRestAPI; } } // 4️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색 else { console.log(`✅ 통과 노드 (${node.type}) → 상위 노드로 계속 탐색`); const upperResult = getAllSourceFields(node.id, visitedNodes, currentPath); fields.push(...upperResult.fields); foundRestAPI = foundRestAPI || upperResult.hasRestAPI; console.log(` 📤 상위 노드에서 ${upperResult.fields.length}개 필드 가져옴`); } }); return { fields, hasRestAPI: foundRestAPI }; }; console.log("🔍 INSERT 노드 ID:", nodeId); const result = getAllSourceFields(nodeId); console.log("📊 필드 수집 완료:"); console.log(` - 총 필드 수: ${result.fields.length}개`); console.log(` - REST API 포함: ${result.hasRestAPI}`); // 🔥 중복 제거 개선: 필드명이 같아도 소스가 다르면 모두 표시 const fieldMap = new Map(); const duplicateFields = new Set(); result.fields.forEach((field) => { const key = `${field.name}`; if (fieldMap.has(key)) { duplicateFields.add(field.name); } // 중복이면 마지막 값으로 덮어씀 (기존 동작 유지) fieldMap.set(key, field); }); if (duplicateFields.size > 0) { console.warn(`⚠️ 중복 필드명 감지: ${Array.from(duplicateFields).join(", ")}`); console.warn(" → 마지막으로 발견된 필드만 표시됩니다."); console.warn(" → 다중 소스 사용 시 필드명이 겹치지 않도록 주의하세요!"); } const uniqueFields = Array.from(fieldMap.values()); setSourceFields(uniqueFields); setHasRestAPISource(result.hasRestAPI); console.log("✅ 최종 소스 필드 목록:", uniqueFields); console.log("✅ REST API 소스 연결:", result.hasRestAPI); }, [nodeId, nodes, edges]); /** * 테이블 목록 로드 */ const loadTables = async () => { try { setTablesLoading(true); const tableList = await tableTypeApi.getTables(); const options: TableOption[] = tableList.map((table) => { const label = (table as any).tableLabel || table.displayName || table.tableName || "알 수 없는 테이블"; return { tableName: table.tableName, displayName: table.displayName || table.tableName, description: table.description || "", label, }; }); setTables(options); console.log(`✅ 테이블 ${options.length}개 로딩 완료`); } catch (error) { console.error("❌ 테이블 목록 로딩 실패:", error); setTables([]); } finally { setTablesLoading(false); } }; /** * 타겟 테이블의 컬럼 목록 로드 */ const loadColumns = async (tableName: string) => { try { setColumnsLoading(true); console.log(`🔍 컬럼 조회 중: ${tableName}`); const columns = await tableTypeApi.getColumns(tableName); const columnInfo: ColumnInfo[] = columns.map((col: any) => ({ columnName: col.column_name || col.columnName, columnLabel: col.label_ko || col.columnLabel, dataType: col.data_type || col.dataType || "unknown", isNullable: col.is_nullable === "YES" || col.isNullable === true, })); setTargetColumns(columnInfo); console.log(`✅ 컬럼 ${columnInfo.length}개 로딩 완료`); } catch (error) { console.error("❌ 컬럼 목록 로딩 실패:", error); setTargetColumns([]); } finally { setColumnsLoading(false); } }; // 🔥 외부 커넥션 로드 (캐시 우선) const loadExternalConnections = async () => { try { // 캐시 확인 const cachedData = getExternalConnectionsCache(); if (cachedData) { console.log("✅ 캐시된 외부 커넥션 사용:", cachedData.length); setExternalConnections(cachedData); return; } setExternalConnectionsLoading(true); console.log("🔍 외부 커넥션 조회 중..."); const connections = await getTestedExternalConnections(); setExternalConnections(connections); console.log(`✅ 외부 커넥션 ${connections.length}개 로딩 완료`); } catch (error) { console.error("❌ 외부 커넥션 로딩 실패:", error); setExternalConnections([]); } finally { setExternalConnectionsLoading(false); } }; // 🔥 외부 테이블 로드 const loadExternalTables = async (connectionId: number) => { try { setExternalTablesLoading(true); console.log(`🔍 외부 테이블 조회 중: connection ${connectionId}`); const tables = await getExternalTables(connectionId); setExternalTables(tables); console.log(`✅ 외부 테이블 ${tables.length}개 로딩 완료`); } catch (error) { console.error("❌ 외부 테이블 로딩 실패:", error); setExternalTables([]); } finally { setExternalTablesLoading(false); } }; // 🔥 외부 컬럼 로드 const loadExternalColumns = async (connectionId: number, tableName: string) => { try { setExternalColumnsLoading(true); console.log(`🔍 외부 컬럼 조회 중: ${tableName}`); const columns = await getExternalColumns(connectionId, tableName); setExternalColumns(columns); console.log(`✅ 외부 컬럼 ${columns.length}개 로딩 완료`); } catch (error) { console.error("❌ 외부 컬럼 로딩 실패:", error); setExternalColumns([]); } finally { setExternalColumnsLoading(false); } }; /** * 테이블 선택 핸들러 */ const handleTableSelect = (selectedTableName: string) => { const selectedTable = tables.find((t) => t.tableName === selectedTableName); if (selectedTable) { setTargetTable(selectedTable.tableName); if (!displayName || displayName === targetTable) { setDisplayName(selectedTable.label); } // 즉시 노드 업데이트 updateNode(nodeId, { displayName: selectedTable.label, targetTable: selectedTable.tableName, fieldMappings, options: { batchSize: batchSize ? parseInt(batchSize) : undefined, ignoreErrors, ignoreDuplicates, }, }); setTablesOpen(false); } }; const handleAddMapping = () => { const newMappings = [ ...fieldMappings, { sourceField: null, targetField: "", staticValue: undefined, }, ]; setFieldMappings(newMappings); updateNode(nodeId, { fieldMappings: newMappings }); }; const handleRemoveMapping = (index: number) => { const newMappings = fieldMappings.filter((_, i) => i !== index); setFieldMappings(newMappings); updateNode(nodeId, { fieldMappings: newMappings }); }; const handleMappingChange = (index: number, field: string, value: any) => { const newMappings = [...fieldMappings]; // 필드 변경 시 라벨도 함께 저장 if (field === "sourceField") { const sourceField = sourceFields.find((f) => f.name === value); newMappings[index] = { ...newMappings[index], sourceField: value, sourceFieldLabel: sourceField?.label, }; } else if (field === "targetField") { const targetColumn = (() => { if (targetType === "internal") { return targetColumns.find((col) => col.column_name === value); } else if (targetType === "external") { return externalColumns.find((col) => col.column_name === value); } return null; })(); newMappings[index] = { ...newMappings[index], targetField: value, targetFieldLabel: targetColumn?.label_ko || targetColumn?.column_label || targetColumn?.displayName || value, }; } else { newMappings[index] = { ...newMappings[index], [field]: value, }; } setFieldMappings(newMappings); updateNode(nodeId, { fieldMappings: newMappings }); }; // 즉시 반영 핸들러들 const handleDisplayNameChange = (newDisplayName: string) => { setDisplayName(newDisplayName); updateNode(nodeId, { displayName: newDisplayName }); }; const handleFieldMappingsChange = (newMappings: any[]) => { setFieldMappings(newMappings); updateNode(nodeId, { fieldMappings: newMappings }); }; const handleBatchSizeChange = (newBatchSize: string) => { setBatchSize(newBatchSize); updateNode(nodeId, { options: { batchSize: newBatchSize ? parseInt(newBatchSize) : undefined, ignoreErrors, ignoreDuplicates, }, }); }; const handleIgnoreErrorsChange = (checked: boolean) => { setIgnoreErrors(checked); updateNode(nodeId, { options: { batchSize: batchSize ? parseInt(batchSize) : undefined, ignoreErrors: checked, ignoreDuplicates, }, }); }; const handleIgnoreDuplicatesChange = (checked: boolean) => { setIgnoreDuplicates(checked); updateNode(nodeId, { options: { batchSize: batchSize ? parseInt(batchSize) : undefined, ignoreErrors, ignoreDuplicates: checked, }, }); }; const selectedTableLabel = tables.find((t) => t.tableName === targetTable)?.label || targetTable; // 🔥 타겟 타입 변경 핸들러 const handleTargetTypeChange = (newType: "internal" | "external" | "api") => { setTargetType(newType); // 타입 변경 시 관련 필드 초기화 const updates: any = { targetType: newType, displayName, }; // 이전 타입의 데이터 유지 if (newType === "internal") { updates.targetTable = targetTable; updates.targetTableLabel = data.targetTableLabel; } else if (newType === "external") { updates.externalConnectionId = data.externalConnectionId; updates.externalTargetTable = data.externalTargetTable; } else if (newType === "api") { updates.apiEndpoint = data.apiEndpoint; updates.apiMethod = data.apiMethod || "POST"; } updates.fieldMappings = fieldMappings; updates.options = { batchSize: batchSize ? parseInt(batchSize) : undefined, ignoreErrors, ignoreDuplicates, }; updateNode(nodeId, updates); }; return (
{/* 🔥 타겟 타입 선택 */}
{/* 기본 정보 */}

기본 정보

setDisplayName(e.target.value)} className="mt-1" placeholder="노드 표시 이름" />
{/* 🔥 타겟 타입에 따른 조건부 렌더링 */} {targetType === "internal" && ( <> {/* 타겟 테이블 Combobox */}
검색 결과가 없습니다. {tables.map((table) => ( handleTableSelect(table.tableName)} className="cursor-pointer" >
{table.label} {table.label !== table.tableName && ( {table.tableName} )} {table.description && ( {table.description} )}
))}
{targetTable && selectedTableLabel !== targetTable && (

실제 테이블명: {targetTable}

)}
)} {/* 🔥 외부 DB 타입 UI */} {targetType === "external" && ( <> {/* 외부 커넥션 선택 */}
{externalConnectionsLoading &&

로딩 중...

} {externalConnections.length === 0 && !externalConnectionsLoading && (

⚠️ 테스트에 성공한 외부 커넥션이 없습니다.

)}
{/* 외부 테이블 선택 */} {selectedExternalConnectionId && (
{externalTablesLoading &&

로딩 중...

}
)} {/* 외부 컬럼 정보 표시 */} {selectedExternalConnectionId && externalTargetTable && externalColumns.length > 0 && (

타겟 컬럼 ({externalColumns.length}개)

{externalColumns.map((col) => (
{col.column_name} {col.data_type}
))}
)} )} {/* 🔥 REST API 타입 UI (추후 구현) */} {targetType === "api" && (
{/* API 엔드포인트 */}
{ setApiEndpoint(e.target.value); updateNode(nodeId, { apiEndpoint: e.target.value }); }} className="h-8 text-xs" />
{/* HTTP 메서드 */}
{/* 인증 타입 */}
{/* 인증 설정 */} {apiAuthType !== "none" && (
{apiAuthType === "bearer" && ( { const newConfig = { token: e.target.value }; setApiAuthConfig(newConfig); updateNode(nodeId, { apiAuthConfig: newConfig }); }} className="h-8 text-xs" /> )} {apiAuthType === "basic" && (
{ const newConfig = { ...(apiAuthConfig as any), username: e.target.value }; setApiAuthConfig(newConfig); updateNode(nodeId, { apiAuthConfig: newConfig }); }} className="h-8 text-xs" /> { const newConfig = { ...(apiAuthConfig as any), password: e.target.value }; setApiAuthConfig(newConfig); updateNode(nodeId, { apiAuthConfig: newConfig }); }} className="h-8 text-xs" />
)} {apiAuthType === "apikey" && (
{ const newConfig = { ...(apiAuthConfig as any), headerName: e.target.value }; setApiAuthConfig(newConfig); updateNode(nodeId, { apiAuthConfig: newConfig }); }} className="h-8 text-xs" /> { const newConfig = { ...(apiAuthConfig as any), apiKey: e.target.value }; setApiAuthConfig(newConfig); updateNode(nodeId, { apiAuthConfig: newConfig }); }} className="h-8 text-xs" />
)}
)} {/* 커스텀 헤더 */}
{Object.entries(apiHeaders).map(([key, value], index) => (
{ const newHeaders = { ...apiHeaders }; delete newHeaders[key]; newHeaders[e.target.value] = value; setApiHeaders(newHeaders); updateNode(nodeId, { apiHeaders: newHeaders }); }} className="h-7 flex-1 text-xs" /> { const newHeaders = { ...apiHeaders, [key]: e.target.value }; setApiHeaders(newHeaders); updateNode(nodeId, { apiHeaders: newHeaders }); }} className="h-7 flex-1 text-xs" />
))}
{/* 요청 바디 설정 */}