From d18e78e8a0336ec7fde8f654be7ab04082d0f99a Mon Sep 17 00:00:00 2001 From: hyeonsu Date: Tue, 16 Sep 2025 12:37:57 +0900 Subject: [PATCH 01/10] =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=EC=A1=B0=EA=B1=B4?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dataflow/edit/[diagramId]/page.tsx | 2 +- .../dataflow/ConnectionSetupModal.tsx | 835 ++++++++++-------- .../components/dataflow/DataFlowDesigner.tsx | 282 +++++- frontend/lib/api/dataflow.ts | 81 +- 4 files changed, 776 insertions(+), 424 deletions(-) diff --git a/frontend/app/(main)/admin/dataflow/edit/[diagramId]/page.tsx b/frontend/app/(main)/admin/dataflow/edit/[diagramId]/page.tsx index ede20c29..fd25cb96 100644 --- a/frontend/app/(main)/admin/dataflow/edit/[diagramId]/page.tsx +++ b/frontend/app/(main)/admin/dataflow/edit/[diagramId]/page.tsx @@ -6,7 +6,6 @@ import { ArrowLeft } from "lucide-react"; import { Button } from "@/components/ui/button"; import { DataFlowDesigner } from "@/components/dataflow/DataFlowDesigner"; import { DataFlowAPI } from "@/lib/api/dataflow"; -import { toast } from "sonner"; export default function DataFlowEditPage() { const params = useParams(); @@ -80,6 +79,7 @@ export default function DataFlowEditPage() { {/* 데이터플로우 디자이너 */}
; @@ -170,23 +170,92 @@ export const ConnectionSetupModal: React.FC = ({ // 기존 관계 정보가 있으면 사용, 없으면 기본값 설정 const existingRel = connection.existingRelationship; + const connectionType = + (existingRel?.connectionType as "simple-key" | "data-save" | "external-call") || "simple-key"; + setConfig({ relationshipName: existingRel?.relationshipName || `${fromDisplayName} → ${toDisplayName}`, - connectionType: (existingRel?.connectionType as "simple-key" | "data-save" | "external-call") || "simple-key", + connectionType, fromColumnName: "", toColumnName: "", settings: existingRel?.settings || {}, }); - // 단순 키값 연결 기본값 설정 - setSimpleKeySettings({ - notes: `${fromDisplayName}과 ${toDisplayName} 간의 키값 연결`, - }); + // 🔥 기존 설정 데이터 로드 + if (existingRel?.settings) { + const settings = existingRel.settings; - // 데이터 저장 기본값 설정 (빈 배열로 시작) - setDataSaveSettings({ - actions: [], - }); + if (connectionType === "simple-key" && settings.notes) { + setSimpleKeySettings({ + notes: settings.notes as string, + }); + } else if (connectionType === "data-save" && settings.actions) { + // data-save 설정 로드 - 안전하게 처리 + const actionsData = Array.isArray(settings.actions) ? settings.actions : []; + setDataSaveSettings({ + actions: actionsData.map((action: any) => ({ + id: action.id || `action-${Date.now()}`, + name: action.name || "새 액션", + actionType: action.actionType || "insert", + conditions: Array.isArray(action.conditions) ? action.conditions : [], + fieldMappings: Array.isArray(action.fieldMappings) + ? action.fieldMappings.map((mapping: any) => ({ + sourceTable: mapping.sourceTable || "", + sourceField: mapping.sourceField || "", + targetTable: mapping.targetTable || "", + targetField: mapping.targetField || "", + defaultValue: mapping.defaultValue || "", + transformFunction: mapping.transformFunction || "", + })) + : [], + splitConfig: action.splitConfig + ? { + sourceField: action.splitConfig.sourceField || "", + delimiter: action.splitConfig.delimiter || ",", + targetField: action.splitConfig.targetField || "", + } + : undefined, + })), + }); + + // 전체 실행 조건 로드 - 안전하게 처리 + if (settings.control) { + const controlSettings = settings.control as { conditionTree?: ConditionNode[] }; + if (Array.isArray(controlSettings.conditionTree)) { + // 기존 조건이 없을 때만 로드 (사용자가 추가한 조건 보존) + setConditions((prevConditions) => { + if (prevConditions.length === 0) { + return controlSettings.conditionTree || []; + } + return prevConditions; + }); + } else { + // 기존 조건이 없을 때만 초기화 + setConditions((prevConditions) => (prevConditions.length === 0 ? [] : prevConditions)); + } + } else { + // 기존 조건이 없을 때만 초기화 + setConditions((prevConditions) => (prevConditions.length === 0 ? [] : prevConditions)); + } + } else if (connectionType === "external-call") { + setExternalCallSettings({ + callType: (settings.callType as "rest-api" | "webhook") || "rest-api", + apiUrl: (settings.apiUrl as string) || "", + httpMethod: (settings.httpMethod as "GET" | "POST" | "PUT" | "DELETE") || "POST", + headers: (settings.headers as string) || "{}", + bodyTemplate: (settings.bodyTemplate as string) || "{}", + }); + } + } else { + // 기본값 설정 + setSimpleKeySettings({ + notes: `${fromDisplayName}과 ${toDisplayName} 간의 키값 연결`, + }); + + setDataSaveSettings({ + actions: [], + }); + } // 🔥 필드 선택 상태 초기화 setSelectedFromColumns([]); @@ -286,8 +355,8 @@ export const ConnectionSetupModal: React.FC = ({ const tablesToLoad = new Set(); // 필드 매핑에서 사용되는 모든 테이블 수집 - dataSaveSettings.actions.forEach((action) => { - action.fieldMappings.forEach((mapping) => { + (dataSaveSettings.actions || []).forEach((action) => { + (action.fieldMappings || []).forEach((mapping) => { if (mapping.sourceTable && !tableColumnsCache[mapping.sourceTable]) { tablesToLoad.add(mapping.sourceTable); } @@ -329,9 +398,9 @@ export const ConnectionSetupModal: React.FC = ({ // 단순 키값 연결일 때만 컬럼 선택 검증 if (config.connectionType === "simple-key") { - if (selectedFromColumns.length === 0 || selectedToColumns.length === 0) { - toast.error("선택된 컬럼이 없습니다. From과 To 테이블에서 각각 최소 1개 이상의 컬럼을 선택해주세요."); - return; + if (selectedFromColumns.length === 0 || selectedToColumns.length === 0) { + toast.error("선택된 컬럼이 없습니다. From과 To 테이블에서 각각 최소 1개 이상의 컬럼을 선택해주세요."); + return; } } @@ -433,8 +502,11 @@ export const ConnectionSetupModal: React.FC = ({ operator_type: "=", value: "", dataType: "string", - logicalOperator: "AND", // 기본값으로 AND 설정 + // 첫 번째 조건이 아니고, 바로 앞이 group-start가 아니면 logicalOperator 추가 + ...(conditions.length > 0 && + conditions[conditions.length - 1]?.type !== "group-start" && { logicalOperator: "AND" }), }; + setConditions([...conditions, newCondition]); }; @@ -448,7 +520,8 @@ export const ConnectionSetupModal: React.FC = ({ type: "group-start", groupId, groupLevel, - logicalOperator: conditions.length > 0 ? "AND" : undefined, + // 첫 번째 그룹이 아니면 logicalOperator 추가 + ...(conditions.length > 0 && { logicalOperator: "AND" }), }; setConditions([...conditions, groupStart]); @@ -626,7 +699,7 @@ export const ConnectionSetupModal: React.FC = ({ const renderActionCondition = (condition: ConditionNode, condIndex: number, actionIndex: number) => { // 그룹 시작 렌더링 if (condition.type === "group-start") { - return ( + return (
{/* 그룹 시작 앞의 논리 연산자 */} {condIndex > 0 && ( @@ -667,8 +740,8 @@ export const ConnectionSetupModal: React.FC = ({ > -
-
+ + ); } @@ -696,13 +769,13 @@ export const ConnectionSetupModal: React.FC = ({ > - - ); + + ); } // 일반 조건 렌더링 (기존 로직 간소화) - return ( + return (
{/* 그룹 내 첫 번째 조건이 아닐 때만 논리 연산자 표시 */} {condIndex > 0 && dataSaveSettings.actions[actionIndex].conditions![condIndex - 1]?.type !== "group-start" && ( @@ -776,7 +849,7 @@ export const ConnectionSetupModal: React.FC = ({ if (dataType.includes("timestamp") || dataType.includes("datetime") || dataType.includes("date")) { return ( - { @@ -789,7 +862,7 @@ export const ConnectionSetupModal: React.FC = ({ ); } else if (dataType.includes("time")) { return ( - { @@ -880,9 +953,9 @@ export const ConnectionSetupModal: React.FC = ({ className="h-6 w-6 p-0" > - -
- + + + ); }; @@ -893,7 +966,7 @@ export const ConnectionSetupModal: React.FC = ({
전체 실행 조건 (언제 이 연결이 동작할지) -
+ {/* 실행 조건 설정 */}
@@ -910,7 +983,7 @@ export const ConnectionSetupModal: React.FC = ({ -
+ {/* 조건 목록 */} @@ -922,352 +995,379 @@ export const ConnectionSetupModal: React.FC = ({ 조건이 없으면 항상 실행됩니다. ) : ( - conditions.map((condition, index) => { - // 그룹 시작 렌더링 - if (condition.type === "group-start") { + + {conditions.map((condition, index) => { + // 그룹 시작 렌더링 + if (condition.type === "group-start") { + return ( +
+ {/* 그룹 시작 앞의 논리 연산자 - 이전 요소가 group-end가 아닌 경우에만 표시 */} + {index > 0 && conditions[index - 1]?.type !== "group-end" && ( + + )} + {/* 그룹 레벨에 따른 들여쓰기 */} +
+ ( + 그룹 시작 + +
+
+ ); + } + + // 그룹 끝 렌더링 + if (condition.type === "group-end") { + return ( +
+
+ ) + 그룹 끝 + +
+ + {/* 그룹 끝 다음에 다른 조건이나 그룹이 있으면 논리 연산자 표시 */} + {index < conditions.length - 1 && ( + + )} +
+ ); + } + + // 일반 조건 렌더링 return (
- {/* 그룹 시작 앞의 논리 연산자 */} - {index > 0 && ( + {/* 일반 조건 앞의 논리 연산자 - 이전 요소가 group-end가 아닌 경우에만 표시 */} + {index > 0 && + conditions[index - 1]?.type !== "group-start" && + conditions[index - 1]?.type !== "group-end" && ( + + )} + + {/* 그룹 레벨에 따른 들여쓰기와 조건 필드들 */} +
+ {/* 조건 필드 선택 */} + + {/* 연산자 선택 */} + - )} - {/* 그룹 레벨에 따른 들여쓰기 */} -
- ( - 그룹 시작 - -
-
- ); - } - // 그룹 끝 렌더링 - if (condition.type === "group-end") { - return ( -
-
- ) - 그룹 끝 + {/* 데이터 타입에 따른 동적 입력 컴포넌트 */} + {(() => { + const selectedColumn = fromTableColumns.find((col) => col.columnName === condition.field); + const dataType = selectedColumn?.dataType?.toLowerCase() || "string"; + + if ( + dataType.includes("timestamp") || + dataType.includes("datetime") || + dataType.includes("date") + ) { + return ( + updateCondition(index, "value", e.target.value)} + className="h-8 flex-1 text-xs" + /> + ); + } else if (dataType.includes("time")) { + return ( + updateCondition(index, "value", e.target.value)} + className="h-8 flex-1 text-xs" + /> + ); + } else if (dataType.includes("date")) { + return ( + updateCondition(index, "value", e.target.value)} + className="h-8 flex-1 text-xs" + /> + ); + } else if ( + dataType.includes("int") || + dataType.includes("numeric") || + dataType.includes("decimal") || + dataType.includes("float") || + dataType.includes("double") + ) { + return ( + updateCondition(index, "value", e.target.value)} + className="h-8 flex-1 text-xs" + /> + ); + } else if (dataType.includes("bool")) { + return ( + + ); + } else { + return ( + updateCondition(index, "value", e.target.value)} + className="h-8 flex-1 text-xs" + /> + ); + } + })()} + + {/* 삭제 버튼 */} -
+
); - } - - // 일반 조건 렌더링 - return ( -
- {/* 그룹 내 첫 번째 조건이 아닐 때만 논리 연산자 표시 */} - {index > 0 && conditions[index - 1]?.type !== "group-start" && ( - - )} - - {/* 그룹 레벨에 따른 들여쓰기와 조건 필드들 */} -
- {/* 조건 필드 선택 */} - - - {/* 연산자 선택 */} - - - {/* 데이터 타입에 따른 동적 입력 컴포넌트 */} - {(() => { - const selectedColumn = fromTableColumns.find((col) => col.columnName === condition.field); - const dataType = selectedColumn?.dataType?.toLowerCase() || "string"; - - if ( - dataType.includes("timestamp") || - dataType.includes("datetime") || - dataType.includes("date") - ) { - return ( - updateCondition(index, "value", e.target.value)} - className="h-8 flex-1 text-xs" - /> - ); - } else if (dataType.includes("time")) { - return ( - updateCondition(index, "value", e.target.value)} - className="h-8 flex-1 text-xs" - /> - ); - } else if (dataType.includes("date")) { - return ( - updateCondition(index, "value", e.target.value)} - className="h-8 flex-1 text-xs" - /> - ); - } else if ( - dataType.includes("int") || - dataType.includes("numeric") || - dataType.includes("decimal") || - dataType.includes("float") || - dataType.includes("double") - ) { - return ( - updateCondition(index, "value", e.target.value)} - className="h-8 flex-1 text-xs" - /> - ); - } else if (dataType.includes("bool")) { - return ( - - ); - } else { - return ( - updateCondition(index, "value", e.target.value)} - className="h-8 flex-1 text-xs" - /> - ); - } - })()} - - {/* 삭제 버튼 */} - -
-
- ); - }) - )} + })} +
+ )} - - - ); + + + ); }; // 연결 종류별 설정 패널 렌더링 const renderConnectionTypeSettings = () => { switch (config.connectionType) { case "simple-key": - return ( -
- {/* 테이블 및 컬럼 선택 */} -
-
테이블 및 컬럼 선택
+ return ( +
+ {/* 테이블 및 컬럼 선택 */} +
+
테이블 및 컬럼 선택
- {/* 현재 선택된 테이블 표시 */} -
-
- -
- - {availableTables.find((t) => t.tableName === selectedFromTable)?.displayName || selectedFromTable} - - ({selectedFromTable}) -
-
- -
- -
- - {availableTables.find((t) => t.tableName === selectedToTable)?.displayName || selectedToTable} - - ({selectedToTable}) -
-
-
- - {/* 컬럼 선택 */} -
-
- -
- {fromTableColumns.map((column) => ( - - ))} - {fromTableColumns.length === 0 && ( -
- {selectedFromTable ? "컬럼을 불러오는 중..." : "테이블을 먼저 선택해주세요"} -
- )} -
-
- -
- -
- {toTableColumns.map((column) => ( - - ))} - {toTableColumns.length === 0 && ( -
- {selectedToTable ? "컬럼을 불러오는 중..." : "테이블을 먼저 선택해주세요"} -
- )} -
-
-
- - {/* 선택된 컬럼 미리보기 */} - {(selectedFromColumns.length > 0 || selectedToColumns.length > 0) && ( -
+ {/* 현재 선택된 테이블 표시 */} +
- -
- {selectedFromColumns.length > 0 ? ( - selectedFromColumns.map((column) => ( - - {column} - - )) - ) : ( - 선택된 컬럼 없음 + +
+ + {availableTables.find((t) => t.tableName === selectedFromTable)?.displayName || selectedFromTable} + + ({selectedFromTable}) +
+
+ +
+ +
+ + {availableTables.find((t) => t.tableName === selectedToTable)?.displayName || selectedToTable} + + ({selectedToTable}) +
+
+
+ + {/* 컬럼 선택 */} +
+
+ +
+ {fromTableColumns.map((column) => ( + + ))} + {fromTableColumns.length === 0 && ( +
+ {selectedFromTable ? "컬럼을 불러오는 중..." : "테이블을 먼저 선택해주세요"} +
)}
- -
- {selectedToColumns.length > 0 ? ( - selectedToColumns.map((column) => ( - - {column} - - )) - ) : ( - 선택된 컬럼 없음 + +
+ {toTableColumns.map((column) => ( + + ))} + {toTableColumns.length === 0 && ( +
+ {selectedToTable ? "컬럼을 불러오는 중..." : "테이블을 먼저 선택해주세요"} +
)}
- )} -
+ + {/* 선택된 컬럼 미리보기 */} + {(selectedFromColumns.length > 0 || selectedToColumns.length > 0) && ( +
+
+ +
+ {selectedFromColumns.length > 0 ? ( + selectedFromColumns.map((column) => ( + + {column} + + )) + ) : ( + 선택된 컬럼 없음 + )} +
+
+ +
+ +
+ {selectedToColumns.length > 0 ? ( + selectedToColumns.map((column) => ( + + {column} + + )) + ) : ( + 선택된 컬럼 없음 + )} +
+
+
+ )} +
{/* 단순 키값 연결 설정 */}
@@ -1276,7 +1376,7 @@ export const ConnectionSetupModal: React.FC = ({ 단순 키값 연결 설정
-
+
@@ -1318,7 +1418,7 @@ export const ConnectionSetupModal: React.FC = ({ conditions: [], splitConfig: { sourceField: "", - delimiter: ",", + delimiter: "", targetField: "", }, }; @@ -1334,16 +1434,16 @@ export const ConnectionSetupModal: React.FC = ({
- {dataSaveSettings.actions.length === 0 ? ( + {(dataSaveSettings.actions || []).length === 0 ? (
저장 액션을 추가하여 데이터를 어떻게 저장할지 설정하세요.
) : (
- {dataSaveSettings.actions.map((action, actionIndex) => ( + {(dataSaveSettings.actions || []).map((action, actionIndex) => (
- { const newActions = [...dataSaveSettings.actions]; @@ -1471,9 +1571,11 @@ export const ConnectionSetupModal: React.FC = ({
{action.conditions && action.conditions.length > 0 && (
- {action.conditions.map((condition, condIndex) => - renderActionCondition(condition, condIndex, actionIndex), - )} + {action.conditions.map((condition, condIndex) => ( +
+ {renderActionCondition(condition, condIndex, actionIndex)} +
+ ))}
)}
@@ -1549,13 +1651,13 @@ export const ConnectionSetupModal: React.FC = ({
{ const newActions = [...dataSaveSettings.actions]; if (!newActions[actionIndex].splitConfig) { newActions[actionIndex].splitConfig = { sourceField: "", - delimiter: ",", + delimiter: "", targetField: "", }; } @@ -1627,7 +1729,10 @@ export const ConnectionSetupModal: React.FC = ({
{action.fieldMappings.map((mapping, mappingIndex) => ( -
+
{/* 컴팩트한 매핑 표시 */}
{/* 소스 */} @@ -1850,31 +1955,31 @@ export const ConnectionSetupModal: React.FC = ({ value={externalCallSettings.apiUrl} onChange={(e) => setExternalCallSettings({ ...externalCallSettings, apiUrl: e.target.value })} placeholder="https://api.example.com/webhook" - className="text-sm" - /> -
+ className="text-sm" + /> +
-
+
- -
+ + +
@@ -1222,7 +1376,7 @@ export const DataFlowDesigner: React.FC = ({
메모리 관계: - {tempRelationships.length}개 + {(tempRelationships || []).length}개
관계도 ID: @@ -1313,7 +1467,7 @@ export const DataFlowDesigner: React.FC = ({
{/* 편집 버튼 */} - - - - -
- - {/* 통계 정보 */} -
-
통계
-
-
- 테이블 노드: - {nodes.length}개 -
-
- 연결: - {edges.length}개 -
-
- 메모리 관계: - {(tempRelationships || []).length}개 -
-
- 관계도 ID: - {currentDiagramId || "미설정"} -
-
- 연결 종류: - - {currentDiagramCategory === "simple-key" && "단순 키값"} - {currentDiagramCategory === "data-save" && "데이터 저장"} - {currentDiagramCategory === "external-call" && "외부 호출"} - -
- {hasUnsavedChanges && ( -
⚠️ 저장되지 않은 변경사항이 있습니다
- )} -
-
- - {/* 선택된 컬럼 정보 */} -
-
+ {/* React Flow 캔버스 */}
@@ -1431,295 +488,40 @@ export const DataFlowDesigner: React.FC = ({ - {/* 관계 목록 모달 - 캔버스 내부 우측 상단에 배치 */} - {showRelationshipListModal && ( -
- {/* 헤더 */} -
-
-
- 🔗 -
-
테이블 간 관계 목록
-
- -
- - {/* 관계 목록 */} -
-
- {selectedTablePairRelationships.map((relationship) => ( -
-
-

- {relationship.fromTable} → {relationship.toTable} -

-
- {/* 편집 버튼 */} - - {/* 삭제 버튼 */} - -
-
-
-

타입: {relationship.connectionType}

-

From: {relationship.fromTable}

-

To: {relationship.toTable}

-
-
- ))} -
-
-
- )} + {/* 관계 목록 모달 */} + setShowRelationshipListModal(false)} + onEdit={() => {}} + onDelete={(relationshipId) => { + setTempRelationships((prev) => prev.filter((rel) => rel.id !== relationshipId)); + setEdges((prev) => prev.filter((edge) => edge.data?.relationshipId !== relationshipId)); + setHasUnsavedChanges(true); + }} + onSetEditingId={setEditingRelationshipId} + onSetSelectedColumns={setSelectedColumns} + onSetPendingConnection={setPendingConnection} + /> - {/* 선택된 테이블 노드 팝업 - 캔버스 좌측 상단 고정 (새 관계 생성 시에만 표시) */} + {/* 선택된 테이블 노드 팝업 */} {selectedNodes.length > 0 && !showEdgeActions && !pendingConnection && !editingRelationshipId && ( -
- {/* 헤더 */} -
-
-
- 📋 -
-
-
선택된 테이블
-
- {selectedNodes.length === 1 - ? "FROM 테이블 선택됨" - : selectedNodes.length === 2 - ? "FROM → TO 연결 준비" - : `${selectedNodes.length}개 테이블`} -
-
-
- -
- - {/* 컨텐츠 */} -
-
- {selectedNodes.map((nodeId, index) => { - const node = nodes.find((n) => n.id === nodeId); - if (!node) return null; - - const { tableName, displayName } = node.data.table; - return ( -
- {/* 테이블 정보 */} -
-
-
- {displayName} -
- {selectedNodes.length === 2 && ( -
- {index === 0 ? "FROM" : "TO"} -
- )} -
-
{tableName}
-
- - {/* 연결 화살표 (마지막이 아닌 경우) */} - {index < selectedNodes.length - 1 && ( -
-
-
- )} -
- ); - })} -
-
- - {/* 액션 버튼 */} -
- - -
-
+ setSelectedNodes([])} + onOpenConnectionModal={openConnectionModal} + onClear={() => { + setSelectedColumns({}); + setSelectedNodes([]); + }} + canCreateConnection={canCreateConnection()} + /> )} {/* 안내 메시지 */} @@ -1753,120 +555,26 @@ export const DataFlowDesigner: React.FC = ({ /> {/* 엣지 정보 및 액션 버튼 */} - {showEdgeActions && selectedEdgeForEdit && selectedEdgeInfo && ( -
- {/* 헤더 */} -
-
-
- 🔗 -
-
-
{selectedEdgeInfo.relationshipName}
-
데이터 관계 정보
-
-
- -
- - {/* 관계 정보 요약 */} -
-
-
-
연결 유형
-
- {selectedEdgeInfo.connectionType} -
-
-
-
- - {/* 연결 정보 */} -
- {/* From 테이블 */} -
-
FROM
-
{selectedEdgeInfo.fromTable}
-
-
- {selectedEdgeInfo.fromColumns.map((column, index) => ( - - {column} - - ))} -
-
-
- - {/* 관계 화살표 */} -
- -
- - {/* To 테이블 */} -
-
TO
-
{selectedEdgeInfo.toTable}
-
-
- {selectedEdgeInfo.toColumns.map((column, index) => ( - - {column} - - ))} -
-
-
-
- - {/* 액션 버튼 */} -
- - -
-
- )} + { + setSelectedEdgeInfo(null); + setShowEdgeActions(false); + setSelectedEdgeForEdit(null); + setSelectedColumns({}); + }} + onEdit={() => {}} + onDelete={() => {}} + /> {/* 관계도 저장 모달 */} 0 && currentDiagramName ? currentDiagramName // 편집 모드: 기존 관계도 이름 diff --git a/frontend/components/dataflow/DataFlowSidebar.tsx b/frontend/components/dataflow/DataFlowSidebar.tsx new file mode 100644 index 00000000..92dba462 --- /dev/null +++ b/frontend/components/dataflow/DataFlowSidebar.tsx @@ -0,0 +1,108 @@ +"use client"; + +import React from "react"; +import { TableSelector } from "./TableSelector"; +import { TableDefinition } from "@/lib/api/dataflow"; +import { ExtendedJsonRelationship } from "@/types/dataflowTypes"; + +interface DataFlowSidebarProps { + companyCode: string; + nodes: Array<{ id: string; data: { table: { tableName: string } } }>; + edges: Array<{ id: string }>; + tempRelationships: ExtendedJsonRelationship[]; + hasUnsavedChanges: boolean; + currentDiagramId: number | null; + currentDiagramCategory: string; + onTableAdd: (table: TableDefinition) => void; + onRemoveOrphanedNodes: () => void; + onClearAll: () => void; + onOpenSaveModal: () => void; + getSelectedTableNames: () => string[]; +} + +export const DataFlowSidebar: React.FC = ({ + companyCode, + nodes, + edges, + tempRelationships, + hasUnsavedChanges, + currentDiagramId, + currentDiagramCategory, + onTableAdd, + onRemoveOrphanedNodes, + onClearAll, + onOpenSaveModal, + getSelectedTableNames, +}) => { + return ( +
+
+

테이블 간 데이터 관계 설정

+ + {/* 테이블 선택기 */} + + + {/* 컨트롤 버튼들 */} +
+ + + + + +
+ + {/* 통계 정보 */} +
+
통계
+
+
+ 테이블 노드: + {nodes.length}개 +
+
+ 연결: + {edges.length}개 +
+
+ 메모리 관계: + {tempRelationships.length}개 +
+
+ 관계도 ID: + {currentDiagramId || "미설정"} +
+
+ 연결 종류: + + {currentDiagramCategory === "simple-key" && "단순 키값"} + {currentDiagramCategory === "data-save" && "데이터 저장"} + {currentDiagramCategory === "external-call" && "외부 호출"} + +
+ {hasUnsavedChanges && ( +
⚠️ 저장되지 않은 변경사항이 있습니다
+ )} +
+
+
+
+ ); +}; diff --git a/frontend/components/dataflow/EdgeInfoPanel.tsx b/frontend/components/dataflow/EdgeInfoPanel.tsx new file mode 100644 index 00000000..ad9be50e --- /dev/null +++ b/frontend/components/dataflow/EdgeInfoPanel.tsx @@ -0,0 +1,127 @@ +"use client"; + +import React from "react"; +import { SelectedEdgeInfo } from "@/types/dataflowTypes"; + +interface EdgeInfoPanelProps { + isOpen: boolean; + edgeInfo: SelectedEdgeInfo | null; + position: { x: number; y: number }; + onClose: () => void; + onEdit: () => void; + onDelete: () => void; +} + +export const EdgeInfoPanel: React.FC = ({ + isOpen, + edgeInfo, + position, + onClose, + onEdit, + onDelete, +}) => { + if (!isOpen || !edgeInfo) return null; + + return ( +
+ {/* 헤더 */} +
+
+
+ 🔗 +
+
+
{edgeInfo.relationshipName}
+
데이터 관계 정보
+
+
+ +
+ + {/* 관계 정보 요약 */} +
+
+
+
연결 유형
+
+ {edgeInfo.connectionType} +
+
+
+
+ + {/* 연결 정보 */} +
+ {/* From 테이블 */} +
+
FROM
+
{edgeInfo.fromTable}
+
+
+ {edgeInfo.fromColumns.map((column, index) => ( + + {column} + + ))} +
+
+
+ + {/* 관계 화살표 */} +
+ +
+ + {/* To 테이블 */} +
+
TO
+
{edgeInfo.toTable}
+
+
+ {edgeInfo.toColumns.map((column, index) => ( + + {column} + + ))} +
+
+
+
+ + {/* 액션 버튼 */} +
+ + +
+
+ ); +}; diff --git a/frontend/components/dataflow/RelationshipListModal.tsx b/frontend/components/dataflow/RelationshipListModal.tsx new file mode 100644 index 00000000..3c2c8d48 --- /dev/null +++ b/frontend/components/dataflow/RelationshipListModal.tsx @@ -0,0 +1,211 @@ +"use client"; + +import React from "react"; +import { ExtendedJsonRelationship, TableNodeData } from "@/types/dataflowTypes"; +import { DataFlowAPI } from "@/lib/api/dataflow"; + +interface RelationshipListModalProps { + isOpen: boolean; + relationships: ExtendedJsonRelationship[]; + nodes: Array<{ id: string; data: TableNodeData }>; + diagramId?: number; + companyCode: string; + editingRelationshipId: string | null; + onClose: () => void; + onEdit: (relationship: ExtendedJsonRelationship) => void; + onDelete: (relationshipId: string) => void; + onSetEditingId: (id: string | null) => void; + onSetSelectedColumns: (columns: { [tableName: string]: string[] }) => void; + onSetPendingConnection: (connection: any) => void; +} + +export const RelationshipListModal: React.FC = ({ + isOpen, + relationships, + nodes, + diagramId, + companyCode, + editingRelationshipId, + onClose, + onEdit, + onDelete, + onSetEditingId, + onSetSelectedColumns, + onSetPendingConnection, +}) => { + if (!isOpen) return null; + + const handleEdit = async (relationship: ExtendedJsonRelationship) => { + // 관계 선택 시 수정 모드로 전환 + onSetEditingId(relationship.id); + + // 관련 컬럼 하이라이트 + const newSelectedColumns: { [tableName: string]: string[] } = {}; + if (relationship.fromTable && relationship.fromColumns) { + newSelectedColumns[relationship.fromTable] = [...relationship.fromColumns]; + } + if (relationship.toTable && relationship.toColumns) { + newSelectedColumns[relationship.toTable] = [...relationship.toColumns]; + } + onSetSelectedColumns(newSelectedColumns); + + // 🔥 수정: 데이터베이스에서 관계 설정 정보 로드 + let relationshipSettings = {}; + if (diagramId && diagramId > 0) { + try { + const jsonDiagram = await DataFlowAPI.getJsonDataFlowDiagramById(diagramId, companyCode); + if (jsonDiagram && relationship.connectionType === "data-save") { + const control = jsonDiagram.control?.find((c) => c.id === relationship.id); + const plan = jsonDiagram.plan?.find((p) => p.id === relationship.id); + + relationshipSettings = { + control: control + ? { + triggerType: control.triggerType, + conditionTree: control.conditions || [], + } + : undefined, + actions: plan ? plan.actions || [] : [], + }; + } + } catch (error) { + console.error("관계 설정 정보 로드 실패:", error); + } + } + + // 연결 설정 모달 열기 + const fromTable = nodes.find((node) => node.data?.table?.tableName === relationship.fromTable); + const toTable = nodes.find((node) => node.data?.table?.tableName === relationship.toTable); + + if (fromTable && toTable) { + onSetPendingConnection({ + fromNode: { + id: fromTable.id, + tableName: relationship.fromTable, + displayName: fromTable.data?.table?.displayName || relationship.fromTable, + }, + toNode: { + id: toTable.id, + tableName: relationship.toTable, + displayName: toTable.data?.table?.displayName || relationship.toTable, + }, + selectedColumnsData: { + [relationship.fromTable]: { + displayName: fromTable.data?.table?.displayName || relationship.fromTable, + columns: relationship.fromColumns || [], + }, + [relationship.toTable]: { + displayName: toTable.data?.table?.displayName || relationship.toTable, + columns: relationship.toColumns || [], + }, + }, + existingRelationship: { + relationshipName: relationship.relationshipName, + connectionType: relationship.connectionType, + settings: relationshipSettings, + }, + }); + } + + // 모달 닫기 + onClose(); + }; + + const handleDelete = (relationship: ExtendedJsonRelationship) => { + onDelete(relationship.id); + + // 선택된 컬럼 초기화 + onSetSelectedColumns({}); + + // 편집 모드 해제 + if (editingRelationshipId === relationship.id) { + onSetEditingId(null); + } + + // 모달 닫기 + onClose(); + }; + + return ( +
+ {/* 헤더 */} +
+
+
+ 🔗 +
+
테이블 간 관계 목록
+
+ +
+ + {/* 관계 목록 */} +
+
+ {relationships.map((relationship) => ( +
+
+

+ {relationship.fromTable} → {relationship.toTable} +

+
+ {/* 편집 버튼 */} + + {/* 삭제 버튼 */} + +
+
+
+

타입: {relationship.connectionType}

+

From: {relationship.fromTable}

+

To: {relationship.toTable}

+
+
+ ))} +
+
+
+ ); +}; diff --git a/frontend/components/dataflow/SelectedTablesPanel.tsx b/frontend/components/dataflow/SelectedTablesPanel.tsx new file mode 100644 index 00000000..7788ffda --- /dev/null +++ b/frontend/components/dataflow/SelectedTablesPanel.tsx @@ -0,0 +1,130 @@ +"use client"; + +import React from "react"; +import { TableNodeData } from "@/types/dataflowTypes"; + +interface SelectedTablesPanelProps { + selectedNodes: string[]; + nodes: Array<{ + id: string; + data: TableNodeData; + }>; + onClose: () => void; + onOpenConnectionModal: () => void; + onClear: () => void; + canCreateConnection: boolean; +} + +export const SelectedTablesPanel: React.FC = ({ + selectedNodes, + nodes, + onClose, + onOpenConnectionModal, + onClear, + canCreateConnection, +}) => { + return ( +
+ {/* 헤더 */} +
+
+
+ 📋 +
+
+
선택된 테이블
+
+ {selectedNodes.length === 1 + ? "FROM 테이블 선택됨" + : selectedNodes.length === 2 + ? "FROM → TO 연결 준비" + : `${selectedNodes.length}개 테이블`} +
+
+
+ +
+ + {/* 컨텐츠 */} +
+
+ {selectedNodes.map((nodeId, index) => { + const node = nodes.find((n) => n.id === nodeId); + if (!node) return null; + + const { tableName, displayName } = node.data.table; + return ( +
+ {/* 테이블 정보 */} +
+
+
+ {displayName} +
+ {selectedNodes.length === 2 && ( +
+ {index === 0 ? "FROM" : "TO"} +
+ )} +
+
{tableName}
+
+ + {/* 연결 화살표 (마지막이 아닌 경우) */} + {index < selectedNodes.length - 1 && ( +
+
+
+ )} +
+ ); + })} +
+
+ + {/* 액션 버튼 */} +
+ + +
+
+ ); +}; diff --git a/frontend/hooks/useDataFlowDesigner.ts b/frontend/hooks/useDataFlowDesigner.ts new file mode 100644 index 00000000..d84b3ade --- /dev/null +++ b/frontend/hooks/useDataFlowDesigner.ts @@ -0,0 +1,93 @@ +"use client"; + +import { useState, useCallback, useRef } from "react"; +import { Node, Edge, useNodesState, useEdgesState } from "@xyflow/react"; +import { TableNodeData, ExtendedJsonRelationship, ConnectionInfo, SelectedEdgeInfo } from "@/types/dataflowTypes"; + +export const useDataFlowDesigner = () => { + const [nodes, setNodes, onNodesChange] = useNodesState>([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + + // 상태 관리 + const [selectedColumns, setSelectedColumns] = useState<{ [tableName: string]: string[] }>({}); + const [selectedNodes, setSelectedNodes] = useState([]); + const [pendingConnection, setPendingConnection] = useState(null); + const [relationships, setRelationships] = useState([]); // eslint-disable-line @typescript-eslint/no-unused-vars + const [currentDiagramId, setCurrentDiagramId] = useState(null); + const [selectedEdgeInfo, setSelectedEdgeInfo] = useState(null); + + // 메모리 기반 상태들 + const [tempRelationships, setTempRelationships] = useState([]); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [showSaveModal, setShowSaveModal] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [currentDiagramName, setCurrentDiagramName] = useState(""); + const [currentDiagramCategory, setCurrentDiagramCategory] = useState("simple-key"); + const [selectedEdgeForEdit, setSelectedEdgeForEdit] = useState(null); + const [showEdgeActions, setShowEdgeActions] = useState(false); + const [edgeActionPosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 }); + const [editingRelationshipId, setEditingRelationshipId] = useState(null); + const [showRelationshipListModal, setShowRelationshipListModal] = useState(false); + const [selectedTablePairRelationships, setSelectedTablePairRelationships] = useState([]); + const toastShownRef = useRef(false); // eslint-disable-line @typescript-eslint/no-unused-vars + + return { + // Node & Edge states + nodes, + setNodes, + onNodesChange, + edges, + setEdges, + onEdgesChange, + + // Selection states + selectedColumns, + setSelectedColumns, + selectedNodes, + setSelectedNodes, + + // Connection states + pendingConnection, + setPendingConnection, + relationships, + setRelationships, + + // Diagram states + currentDiagramId, + setCurrentDiagramId, + currentDiagramName, + setCurrentDiagramName, + currentDiagramCategory, + setCurrentDiagramCategory, + + // Memory-based states + tempRelationships, + setTempRelationships, + hasUnsavedChanges, + setHasUnsavedChanges, + + // Modal states + showSaveModal, + setShowSaveModal, + isSaving, + setIsSaving, + showRelationshipListModal, + setShowRelationshipListModal, + selectedTablePairRelationships, + setSelectedTablePairRelationships, + + // Edge states + selectedEdgeInfo, + setSelectedEdgeInfo, + selectedEdgeForEdit, + setSelectedEdgeForEdit, + showEdgeActions, + setShowEdgeActions, + edgeActionPosition, + editingRelationshipId, + setEditingRelationshipId, + + // Refs + toastShownRef, + }; +}; diff --git a/frontend/types/dataflowTypes.ts b/frontend/types/dataflowTypes.ts new file mode 100644 index 00000000..50d3e760 --- /dev/null +++ b/frontend/types/dataflowTypes.ts @@ -0,0 +1,117 @@ +import { JsonRelationship, TableRelationship, DataFlowDiagram } from "@/lib/api/dataflow"; + +// 테이블 노드 데이터 타입 정의 +export interface TableNodeData extends Record { + table: { + tableName: string; + displayName: string; + description: string; + columns: Array<{ + name: string; + type: string; + description: string; + }>; + }; + onColumnClick: (tableName: string, columnName: string) => void; + selectedColumns: string[]; + connectedColumns?: { + [columnName: string]: { direction: "source" | "target" | "both" }; + }; +} + +// 내부에서 사용할 확장된 JsonRelationship 타입 (connectionType 포함) +export interface ExtendedJsonRelationship extends JsonRelationship { + connectionType: "simple-key" | "data-save" | "external-call"; + settings?: { + control?: { + triggerType?: "insert" | "update" | "delete"; + conditionTree?: Array<{ + id: string; + type: string; + field?: string; + operator?: string; + value?: unknown; + logicalOperator?: string; + groupId?: string; + groupLevel?: number; + }>; + }; + actions?: Array<{ + id: string; + name: string; + actionType: "insert" | "update" | "delete" | "upsert"; + conditions?: Array<{ + id: string; + type: string; + field?: string; + operator?: string; + value?: unknown; + logicalOperator?: string; + groupId?: string; + groupLevel?: number; + }>; + fieldMappings: Array<{ + sourceTable?: string; + sourceField: string; + targetTable?: string; + targetField: string; + defaultValue?: string; + }>; + splitConfig?: { + sourceField: string; + delimiter: string; + targetField: string; + }; + }>; + notes?: string; + apiCall?: { + url: string; + method: string; + headers: Array<{ key: string; value: string }>; + body: string; + successCriteria: string; + }; + }; +} + +// DataFlowDesigner Props 타입 +export interface DataFlowDesignerProps { + companyCode?: string; + onSave?: (relationships: TableRelationship[]) => void; + selectedDiagram?: DataFlowDiagram | string | null; + diagramId?: number; + relationshipId?: string; // 하위 호환성 유지 + onBackToList?: () => void; + onDiagramNameUpdate?: (diagramName: string) => void; +} + +// 연결 정보 타입 +export interface ConnectionInfo { + fromNode: { id: string; tableName: string; displayName: string }; + toNode: { id: string; tableName: string; displayName: string }; + fromColumn?: string; + toColumn?: string; + selectedColumnsData?: { + [tableName: string]: { + displayName: string; + columns: string[]; + }; + }; + existingRelationship?: { + relationshipName: string; + connectionType: string; + settings?: Record; + }; +} + +// 선택된 엣지 정보 타입 +export interface SelectedEdgeInfo { + relationshipId: string; + relationshipName: string; + fromTable: string; + toTable: string; + fromColumns: string[]; + toColumns: string[]; + connectionType: string; + connectionInfo: string; +} diff --git a/frontend/utils/dataflowUtils.ts b/frontend/utils/dataflowUtils.ts new file mode 100644 index 00000000..e9715393 --- /dev/null +++ b/frontend/utils/dataflowUtils.ts @@ -0,0 +1,104 @@ +// 데이터플로우 관련 유틸리티 함수들 + +/** + * 고유 ID 생성 함수 + */ +export const generateUniqueId = (prefix: string, diagramId?: number): string => { + const timestamp = Date.now(); + const random = Math.random().toString(36).substr(2, 9); + return `${prefix}-${diagramId || timestamp}-${random}`; +}; + +/** + * 테이블 쌍별 관계 개수 계산 + */ +export const calculateTableRelationshipCount = (relationships: Array<{ fromTable: string; toTable: string }>) => { + const tableRelationshipCount: { [key: string]: number } = {}; + + relationships.forEach((rel) => { + const tableKey = [rel.fromTable, rel.toTable].sort().join("-"); + tableRelationshipCount[tableKey] = (tableRelationshipCount[tableKey] || 0) + 1; + }); + + return tableRelationshipCount; +}; + +/** + * 연결된 컬럼 정보 계산 + */ +export const calculateConnectedColumns = ( + relationships: Array<{ + fromTable: string; + toTable: string; + fromColumns: string[]; + toColumns: string[]; + }>, +) => { + const connectedColumnsInfo: { + [tableName: string]: { [columnName: string]: { direction: "source" | "target" | "both" } }; + } = {}; + + relationships.forEach((rel) => { + const { fromTable, toTable, fromColumns, toColumns } = rel; + + // 소스 테이블의 컬럼들을 source로 표시 + if (!connectedColumnsInfo[fromTable]) connectedColumnsInfo[fromTable] = {}; + fromColumns.forEach((col: string) => { + if (connectedColumnsInfo[fromTable][col]) { + connectedColumnsInfo[fromTable][col].direction = "both"; + } else { + connectedColumnsInfo[fromTable][col] = { direction: "source" }; + } + }); + + // 타겟 테이블의 컬럼들을 target으로 표시 + if (!connectedColumnsInfo[toTable]) connectedColumnsInfo[toTable] = {}; + toColumns.forEach((col: string) => { + if (connectedColumnsInfo[toTable][col]) { + connectedColumnsInfo[toTable][col].direction = "both"; + } else { + connectedColumnsInfo[toTable][col] = { direction: "target" }; + } + }); + }); + + return connectedColumnsInfo; +}; + +/** + * 노드 위치 추출 + */ +export const extractNodePositions = ( + nodes: Array<{ + data: { table: { tableName: string } }; + position: { x: number; y: number }; + }>, +): { [tableName: string]: { x: number; y: number } } => { + const nodePositions: { [tableName: string]: { x: number; y: number } } = {}; + + nodes.forEach((node) => { + if (node.data?.table?.tableName) { + nodePositions[node.data.table.tableName] = { + x: node.position.x, + y: node.position.y, + }; + } + }); + + return nodePositions; +}; + +/** + * 테이블명 목록 추출 + */ +export const extractTableNames = ( + nodes: Array<{ + id: string; + data: { table: { tableName: string } }; + }>, +): string[] => { + return nodes + .filter((node) => node.id.startsWith("table-")) + .map((node) => node.data.table.tableName) + .sort(); +}; From 7acea0b2725cc479ea01edaf2f3f65efa75dba21 Mon Sep 17 00:00:00 2001 From: hyeonsu Date: Tue, 16 Sep 2025 15:43:18 +0900 Subject: [PATCH 04/10] =?UTF-8?q?ConnectionSetupModal=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=84=B0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dataflow/ConnectionSetupModal.tsx | 1958 ++--------------- .../components/dataflow/DataFlowDesigner.tsx | 147 +- .../dataflow/condition/ConditionRenderer.tsx | 215 ++ .../condition/ConditionalSettings.tsx | 67 + .../connection/ActionConditionRenderer.tsx | 206 ++ .../connection/ActionConditionsSection.tsx | 132 ++ .../connection/ActionFieldMappings.tsx | 214 ++ .../dataflow/connection/ActionSplitConfig.tsx | 131 ++ .../connection/ConnectionTypeSelector.tsx | 59 + .../dataflow/connection/DataSaveSettings.tsx | 158 ++ .../connection/ExternalCallSettings.tsx | 115 + .../dataflow/connection/SimpleKeySettings.tsx | 188 ++ frontend/hooks/useConditionManager.ts | 182 ++ frontend/types/connectionTypes.ts | 83 + frontend/utils/connectionUtils.ts | 102 + 15 files changed, 2146 insertions(+), 1811 deletions(-) create mode 100644 frontend/components/dataflow/condition/ConditionRenderer.tsx create mode 100644 frontend/components/dataflow/condition/ConditionalSettings.tsx create mode 100644 frontend/components/dataflow/connection/ActionConditionRenderer.tsx create mode 100644 frontend/components/dataflow/connection/ActionConditionsSection.tsx create mode 100644 frontend/components/dataflow/connection/ActionFieldMappings.tsx create mode 100644 frontend/components/dataflow/connection/ActionSplitConfig.tsx create mode 100644 frontend/components/dataflow/connection/ConnectionTypeSelector.tsx create mode 100644 frontend/components/dataflow/connection/DataSaveSettings.tsx create mode 100644 frontend/components/dataflow/connection/ExternalCallSettings.tsx create mode 100644 frontend/components/dataflow/connection/SimpleKeySettings.tsx create mode 100644 frontend/hooks/useConditionManager.ts create mode 100644 frontend/types/connectionTypes.ts create mode 100644 frontend/utils/connectionUtils.ts diff --git a/frontend/components/dataflow/ConnectionSetupModal.tsx b/frontend/components/dataflow/ConnectionSetupModal.tsx index 9b0bf788..16a2676f 100644 --- a/frontend/components/dataflow/ConnectionSetupModal.tsx +++ b/frontend/components/dataflow/ConnectionSetupModal.tsx @@ -1,98 +1,28 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useCallback } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Badge } from "@/components/ui/badge"; -import { Textarea } from "@/components/ui/textarea"; -import { Link, Key, Save, Globe, Plus, Zap, Trash2 } from "lucide-react"; +import { Link } from "lucide-react"; import { DataFlowAPI, TableRelationship, TableInfo, ColumnInfo, ConditionNode } from "@/lib/api/dataflow"; +import { + ConnectionConfig, + SimpleKeySettings, + DataSaveSettings, + ExternalCallSettings, + ConnectionSetupModalProps, +} from "@/types/connectionTypes"; +import { isConditionalConnection } from "@/utils/connectionUtils"; +import { useConditionManager } from "@/hooks/useConditionManager"; +import { ConditionalSettings } from "./condition/ConditionalSettings"; +import { ConnectionTypeSelector } from "./connection/ConnectionTypeSelector"; +import { SimpleKeySettings as SimpleKeySettingsComponent } from "./connection/SimpleKeySettings"; +import { DataSaveSettings as DataSaveSettingsComponent } from "./connection/DataSaveSettings"; +import { ExternalCallSettings as ExternalCallSettingsComponent } from "./connection/ExternalCallSettings"; import toast from "react-hot-toast"; -// 연결 정보 타입 -interface ConnectionInfo { - fromNode: { - id: string; - tableName: string; - displayName: string; - }; - toNode: { - id: string; - tableName: string; - displayName: string; - }; - fromColumn?: string; - toColumn?: string; - selectedColumnsData?: { - [tableName: string]: { - displayName: string; - columns: string[]; - }; - }; - existingRelationship?: { - relationshipName: string; - connectionType: string; - settings?: Record; - }; -} - -// 연결 설정 타입 -interface ConnectionConfig { - relationshipName: string; - connectionType: "simple-key" | "data-save" | "external-call"; - fromColumnName: string; - toColumnName: string; - settings?: Record; -} - -// 단순 키값 연결 설정 -interface SimpleKeySettings { - notes: string; -} - -// 데이터 저장 설정 -interface DataSaveSettings { - actions: Array<{ - id: string; - name: string; - actionType: "insert" | "update" | "delete" | "upsert"; - conditions?: ConditionNode[]; - fieldMappings: Array<{ - sourceTable?: string; - sourceField: string; - targetTable?: string; - targetField: string; - defaultValue?: string; - transformFunction?: string; - }>; - splitConfig?: { - sourceField: string; // 분할할 소스 필드 - delimiter: string; // 구분자 (예: ",") - targetField: string; // 분할된 값이 들어갈 필드 - }; - }>; -} - -// 외부 호출 설정 -interface ExternalCallSettings { - callType: "rest-api" | "email" | "webhook" | "ftp" | "queue"; - apiUrl?: string; - httpMethod?: "GET" | "POST" | "PUT" | "DELETE"; - headers?: string; - bodyTemplate?: string; -} - -interface ConnectionSetupModalProps { - isOpen: boolean; - connection: ConnectionInfo | null; - companyCode: string; - onConfirm: (relationship: TableRelationship) => void; - onCancel: () => void; -} - export const ConnectionSetupModal: React.FC = ({ isOpen, connection, @@ -125,7 +55,7 @@ export const ConnectionSetupModal: React.FC = ({ bodyTemplate: "{}", }); - // 테이블 및 컬럼 선택을 위한 새로운 상태들 + // 테이블 및 컬럼 선택을 위한 상태들 const [availableTables, setAvailableTables] = useState([]); const [selectedFromTable, setSelectedFromTable] = useState(""); const [selectedToTable, setSelectedToTable] = useState(""); @@ -133,11 +63,80 @@ export const ConnectionSetupModal: React.FC = ({ const [toTableColumns, setToTableColumns] = useState([]); const [selectedFromColumns, setSelectedFromColumns] = useState([]); const [selectedToColumns, setSelectedToColumns] = useState([]); - // 필요시 로드하는 테이블 컬럼 캐시 const [tableColumnsCache, setTableColumnsCache] = useState<{ [tableName: string]: ColumnInfo[] }>({}); - // 조건부 연결을 위한 새로운 상태들 - const [conditions, setConditions] = useState([]); + // 조건 관리 훅 사용 + const { + conditions, + setConditions, + addCondition, + addGroupStart, + addGroupEnd, + updateCondition, + removeCondition, + getCurrentGroupLevel, + } = useConditionManager(); + + // 기존 설정 로드 함수 + const loadExistingSettings = useCallback( + (settings: Record, connectionType: string) => { + if (connectionType === "simple-key" && settings.notes) { + setSimpleKeySettings({ + notes: settings.notes as string, + }); + } else if (connectionType === "data-save" && settings.actions) { + // data-save 설정 로드 - 안전하게 처리 + const actionsData = Array.isArray(settings.actions) ? settings.actions : []; + setDataSaveSettings({ + actions: actionsData.map((action: Record) => ({ + id: (action.id as string) || `action-${Date.now()}`, + name: (action.name as string) || "새 액션", + actionType: (action.actionType as "insert" | "update" | "delete" | "upsert") || "insert", + conditions: Array.isArray(action.conditions) + ? (action.conditions as ConditionNode[]).map((condition) => ({ + ...condition, + operator: condition.operator || "=", // 기본값 보장 + })) + : [], + fieldMappings: Array.isArray(action.fieldMappings) + ? action.fieldMappings.map((mapping: Record) => ({ + sourceTable: (mapping.sourceTable as string) || "", + sourceField: (mapping.sourceField as string) || "", + targetTable: (mapping.targetTable as string) || "", + targetField: (mapping.targetField as string) || "", + defaultValue: (mapping.defaultValue as string) || "", + transformFunction: (mapping.transformFunction as string) || "", + })) + : [], + splitConfig: action.splitConfig + ? { + sourceField: ((action.splitConfig as Record).sourceField as string) || "", + delimiter: ((action.splitConfig as Record).delimiter as string) || ",", + targetField: ((action.splitConfig as Record).targetField as string) || "", + } + : undefined, + })), + }); + + // 전체 실행 조건 로드 + if (settings.control) { + const controlSettings = settings.control as { conditionTree?: ConditionNode[] }; + if (Array.isArray(controlSettings.conditionTree)) { + setConditions(controlSettings.conditionTree || []); + } + } + } else if (connectionType === "external-call") { + setExternalCallSettings({ + callType: (settings.callType as "rest-api" | "webhook") || "rest-api", + apiUrl: (settings.apiUrl as string) || "", + httpMethod: (settings.httpMethod as "GET" | "POST" | "PUT" | "DELETE") || "POST", + headers: (settings.headers as string) || "{}", + bodyTemplate: (settings.bodyTemplate as string) || "{}", + }); + } + }, + [setConditions, setSimpleKeySettings, setDataSaveSettings, setExternalCallSettings], + ); // 테이블 목록 로드 useEffect(() => { @@ -181,103 +180,21 @@ export const ConnectionSetupModal: React.FC = ({ settings: existingRel?.settings || {}, }); - // 🔥 기존 설정 데이터 로드 + // 기존 설정 데이터 로드 if (existingRel?.settings) { - const settings = existingRel.settings; - - if (connectionType === "simple-key" && settings.notes) { - setSimpleKeySettings({ - notes: settings.notes as string, - }); - } else if (connectionType === "data-save" && settings.actions) { - // data-save 설정 로드 - 안전하게 처리 - const actionsData = Array.isArray(settings.actions) ? settings.actions : []; - setDataSaveSettings({ - actions: actionsData.map((action: Record) => ({ - id: (action.id as string) || `action-${Date.now()}`, - name: (action.name as string) || "새 액션", - actionType: (action.actionType as "insert" | "update" | "delete" | "upsert") || "insert", - conditions: Array.isArray(action.conditions) - ? action.conditions.map( - (condition: Record) => - ({ - ...condition, - operator: condition.operator || "=", // 기본값 보장 - }) as ConditionNode, - ) - : [], - fieldMappings: Array.isArray(action.fieldMappings) - ? action.fieldMappings.map((mapping: Record) => ({ - sourceTable: (mapping.sourceTable as string) || "", - sourceField: (mapping.sourceField as string) || "", - targetTable: (mapping.targetTable as string) || "", - targetField: (mapping.targetField as string) || "", - defaultValue: (mapping.defaultValue as string) || "", - transformFunction: (mapping.transformFunction as string) || "", - })) - : [], - splitConfig: action.splitConfig - ? { - sourceField: ((action.splitConfig as Record).sourceField as string) || "", - delimiter: ((action.splitConfig as Record).delimiter as string) || ",", - targetField: ((action.splitConfig as Record).targetField as string) || "", - } - : undefined, - })), - }); - - // 전체 실행 조건 로드 - 안전하게 처리 - if (settings.control) { - const controlSettings = settings.control as { conditionTree?: ConditionNode[] }; - if (Array.isArray(controlSettings.conditionTree)) { - // 기존 조건이 없을 때만 로드 (사용자가 추가한 조건 보존) - setConditions((prevConditions) => { - if (prevConditions.length === 0) { - return controlSettings.conditionTree || []; - } - return prevConditions; - }); - } else { - // 기존 조건이 없을 때만 초기화 - setConditions((prevConditions) => (prevConditions.length === 0 ? [] : prevConditions)); - } - } else { - // 기존 조건이 없을 때만 초기화 - setConditions((prevConditions) => (prevConditions.length === 0 ? [] : prevConditions)); - } - } else if (connectionType === "external-call") { - setExternalCallSettings({ - callType: (settings.callType as "rest-api" | "webhook") || "rest-api", - apiUrl: (settings.apiUrl as string) || "", - httpMethod: (settings.httpMethod as "GET" | "POST" | "PUT" | "DELETE") || "POST", - headers: (settings.headers as string) || "{}", - bodyTemplate: (settings.bodyTemplate as string) || "{}", - }); - } + loadExistingSettings(existingRel.settings, connectionType); } else { // 기본값 설정 setSimpleKeySettings({ notes: `${fromDisplayName}과 ${toDisplayName} 간의 키값 연결`, }); - - setDataSaveSettings({ - actions: [], - }); + setDataSaveSettings({ actions: [] }); } - // 🔥 필드 선택 상태 초기화 + // 필드 선택 상태 초기화 setSelectedFromColumns([]); setSelectedToColumns([]); - // 외부 호출 기본값 설정 - setExternalCallSettings({ - callType: "rest-api", - apiUrl: "https://api.example.com/webhook", - httpMethod: "POST", - headers: "{}", - bodyTemplate: "{}", - }); - // 선택된 컬럼 정보가 있다면 설정 if (connection.selectedColumnsData) { const fromColumns = connection.selectedColumnsData[fromTableName]?.columns || []; @@ -293,7 +210,7 @@ export const ConnectionSetupModal: React.FC = ({ })); } } - }, [isOpen, connection]); + }, [isOpen, connection, setConditions, loadExistingSettings]); // From 테이블 선택 시 컬럼 로드 useEffect(() => { @@ -363,8 +280,8 @@ export const ConnectionSetupModal: React.FC = ({ const tablesToLoad = new Set(); // 필드 매핑에서 사용되는 모든 테이블 수집 - (dataSaveSettings.actions || []).forEach((action) => { - (action.fieldMappings || []).forEach((mapping) => { + dataSaveSettings.actions?.forEach((action) => { + action.fieldMappings?.forEach((mapping) => { if (mapping.sourceTable && !tableColumnsCache[mapping.sourceTable]) { tablesToLoad.add(mapping.sourceTable); } @@ -417,7 +334,7 @@ export const ConnectionSetupModal: React.FC = ({ const toTableName = selectedToTable || connection.toNode.tableName; // 조건부 연결 설정 데이터 준비 - const conditionalSettings = isConditionalConnection() + const conditionalSettings = isConditionalConnection(config.connectionType) ? { control: { triggerType: "insert", @@ -474,8 +391,6 @@ export const ConnectionSetupModal: React.FC = ({ settings: { ...settings, ...conditionalSettings, // 조건부 연결 설정 추가 - // 중복 제거: multiColumnMapping, isMultiColumn, columnCount, description 제거 - // 필요시 from_column_name, to_column_name에서 split으로 추출 가능 }, }; @@ -496,1543 +411,41 @@ export const ConnectionSetupModal: React.FC = ({ onCancel(); }; - if (!connection) return null; - - // 선택된 컬럼 데이터 가져오기 (현재 사용되지 않음 - 향후 확장을 위해 유지) - // const selectedColumnsData = connection.selectedColumnsData || {}; - - // 조건부 연결인지 확인하는 헬퍼 함수 - const isConditionalConnection = () => { - return config.connectionType === "data-save" || config.connectionType === "external-call"; - }; - - // 고유 ID 생성 헬퍼 - const generateId = () => `cond_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - - // 조건 관리 헬퍼 함수들 - const addCondition = () => { - const newCondition: ConditionNode = { - id: generateId(), - type: "condition" as const, - field: "", - operator: "=", - value: "", - dataType: "string", - // 첫 번째 조건이 아니고, 바로 앞이 group-start가 아니면 logicalOperator 추가 - ...(conditions.length > 0 && - conditions[conditions.length - 1]?.type !== "group-start" && { logicalOperator: "AND" as const }), - }; - - setConditions([...conditions, newCondition]); - }; - - // 그룹 시작 추가 - const addGroupStart = () => { - const groupId = `group_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - const groupLevel = getNextGroupLevel(); - - const groupStart: ConditionNode = { - id: generateId(), - type: "group-start" as const, - groupId, - groupLevel, - // 첫 번째 그룹이 아니면 logicalOperator 추가 - ...(conditions.length > 0 && { logicalOperator: "AND" as const }), - }; - - setConditions([...conditions, groupStart]); - }; - - // 그룹 끝 추가 - const addGroupEnd = () => { - // 가장 최근에 열린 그룹 찾기 - const openGroups = findOpenGroups(); - if (openGroups.length === 0) { - toast.error("닫을 그룹이 없습니다."); - return; - } - - const lastOpenGroup = openGroups[openGroups.length - 1]; - const groupEnd: ConditionNode = { - id: generateId(), - type: "group-end" as const, - groupId: lastOpenGroup.groupId, - groupLevel: lastOpenGroup.groupLevel, - }; - - setConditions([...conditions, groupEnd]); - }; - - // 다음 그룹 레벨 계산 - const getNextGroupLevel = (): number => { - const openGroups = findOpenGroups(); - return openGroups.length; - }; - - // 열린 그룹 찾기 - const findOpenGroups = () => { - const openGroups: Array<{ groupId: string; groupLevel: number }> = []; - - for (const condition of conditions) { - if (condition.type === "group-start") { - openGroups.push({ - groupId: condition.groupId!, - groupLevel: condition.groupLevel!, - }); - } else if (condition.type === "group-end") { - // 해당 그룹 제거 - const groupIndex = openGroups.findIndex((g) => g.groupId === condition.groupId); - if (groupIndex !== -1) { - openGroups.splice(groupIndex, 1); - } - } - } - - return openGroups; - }; - - const updateCondition = (index: number, field: keyof ConditionNode, value: string) => { - const updatedConditions = [...conditions]; - updatedConditions[index] = { ...updatedConditions[index], [field]: value }; - setConditions(updatedConditions); - }; - - const removeCondition = (index: number) => { - const conditionToRemove = conditions[index]; - - // 그룹 시작/끝을 삭제하는 경우 해당 그룹 전체 삭제 - if (conditionToRemove.type === "group-start" || conditionToRemove.type === "group-end") { - removeGroup(conditionToRemove.groupId!); - } else { - const updatedConditions = conditions.filter((_, i) => i !== index); - setConditions(updatedConditions); - } - }; - - // 그룹 전체 삭제 - const removeGroup = (groupId: string) => { - const updatedConditions = conditions.filter((c) => c.groupId !== groupId); - setConditions(updatedConditions); - }; - - // 현재 조건의 그룹 레벨 계산 - const getCurrentGroupLevel = (conditionIndex: number): number => { - let level = 0; - for (let i = 0; i < conditionIndex; i++) { - const condition = conditions[i]; - if (condition.type === "group-start") { - level++; - } else if (condition.type === "group-end") { - level--; - } - } - return level; - }; - - // 액션별 조건 그룹 관리 함수들 - const addActionGroupStart = (actionIndex: number) => { - const groupId = `action_group_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - const currentConditions = dataSaveSettings.actions[actionIndex].conditions || []; - const groupLevel = getActionNextGroupLevel(currentConditions); - - const groupStart: ConditionNode = { - id: generateId(), - type: "group-start" as const, - groupId, - groupLevel, - // 첫 번째 그룹이 아니면 logicalOperator 추가 - ...(currentConditions.length > 0 && { logicalOperator: "AND" as const }), - }; - - const newActions = [...dataSaveSettings.actions]; - newActions[actionIndex].conditions = [...currentConditions, groupStart]; - setDataSaveSettings({ ...dataSaveSettings, actions: newActions }); - }; - - const addActionGroupEnd = (actionIndex: number) => { - const currentConditions = dataSaveSettings.actions[actionIndex].conditions || []; - const openGroups = findActionOpenGroups(currentConditions); - - if (openGroups.length === 0) { - toast.error("닫을 그룹이 없습니다."); - return; - } - - const lastOpenGroup = openGroups[openGroups.length - 1]; - const groupEnd: ConditionNode = { - id: generateId(), - type: "group-end" as const, - groupId: lastOpenGroup.groupId, - groupLevel: lastOpenGroup.groupLevel, - }; - - const newActions = [...dataSaveSettings.actions]; - newActions[actionIndex].conditions = [...currentConditions, groupEnd]; - setDataSaveSettings({ ...dataSaveSettings, actions: newActions }); - }; - - // 액션별 다음 그룹 레벨 계산 - const getActionNextGroupLevel = (conditions: ConditionNode[]): number => { - const openGroups = findActionOpenGroups(conditions); - return openGroups.length; - }; - - // 액션별 열린 그룹 찾기 - const findActionOpenGroups = (conditions: ConditionNode[]) => { - const openGroups: Array<{ groupId: string; groupLevel: number }> = []; - - for (const condition of conditions) { - if (condition.type === "group-start") { - openGroups.push({ - groupId: condition.groupId!, - groupLevel: condition.groupLevel!, - }); - } else if (condition.type === "group-end") { - const groupIndex = openGroups.findIndex((g) => g.groupId === condition.groupId); - if (groupIndex !== -1) { - openGroups.splice(groupIndex, 1); - } - } - } - - return openGroups; - }; - - // 액션별 현재 조건의 그룹 레벨 계산 - const getActionCurrentGroupLevel = (conditions: ConditionNode[], conditionIndex: number): number => { - let level = 0; - for (let i = 0; i < conditionIndex; i++) { - const condition = conditions[i]; - if (condition.type === "group-start") { - level++; - } else if (condition.type === "group-end") { - level--; - } - } - return level; - }; - - // 액션별 조건 렌더링 함수 - const renderActionCondition = (condition: ConditionNode, condIndex: number, actionIndex: number) => { - // 그룹 시작 렌더링 - if (condition.type === "group-start") { - return ( -
- {/* 그룹 시작 앞의 논리 연산자 */} - {condIndex > 0 && ( - - )} -
- ( - 그룹 시작 - -
-
- ); - } - - // 그룹 끝 렌더링 - if (condition.type === "group-end") { - return ( -
-
- ) - 그룹 끝 - -
-
- ); - } - - // 일반 조건 렌더링 (기존 로직 간소화) - return ( -
- {/* 그룹 내 첫 번째 조건이 아닐 때만 논리 연산자 표시 */} - {condIndex > 0 && dataSaveSettings.actions[actionIndex].conditions![condIndex - 1]?.type !== "group-start" && ( - - )} -
- - - {/* 데이터 타입에 따른 동적 입력 컴포넌트 */} - {(() => { - const selectedColumn = fromTableColumns.find((col) => col.columnName === condition.field); - const dataType = selectedColumn?.dataType?.toLowerCase() || "string"; - - if (dataType.includes("timestamp") || dataType.includes("datetime") || dataType.includes("date")) { - return ( - { - const newActions = [...dataSaveSettings.actions]; - newActions[actionIndex].conditions![condIndex].value = e.target.value; - setDataSaveSettings({ ...dataSaveSettings, actions: newActions }); - }} - className="h-6 flex-1 text-xs" - /> - ); - } else if (dataType.includes("time")) { - return ( - { - const newActions = [...dataSaveSettings.actions]; - newActions[actionIndex].conditions![condIndex].value = e.target.value; - setDataSaveSettings({ ...dataSaveSettings, actions: newActions }); - }} - className="h-6 flex-1 text-xs" - /> - ); - } else if (dataType.includes("date")) { - return ( - { - const newActions = [...dataSaveSettings.actions]; - newActions[actionIndex].conditions![condIndex].value = e.target.value; - setDataSaveSettings({ ...dataSaveSettings, actions: newActions }); - }} - className="h-6 flex-1 text-xs" - /> - ); - } else if ( - dataType.includes("int") || - dataType.includes("numeric") || - dataType.includes("decimal") || - dataType.includes("float") || - dataType.includes("double") - ) { - return ( - { - const newActions = [...dataSaveSettings.actions]; - newActions[actionIndex].conditions![condIndex].value = e.target.value; - setDataSaveSettings({ ...dataSaveSettings, actions: newActions }); - }} - className="h-6 flex-1 text-xs" - /> - ); - } else if (dataType.includes("bool")) { - return ( - - ); - } else { - return ( - { - const newActions = [...dataSaveSettings.actions]; - newActions[actionIndex].conditions![condIndex].value = e.target.value; - setDataSaveSettings({ ...dataSaveSettings, actions: newActions }); - }} - className="h-6 flex-1 text-xs" - /> - ); - } - })()} - -
-
- ); - }; - - // 조건부 연결 설정 UI 렌더링 - const renderConditionalSettings = () => { - return ( -
-
- - 전체 실행 조건 (언제 이 연결이 동작할지) -
- - {/* 실행 조건 설정 */} -
-
- -
- - - -
-
- - {/* 조건 목록 */} -
- {conditions.length === 0 ? ( -
- 조건을 추가하면 해당 조건을 만족할 때만 실행됩니다. -
- 조건이 없으면 항상 실행됩니다. -
- ) : ( - - {conditions.map((condition, index) => { - // 그룹 시작 렌더링 - if (condition.type === "group-start") { - return ( -
- {/* 그룹 시작 앞의 논리 연산자 - 이전 요소가 group-end가 아닌 경우에만 표시 */} - {index > 0 && conditions[index - 1]?.type !== "group-end" && ( - - )} - {/* 그룹 레벨에 따른 들여쓰기 */} -
- ( - 그룹 시작 - -
-
- ); - } - - // 그룹 끝 렌더링 - if (condition.type === "group-end") { - return ( -
-
- ) - 그룹 끝 - -
- - {/* 그룹 끝 다음에 다른 조건이나 그룹이 있으면 논리 연산자 표시 */} - {index < conditions.length - 1 && ( - - )} -
- ); - } - - // 일반 조건 렌더링 - return ( -
- {/* 일반 조건 앞의 논리 연산자 - 이전 요소가 group-end가 아닌 경우에만 표시 */} - {index > 0 && - conditions[index - 1]?.type !== "group-start" && - conditions[index - 1]?.type !== "group-end" && ( - - )} - - {/* 그룹 레벨에 따른 들여쓰기와 조건 필드들 */} -
- {/* 조건 필드 선택 */} - - - {/* 연산자 선택 */} - - - {/* 데이터 타입에 따른 동적 입력 컴포넌트 */} - {(() => { - const selectedColumn = fromTableColumns.find((col) => col.columnName === condition.field); - const dataType = selectedColumn?.dataType?.toLowerCase() || "string"; - - if ( - dataType.includes("timestamp") || - dataType.includes("datetime") || - dataType.includes("date") - ) { - return ( - updateCondition(index, "value", e.target.value)} - className="h-8 flex-1 text-xs" - /> - ); - } else if (dataType.includes("time")) { - return ( - updateCondition(index, "value", e.target.value)} - className="h-8 flex-1 text-xs" - /> - ); - } else if (dataType.includes("date")) { - return ( - updateCondition(index, "value", e.target.value)} - className="h-8 flex-1 text-xs" - /> - ); - } else if ( - dataType.includes("int") || - dataType.includes("numeric") || - dataType.includes("decimal") || - dataType.includes("float") || - dataType.includes("double") - ) { - return ( - updateCondition(index, "value", e.target.value)} - className="h-8 flex-1 text-xs" - /> - ); - } else if (dataType.includes("bool")) { - return ( - - ); - } else { - return ( - updateCondition(index, "value", e.target.value)} - className="h-8 flex-1 text-xs" - /> - ); - } - })()} - - {/* 삭제 버튼 */} - -
-
- ); - })} -
- )} -
-
-
- ); - }; - // 연결 종류별 설정 패널 렌더링 const renderConnectionTypeSettings = () => { switch (config.connectionType) { case "simple-key": return ( -
- {/* 테이블 및 컬럼 선택 */} -
-
테이블 및 컬럼 선택
- - {/* 현재 선택된 테이블 표시 */} -
-
- -
- - {availableTables.find((t) => t.tableName === selectedFromTable)?.displayName || selectedFromTable} - - ({selectedFromTable}) -
-
- -
- -
- - {availableTables.find((t) => t.tableName === selectedToTable)?.displayName || selectedToTable} - - ({selectedToTable}) -
-
-
- - {/* 컬럼 선택 */} -
-
- -
- {fromTableColumns.map((column) => ( - - ))} - {fromTableColumns.length === 0 && ( -
- {selectedFromTable ? "컬럼을 불러오는 중..." : "테이블을 먼저 선택해주세요"} -
- )} -
-
- -
- -
- {toTableColumns.map((column) => ( - - ))} - {toTableColumns.length === 0 && ( -
- {selectedToTable ? "컬럼을 불러오는 중..." : "테이블을 먼저 선택해주세요"} -
- )} -
-
-
- - {/* 선택된 컬럼 미리보기 */} - {(selectedFromColumns.length > 0 || selectedToColumns.length > 0) && ( -
-
- -
- {selectedFromColumns.length > 0 ? ( - selectedFromColumns.map((column) => ( - - {column} - - )) - ) : ( - 선택된 컬럼 없음 - )} -
-
- -
- -
- {selectedToColumns.length > 0 ? ( - selectedToColumns.map((column) => ( - - {column} - - )) - ) : ( - 선택된 컬럼 없음 - )} -
-
-
- )} -
- - {/* 단순 키값 연결 설정 */} -
-
- - 단순 키값 연결 설정 -
-
-
- -