"use client"; /** * UPSERT 액션 노드 속성 편집 (개선 버전) */ 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 { UpsertActionNodeData } from "@/types/node-editor"; import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections"; interface UpsertActionPropertiesProps { nodeId: string; data: UpsertActionNodeData; } interface TableOption { tableName: string; displayName: string; description: string; label: string; } interface ColumnInfo { columnName: string; columnLabel?: string; dataType: string; isNullable: boolean; } export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesProps) { 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 [conflictKeys, setConflictKeys] = useState(data.conflictKeys || []); const [conflictKeyLabels, setConflictKeyLabels] = useState(data.conflictKeyLabels || []); const [fieldMappings, setFieldMappings] = useState(data.fieldMappings || []); const [batchSize, setBatchSize] = useState(data.options?.batchSize?.toString() || ""); const [updateOnConflict, setUpdateOnConflict] = useState(data.options?.updateOnConflict ?? true); // 🔥 외부 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 || "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 || ""); // 테이블 관련 상태 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>([]); // 데이터 변경 시 로컬 상태 업데이트 useEffect(() => { setDisplayName(data.displayName || data.targetTable); setTargetTable(data.targetTable); setConflictKeys(data.conflictKeys || []); setConflictKeyLabels(data.conflictKeyLabels || []); setFieldMappings(data.fieldMappings || []); setBatchSize(data.options?.batchSize?.toString() || ""); setUpdateOnConflict(data.options?.updateOnConflict ?? true); }, [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) => { // 데이터 변환 노드인 경우: 변환된 필드 + 상위 노드의 원본 필드 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 loadExternalConnections = async () => { try { setExternalConnectionsLoading(true); const cached = getExternalConnectionsCache(); if (cached) { setExternalConnections(cached); return; } const data = await getTestedExternalConnections(); setExternalConnections(data); } catch (error) { console.error("외부 커넥션 로딩 실패:", error); } finally { setExternalConnectionsLoading(false); } }; const loadExternalTables = async (connectionId: number) => { try { setExternalTablesLoading(true); const data = await getExternalTables(connectionId); setExternalTables(data); } catch (error) { console.error("외부 테이블 로딩 실패:", error); } finally { setExternalTablesLoading(false); } }; const loadExternalColumns = async (connectionId: number, tableName: string) => { try { setExternalColumnsLoading(true); const data = await getExternalColumns(connectionId, tableName); setExternalColumns(data); } catch (error) { console.error("외부 컬럼 로딩 실패:", error); } finally { setExternalColumnsLoading(false); } }; const handleTargetTypeChange = (newType: "internal" | "external" | "api") => { setTargetType(newType); updateNode(nodeId, { targetType: newType, targetTable: newType === "internal" ? targetTable : undefined, externalConnectionId: newType === "external" ? selectedExternalConnectionId : undefined, externalTargetTable: newType === "external" ? externalTargetTable : undefined, apiEndpoint: newType === "api" ? apiEndpoint : undefined, apiMethod: newType === "api" ? apiMethod : undefined, apiAuthType: newType === "api" ? apiAuthType : undefined, apiAuthConfig: newType === "api" ? apiAuthConfig : undefined, apiHeaders: newType === "api" ? apiHeaders : undefined, apiBodyTemplate: newType === "api" ? apiBodyTemplate : undefined, }); }; 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); } catch (error) { console.error("❌ UPSERT 노드 - 테이블 목록 로딩 실패:", error); } finally { setTablesLoading(false); } }; const loadColumns = async (tableName: string) => { try { setColumnsLoading(true); 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); } catch (error) { console.error("❌ UPSERT 노드 - 컬럼 목록 로딩 실패:", 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, conflictKeys, conflictKeyLabels, fieldMappings, options: { batchSize: batchSize ? parseInt(batchSize) : undefined, updateOnConflict, }, }); setTablesOpen(false); }; const handleAddConflictKey = (columnName: string) => { if (!conflictKeys.includes(columnName)) { const column = targetColumns.find((c) => c.columnName === columnName); const newConflictKeys = [...conflictKeys, columnName]; const newConflictKeyLabels = [...conflictKeyLabels, column?.columnLabel || columnName]; setConflictKeys(newConflictKeys); setConflictKeyLabels(newConflictKeyLabels); } }; const handleRemoveConflictKey = (index: number) => { const newKeys = conflictKeys.filter((_, i) => i !== index); const newLabels = conflictKeyLabels.filter((_, i) => i !== index); setConflictKeys(newKeys); setConflictKeyLabels(newLabels); // 즉시 반영 updateNode(nodeId, { displayName, targetTable, conflictKeys: newKeys, conflictKeyLabels: newLabels, fieldMappings, options: { batchSize: batchSize ? parseInt(batchSize) : undefined, updateOnConflict, }, }); }; 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, conflictKeys, conflictKeyLabels, fieldMappings: newMappings, options: { batchSize: batchSize ? parseInt(batchSize) : undefined, updateOnConflict, }, }); }; 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, conflictKeys, conflictKeyLabels, fieldMappings, options: { batchSize: batchSize ? parseInt(batchSize) : undefined, updateOnConflict, }, }); }; const selectedTableLabel = tables.find((t) => t.tableName === targetTable)?.label || targetTable; return (
{/* 기본 정보 */}

기본 정보

setDisplayName(e.target.value)} className="mt-1" placeholder="노드 표시 이름" />
{/* 🔥 타겟 타입 선택 */}
{/* 내부 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 설정 (INSERT 노드와 동일) */} {targetType === "api" && (
{ setApiEndpoint(e.target.value); updateNode(nodeId, { apiEndpoint: e.target.value }); }} className="h-8 text-xs" />
{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" />
))}