"use client"; /** * UPDATE 액션 노드 속성 편집 (개선 버전) */ 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 { UpdateActionNodeData } from "@/types/node-editor"; import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections"; interface UpdateActionPropertiesProps { nodeId: string; data: UpdateActionNodeData; } interface TableOption { tableName: string; displayName: string; description: string; label: string; } interface ColumnInfo { columnName: string; columnLabel?: string; dataType: string; isNullable: boolean; } const OPERATORS = [ { value: "EQUALS", label: "=" }, { value: "NOT_EQUALS", label: "≠" }, { value: "GREATER_THAN", label: ">" }, { value: "LESS_THAN", label: "<" }, { value: "GREATER_THAN_OR_EQUAL", label: "≥" }, { value: "LESS_THAN_OR_EQUAL", label: "≤" }, { value: "LIKE", label: "LIKE" }, { value: "NOT_LIKE", label: "NOT LIKE" }, { value: "IN", label: "IN" }, { value: "NOT_IN", label: "NOT IN" }, { value: "IS_NULL", label: "IS NULL" }, { value: "IS_NOT_NULL", label: "IS NOT NULL" }, ] as const; export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesProps) { 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 [whereConditions, setWhereConditions] = useState(data.whereConditions || []); const [batchSize, setBatchSize] = useState(data.options?.batchSize?.toString() || ""); const [ignoreErrors, setIgnoreErrors] = useState(data.options?.ignoreErrors || 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<"PUT" | "PATCH">(data.apiMethod || "PUT"); 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 || []); setWhereConditions(data.whereConditions || []); setBatchSize(data.options?.batchSize?.toString() || ""); setIgnoreErrors(data.options?.ignoreErrors || false); }, [data]); // 내부 DB 테이블 목록 로딩 useEffect(() => { if (targetType === "internal") { loadTables(); } }, [targetType]); // 타겟 테이블 변경 시 컬럼 로딩 (내부 DB) useEffect(() => { if (targetType === "internal" && targetTable) { loadColumns(targetTable); } }, [targetType, targetTable]); // 🔥 외부 DB: 커넥션 목록 로딩 useEffect(() => { if (targetType === "external") { loadExternalConnections(); } }, [targetType]); // 🔥 외부 DB: 테이블 목록 로딩 useEffect(() => { if (targetType === "external" && selectedExternalConnectionId) { loadExternalTables(selectedExternalConnectionId); } }, [targetType, selectedExternalConnectionId]); // 🔥 외부 DB: 컬럼 목록 로딩 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) => { // 데이터 변환 노드인 경우: 변환된 필드 + 상위 노드의 원본 필드 if (node.type === "dataTransform") { // 상위 노드의 원본 필드 먼저 수집 const upperFields = getAllSourceFields(node.id, visitedNodes); // 변환된 필드 추가 (in-place 변환 고려) if (node.data.transformations && Array.isArray(node.data.transformations)) { const inPlaceFields = new Set(); node.data.transformations.forEach((transform: any) => { const targetField = transform.targetField || transform.sourceField; const isInPlace = !transform.targetField || transform.targetField === transform.sourceField; if (isInPlace) { inPlaceFields.add(transform.sourceField); } else if (targetField) { fields.push({ name: targetField, label: transform.targetFieldLabel || targetField, }); } }); // 상위 필드 추가 (모두 포함, in-place는 변환 후 값) upperFields.forEach((field) => { fields.push(field); }); } else { fields.push(...upperFields); } } // 일반 소스 노드인 경우 else if (node.type === "tableSource" && node.data.fields) { node.data.fields.forEach((field: any) => { fields.push({ name: field.name, label: field.label || field.displayName, }); }); } else if (node.type === "externalDBSource" && node.data.fields) { node.data.fields.forEach((field: any) => { fields.push({ name: field.name, label: field.label || field.displayName, }); }); } }); return fields; }; const allFields = getAllSourceFields(nodeId); // 중복 제거 const uniqueFields = Array.from(new Map(allFields.map((field) => [field.name, field])).values()); setSourceFields(uniqueFields); }, [nodeId, nodes, edges]); const loadTables = async () => { try { setTablesLoading(true); const tableList = await tableTypeApi.getTables(); console.log("🔍 UPDATE 노드 - 테이블 목록:", tableList); 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, }; }); console.log("✅ UPDATE 노드 - 테이블 옵션:", options); setTables(options); } catch (error) { console.error("❌ UPDATE 노드 - 테이블 목록 로딩 실패:", error); } finally { setTablesLoading(false); } }; // 🔥 외부 DB 커넥션 목록 로딩 const loadExternalConnections = async () => { try { setExternalConnectionsLoading(true); // 캐시 확인 const cached = getExternalConnectionsCache(); if (cached) { setExternalConnections(cached); setExternalConnectionsLoading(false); return; } const connections = await getTestedExternalConnections(); setExternalConnections(connections); } catch (error) { console.error("외부 커넥션 목록 로딩 실패:", error); } finally { setExternalConnectionsLoading(false); } }; // 🔥 외부 DB 테이블 목록 로딩 const loadExternalTables = async (connectionId: number) => { try { setExternalTablesLoading(true); const tables = await getExternalTables(connectionId); setExternalTables(tables); } catch (error) { console.error("외부 테이블 목록 로딩 실패:", error); } finally { setExternalTablesLoading(false); } }; // 🔥 외부 DB 컬럼 목록 로딩 const loadExternalColumns = async (connectionId: number, tableName: string) => { try { setExternalColumnsLoading(true); const columns = await getExternalColumns(connectionId, tableName); setExternalColumns(columns); } catch (error) { console.error("외부 컬럼 목록 로딩 실패:", error); } finally { setExternalColumnsLoading(false); } }; const loadColumns = async (tableName: string) => { try { setColumnsLoading(true); console.log(`🔍 UPDATE 노드 - 컬럼 조회 중: ${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(`✅ UPDATE 노드 - 컬럼 ${columnInfo.length}개 로딩 완료`); } catch (error) { console.error("❌ UPDATE 노드 - 컬럼 목록 로딩 실패:", error); setTargetColumns([]); } finally { setColumnsLoading(false); } }; const handleTableSelect = async (newTableName: string) => { const selectedTable = tables.find((t) => t.tableName === newTableName); const newDisplayName = selectedTable?.label || selectedTable?.displayName || newTableName; setTargetTable(newTableName); setDisplayName(newDisplayName); await loadColumns(newTableName); // 즉시 반영 updateNode(nodeId, { displayName: newDisplayName, targetTable: newTableName, fieldMappings, whereConditions, options: { batchSize: batchSize ? parseInt(batchSize) : undefined, ignoreErrors, }, }); 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, whereConditions, options: { batchSize: batchSize ? parseInt(batchSize) : undefined, ignoreErrors, }, }); }; 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 handleTargetTypeChange = (newType: "internal" | "external" | "api") => { setTargetType(newType); updateNode(nodeId, { targetType: newType, ...(newType === "internal" && { targetTable: data.targetTable, targetConnection: data.targetConnection, displayName: data.displayName, }), ...(newType === "external" && { externalConnectionId: data.externalConnectionId, externalConnectionName: data.externalConnectionName, externalDbType: data.externalDbType, externalTargetTable: data.externalTargetTable, externalTargetSchema: data.externalTargetSchema, }), ...(newType === "api" && { apiEndpoint: data.apiEndpoint, apiMethod: data.apiMethod, apiAuthType: data.apiAuthType, apiAuthConfig: data.apiAuthConfig, apiHeaders: data.apiHeaders, apiBodyTemplate: data.apiBodyTemplate, }), }); }; const handleAddCondition = () => { setWhereConditions([ ...whereConditions, { field: "", operator: "EQUALS", staticValue: "", }, ]); }; const handleRemoveCondition = (index: number) => { const newConditions = whereConditions.filter((_, i) => i !== index); setWhereConditions(newConditions); // 즉시 반영 updateNode(nodeId, { displayName, targetTable, fieldMappings, whereConditions: newConditions, options: { batchSize: batchSize ? parseInt(batchSize) : undefined, ignoreErrors, }, }); }; const handleConditionChange = (index: number, field: string, value: any) => { const newConditions = [...whereConditions]; // 필드 변경 시 라벨도 함께 저장 if (field === "field") { const targetColumn = targetColumns.find((c) => c.columnName === value); newConditions[index] = { ...newConditions[index], field: value, fieldLabel: targetColumn?.columnLabel, }; } else if (field === "sourceField") { const sourceField = sourceFields.find((f) => f.name === value); newConditions[index] = { ...newConditions[index], sourceField: value, sourceFieldLabel: sourceField?.label, }; } else { newConditions[index] = { ...newConditions[index], [field]: value }; } setWhereConditions(newConditions); }; const handleSave = () => { updateNode(nodeId, { displayName, targetTable, fieldMappings, whereConditions, options: { batchSize: batchSize ? parseInt(batchSize) : undefined, ignoreErrors, }, }); }; const selectedTableLabel = tables.find((t) => t.tableName === targetTable)?.label || targetTable; return (
{/* 기본 정보 */}

기본 정보

setDisplayName(e.target.value)} className="mt-1" placeholder="노드 표시 이름" />
{/* 🔥 타겟 타입 선택 */}
{/* 내부 데이터베이스 */} {/* 외부 데이터베이스 */} {/* REST API */}
{/* 내부 DB: 타겟 테이블 Combobox */} {targetType === "internal" && (
테이블을 찾을 수 없습니다. {tables.map((table) => ( handleTableSelect(table.tableName)} >
{table.label || table.displayName} {table.tableName}
))}
)} {/* 🔥 외부 DB 설정 (INSERT 노드와 동일 패턴) */} {targetType === "external" && ( <> {/* 외부 커넥션 선택 */}
{/* 외부 테이블 선택 */} {selectedExternalConnectionId && (
)} {/* 외부 컬럼 표시 */} {externalTargetTable && externalColumns.length > 0 && (
{externalColumns.map((col) => (
{col.column_name} {col.data_type}
))}
)} )} {/* 🔥 REST API 설정 */} {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" />
))}
{/* 요청 바디 설정 */}