"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>([]); // 🔥 외부 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(), ): Array<{ name: string; label?: string }> => { if (visitedNodes.has(targetNodeId)) { return []; } 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)); const fields: Array<{ name: string; label?: string }> = []; sourceNodes.forEach((node) => { console.log(`🔍 노드 ${node.id} 타입: ${node.type}`); console.log(`🔍 노드 ${node.id} 데이터:`, node.data); // 데이터 변환 노드인 경우: 변환된 필드 + 상위 노드의 원본 필드 if (node.type === "dataTransform") { console.log(`✅ 데이터 변환 노드 발견`); // 상위 노드의 원본 필드 먼저 수집 const upperFields = getAllSourceFields(node.id, visitedNodes); console.log(` 📤 상위 노드에서 ${upperFields.length}개 필드 가져옴`); // 변환된 필드 추가 (in-place 변환 고려) if (node.data.transformations && Array.isArray(node.data.transformations)) { console.log(` 📊 ${node.data.transformations.length}개 변환 발견`); const inPlaceFields = new Set(); // in-place 변환된 필드 추적 node.data.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) { // in-place: 원본 필드를 덮어쓰므로, 원본 필드는 이미 upperFields에 있음 inPlaceFields.add(transform.sourceField); } else if (targetField) { // 새 필드 생성 fields.push({ name: targetField, label: transform.targetFieldLabel || targetField, }); } }); // 상위 필드 중 in-place 변환되지 않은 것만 추가 upperFields.forEach((field) => { if (!inPlaceFields.has(field.name)) { fields.push(field); } else { // in-place 변환된 필드도 추가 (변환 후 값) fields.push(field); } }); } else { // 변환이 없으면 상위 필드만 추가 fields.push(...upperFields); } } // 일반 소스 노드인 경우 else { const nodeFields = node.data.fields || node.data.outputFields; if (nodeFields && Array.isArray(nodeFields)) { console.log(`✅ 노드 ${node.id}에서 ${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) { fields.push({ name: fieldName, label: fieldLabel, }); } }); } else { console.log(`❌ 노드 ${node.id}에 fields 없음`); } } }); return fields; }; console.log("🔍 INSERT 노드 ID:", nodeId); const allFields = getAllSourceFields(nodeId); // 중복 제거 const uniqueFields = Array.from(new Map(allFields.map((field) => [field.name, field])).values()); setSourceFields(uniqueFields); console.log("✅ 최종 소스 필드 목록:", uniqueFields); }, [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 = () => { setFieldMappings([ ...fieldMappings, { sourceField: null, targetField: "", staticValue: undefined, }, ]); }; const handleRemoveMapping = (index: number) => { const newMappings = fieldMappings.filter((_, i) => i !== index); setFieldMappings(newMappings); // 즉시 반영 updateNode(nodeId, { displayName, targetTable, fieldMappings: newMappings, options: { batchSize: batchSize ? parseInt(batchSize) : undefined, ignoreErrors, ignoreDuplicates, }, }); }; 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 = targetColumns.find((c) => c.columnName === value); newMappings[index] = { ...newMappings[index], targetField: value, targetFieldLabel: targetColumn?.columnLabel, }; } else { newMappings[index] = { ...newMappings[index], [field]: value }; } setFieldMappings(newMappings); }; const handleSave = () => { updateNode(nodeId, { displayName, targetTable, fieldMappings, options: { batchSize: batchSize ? parseInt(batchSize) : undefined, ignoreErrors, ignoreDuplicates, }, }); }; 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" />
))}
{/* 요청 바디 설정 */}