From 31bd9c26b7965dccf406a62a286eb8492c7645b1 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 24 Oct 2025 17:27:22 +0900 Subject: [PATCH 1/3] =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EC=A0=95=EB=A0=AC?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/api-client-usage.mdc | 209 +++++++++++ .cursor/rules/table-with-sticky-header.mdc | 343 +++++++++++++++++ .../dataflow/node-editor/FlowEditor.tsx | 10 +- frontend/components/screen/ScreenDesigner.tsx | 350 +++++++++++++----- .../components/screen/widgets/FlowWidget.tsx | 36 +- 5 files changed, 843 insertions(+), 105 deletions(-) create mode 100644 .cursor/rules/api-client-usage.mdc create mode 100644 .cursor/rules/table-with-sticky-header.mdc diff --git a/.cursor/rules/api-client-usage.mdc b/.cursor/rules/api-client-usage.mdc new file mode 100644 index 00000000..ff7e2e69 --- /dev/null +++ b/.cursor/rules/api-client-usage.mdc @@ -0,0 +1,209 @@ +--- +alwaysApply: true +description: API 요청 시 항상 전용 API 클라이언트를 사용하도록 강제하는 규칙 +--- + +# API 클라이언트 사용 규칙 + +## 핵심 원칙 + +**절대 `fetch`를 직접 사용하지 않고, 반드시 전용 API 클라이언트를 사용해야 합니다.** + +## 이유 + +1. **환경별 URL 자동 처리**: 프로덕션(`v1.vexplor.com`)과 개발(`localhost`) 환경에서 올바른 백엔드 서버로 요청 +2. **일관된 에러 처리**: 모든 API 호출에서 동일한 에러 핸들링 +3. **인증 토큰 자동 포함**: Authorization 헤더 자동 추가 +4. **유지보수성**: API 변경 시 한 곳에서만 수정 + +## API 클라이언트 위치 + +``` +frontend/lib/api/ +├── client.ts # Axios 기반 공통 클라이언트 +├── flow.ts # 플로우 관리 API +├── dashboard.ts # 대시보드 API +├── mail.ts # 메일 API +├── externalCall.ts # 외부 호출 API +├── company.ts # 회사 관리 API +└── file.ts # 파일 업로드/다운로드 API +``` + +## 올바른 사용법 + +### ❌ 잘못된 방법 (절대 사용 금지) + +```typescript +// 직접 fetch 사용 - 환경별 URL이 자동 처리되지 않음 +const response = await fetch("/api/flow/definitions/29/steps"); +const data = await response.json(); + +// 상대 경로 - 프로덕션에서 잘못된 도메인으로 요청 +const response = await fetch(`/api/flow/${flowId}/steps`); +``` + +### ✅ 올바른 방법 + +```typescript +// 1. API 클라이언트 함수 import +import { getFlowSteps } from "@/lib/api/flow"; + +// 2. 함수 호출 +const stepsResponse = await getFlowSteps(flowId); +if (stepsResponse.success && stepsResponse.data) { + setSteps(stepsResponse.data); +} +``` + +## 주요 API 클라이언트 함수 + +### 플로우 관리 ([flow.ts](mdc:frontend/lib/api/flow.ts)) + +```typescript +import { + getFlowDefinitions, // 플로우 목록 + getFlowById, // 플로우 상세 + createFlowDefinition, // 플로우 생성 + updateFlowDefinition, // 플로우 수정 + deleteFlowDefinition, // 플로우 삭제 + getFlowSteps, // 스텝 목록 ⭐ + createFlowStep, // 스텝 생성 + updateFlowStep, // 스텝 수정 + deleteFlowStep, // 스텝 삭제 + getFlowConnections, // 연결 목록 ⭐ + createFlowConnection, // 연결 생성 + deleteFlowConnection, // 연결 삭제 + getStepDataCount, // 스텝 데이터 카운트 + getStepDataList, // 스텝 데이터 목록 + getAllStepCounts, // 모든 스텝 카운트 + moveData, // 데이터 이동 + moveBatchData, // 배치 데이터 이동 + getAuditLogs, // 오딧 로그 +} from "@/lib/api/flow"; +``` + +### Axios 클라이언트 ([client.ts](mdc:frontend/lib/api/client.ts)) + +```typescript +import apiClient from "@/lib/api/client"; + +// GET 요청 +const response = await apiClient.get("/api/endpoint"); + +// POST 요청 +const response = await apiClient.post("/api/endpoint", { data }); + +// PUT 요청 +const response = await apiClient.put("/api/endpoint", { data }); + +// DELETE 요청 +const response = await apiClient.delete("/api/endpoint"); +``` + +## 새로운 API 함수 추가 가이드 + +기존 API 클라이언트에 함수가 없는 경우: + +```typescript +// frontend/lib/api/yourModule.ts + +// 1. API URL 동적 설정 (필수) +const getApiBaseUrl = (): string => { + if (process.env.NEXT_PUBLIC_API_URL) { + return process.env.NEXT_PUBLIC_API_URL; + } + + if (typeof window !== "undefined") { + const currentHost = window.location.hostname; + + // 프로덕션: v1.vexplor.com → api.vexplor.com + if (currentHost === "v1.vexplor.com") { + return "https://api.vexplor.com/api"; + } + + // 로컬 개발 + if (currentHost === "localhost" || currentHost === "127.0.0.1") { + return "http://localhost:8080/api"; + } + } + + return "/api"; +}; + +const API_BASE = getApiBaseUrl(); + +// 2. API 함수 작성 +export async function getYourData(id: number): Promise> { + try { + const response = await fetch(`${API_BASE}/your-endpoint/${id}`, { + credentials: "include", + }); + + return await response.json(); + } catch (error: any) { + return { + success: false, + error: error.message, + }; + } +} +``` + +## 환경별 URL 매핑 + +API 클라이언트는 자동으로 환경을 감지합니다: + +| 현재 호스트 | 백엔드 API URL | +| ---------------- | ----------------------------- | +| `v1.vexplor.com` | `https://api.vexplor.com/api` | +| `localhost:9771` | `http://localhost:8080/api` | +| `localhost:3000` | `http://localhost:8080/api` | + +## 체크리스트 + +코드 작성 시 다음을 확인하세요: + +- [ ] `fetch('/api/...')` 직접 사용하지 않음 +- [ ] 적절한 API 클라이언트 함수를 import 함 +- [ ] API 응답의 `success` 필드를 체크함 +- [ ] 에러 처리를 구현함 +- [ ] 새로운 API가 필요하면 `lib/api/` 에 함수 추가 + +## 예외 상황 + +다음 경우에만 `fetch`를 직접 사용할 수 있습니다: + +1. **외부 서비스 호출**: 다른 도메인의 API 호출 시 +2. **특수한 헤더가 필요한 경우**: FormData, Blob 등 + +이 경우에도 가능하면 전용 API 클라이언트 함수로 래핑하세요. + +## 실제 적용 예시 + +### 플로우 위젯 ([FlowWidget.tsx](mdc:frontend/components/screen/widgets/FlowWidget.tsx)) + +```typescript +// ❌ 이전 코드 +const stepsResponse = await fetch(`/api/flow/definitions/${flowId}/steps`); +const connectionsResponse = await fetch(`/api/flow/connections/${flowId}`); + +// ✅ 수정된 코드 +const stepsResponse = await getFlowSteps(flowId); +const connectionsResponse = await getFlowConnections(flowId); +``` + +### 플로우 가시성 패널 ([FlowVisibilityConfigPanel.tsx](mdc:frontend/components/screen/config-panels/FlowVisibilityConfigPanel.tsx)) + +```typescript +// ❌ 이전 코드 +const stepsResponse = await fetch(`/api/flow/definitions/${flowId}/steps`); + +// ✅ 수정된 코드 +const stepsResponse = await getFlowSteps(flowId); +``` + +## 참고 자료 + +- [API 클라이언트 공통 설정](mdc:frontend/lib/api/client.ts) +- [플로우 API 클라이언트](mdc:frontend/lib/api/flow.ts) +- [API URL 유틸리티](mdc:frontend/lib/utils/apiUrl.ts) diff --git a/.cursor/rules/table-with-sticky-header.mdc b/.cursor/rules/table-with-sticky-header.mdc new file mode 100644 index 00000000..cc4c544f --- /dev/null +++ b/.cursor/rules/table-with-sticky-header.mdc @@ -0,0 +1,343 @@ +# 고정 헤더 테이블 표준 가이드 + +## 개요 + +스크롤 가능한 테이블에서 헤더를 상단에 고정하는 표준 구조입니다. +플로우 위젯의 스텝 데이터 리스트 테이블을 참조 기준으로 합니다. + +## 필수 구조 + +### 1. 기본 HTML 구조 + +```tsx +
+ + + + + 헤더 1 + + + 헤더 2 + + + + {/* 데이터 행들 */} +
+
+``` + +### 2. 필수 클래스 설명 + +#### 스크롤 컨테이너 (외부 div) + +```tsx +className="relative overflow-auto" +style={{ height: "450px" }} +``` + +**필수 요소:** + +- `relative`: sticky positioning의 기준점 +- `overflow-auto`: 스크롤 활성화 +- `height`: 고정 높이 (인라인 스타일 또는 Tailwind 클래스) + +#### Table 컴포넌트 + +```tsx + +``` + +**필수 props:** + +- `noWrapper`: Table 컴포넌트의 내부 wrapper 제거 (매우 중요!) + - 이것이 없으면 sticky header가 작동하지 않음 + +#### TableHead (헤더 셀) + +```tsx +className = + "bg-background sticky top-0 z-10 border-b shadow-[0_1px_0_0_rgb(0,0,0,0.1)]"; +``` + +**필수 클래스:** + +- `bg-background`: 배경색 (스크롤 시 데이터가 보이지 않도록) +- `sticky top-0`: 상단 고정 +- `z-10`: 다른 요소 위에 표시 +- `border-b`: 하단 테두리 +- `shadow-[0_1px_0_0_rgb(0,0,0,0.1)]`: 얇은 그림자 (헤더와 본문 구분) + +### 3. 왼쪽 열 고정 (체크박스 등) + +첫 번째 열도 고정하려면: + +```tsx + + + +``` + +**z-index 규칙:** + +- 왼쪽+상단 고정: `z-20` +- 상단만 고정: `z-10` +- 왼쪽만 고정: `z-10` +- 일반 셀: z-index 없음 + +### 4. 완전한 예제 (체크박스 포함) + +```tsx +
+
+ + + {/* 왼쪽 고정 체크박스 열 */} + + + + + {/* 일반 헤더 열들 */} + {columns.map((col) => ( + + {col} + + ))} + + + + + {data.map((row, index) => ( + + {/* 왼쪽 고정 체크박스 */} + + toggleRow(index)} + /> + + + {/* 데이터 셀들 */} + {columns.map((col) => ( + + {row[col]} + + ))} + + ))} + +
+ +``` + +## 반응형 대응 + +### 모바일: 카드 뷰 + +```tsx +{ + /* 모바일: 카드 뷰 */ +} +
+
+ {data.map((item, index) => ( +
+ {/* 카드 내용 */} +
+ ))} +
+
; + +{ + /* 데스크톱: 테이블 뷰 */ +} +
+ {/* 위의 테이블 구조 */}
+
; +``` + +## 자주하는 실수 + +### ❌ 잘못된 예시 + +```tsx +{ + /* 1. noWrapper 없음 - sticky 작동 안함 */ +} + + ... +
; + +{ + /* 2. 배경색 없음 - 스크롤 시 데이터가 보임 */ +} +헤더; + +{ + /* 3. relative 없음 - sticky 기준점 없음 */ +} +
+ ...
+
; + +{ + /* 4. 고정 높이 없음 - 스크롤 발생 안함 */ +} +
+ ...
+
; +``` + +### ✅ 올바른 예시 + +```tsx +{ + /* 모든 필수 요소 포함 */ +} +
+ + + + + 헤더 + + + + ... +
+
; +``` + +## 높이 설정 가이드 + +### 권장 높이값 + +- **소형 리스트**: `300px` ~ `400px` +- **중형 리스트**: `450px` ~ `600px` (플로우 위젯 기준) +- **대형 리스트**: `calc(100vh - 200px)` (화면 높이 기준) + +### 동적 높이 계산 + +```tsx +// 화면 높이의 60% +style={{ height: "60vh" }} + +// 화면 높이 - 헤더/푸터 제외 +style={{ height: "calc(100vh - 250px)" }} + +// 부모 요소 기준 +className="h-full overflow-auto" +``` + +## 성능 최적화 + +### 1. 가상 스크롤 (대량 데이터) + +데이터가 1000건 이상인 경우 `react-virtual` 사용 권장: + +```tsx +import { useVirtualizer } from "@tanstack/react-virtual"; + +const parentRef = useRef(null); + +const rowVirtualizer = useVirtualizer({ + count: data.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 50, // 행 높이 +}); +``` + +### 2. 페이지네이션 + +대량 데이터는 페이지 단위로 렌더링: + +```tsx +const paginatedData = data.slice((page - 1) * pageSize, page * pageSize); +``` + +## 접근성 + +### ARIA 레이블 + +```tsx +
+ + {/* 테이블 내용 */} +
+
+``` + +### 키보드 네비게이션 + +```tsx + { + if (e.key === "Enter" || e.key === " ") { + handleRowClick(); + } + }} +> + {/* 행 내용 */} + +``` + +## 다크 모드 대응 + +### 배경색 + +```tsx +{ + /* 라이트/다크 모드 모두 대응 */ +} +className = "bg-background"; // ✅ 권장 + +{ + /* 고정 색상 - 다크 모드 문제 */ +} +className = "bg-white"; // ❌ 비권장 +``` + +### 그림자 + +```tsx +{ + /* 다크 모드에서도 보이는 그림자 */ +} +className = "shadow-[0_1px_0_0_hsl(var(--border))]"; + +{ + /* 또는 */ +} +className = "shadow-[0_1px_0_0_rgb(0,0,0,0.1)]"; +``` + +## 참조 파일 + +- **구현 예시**: `frontend/components/screen/widgets/FlowWidget.tsx` (line 760-820) +- **Table 컴포넌트**: `frontend/components/ui/table.tsx` + +## 체크리스트 + +테이블 구현 시 다음을 확인하세요: + +- [ ] 외부 div에 `relative overflow-auto` 적용 +- [ ] 외부 div에 고정 높이 설정 +- [ ] `` 사용 +- [ ] TableHead에 `bg-background sticky top-0 z-10` 적용 +- [ ] TableHead에 `border-b shadow-[...]` 적용 +- [ ] 왼쪽 고정 열은 `z-20` 사용 +- [ ] 모바일 반응형 대응 (카드 뷰) +- [ ] 다크 모드 호환 색상 사용 diff --git a/frontend/components/dataflow/node-editor/FlowEditor.tsx b/frontend/components/dataflow/node-editor/FlowEditor.tsx index f144a7a8..c87c80aa 100644 --- a/frontend/components/dataflow/node-editor/FlowEditor.tsx +++ b/frontend/components/dataflow/node-editor/FlowEditor.tsx @@ -147,6 +147,14 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) { flowData.nodes || [], flowData.edges || [], ); + + // 🆕 플로우 로드 후 첫 번째 노드 자동 선택 + if (flowData.nodes && flowData.nodes.length > 0) { + const firstNode = flowData.nodes[0]; + selectNodes([firstNode.id]); + setShowPropertiesPanelLocal(true); + console.log("✅ 첫 번째 노드 자동 선택:", firstNode.id); + } } } catch (error) { console.error("플로우 로드 실패:", error); @@ -155,7 +163,7 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) { }; fetchAndLoadFlow(); - }, [initialFlowId]); + }, [initialFlowId, loadFlow, selectNodes]); /** * 노드 선택 변경 핸들러 diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index edd5c7d0..4784fe38 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -3076,7 +3076,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD }) : null; - // 일반 컴포넌트만 격자 스냅 적용 (그룹 제외) + // 일반 컴포넌트 및 플로우 버튼 그룹에 격자 스냅 적용 (일반 그룹 제외) if (draggedComponent?.type !== "group" && layout.gridSettings?.snapToGrid && currentGridInfo) { finalPosition = snapToGrid( { @@ -3094,6 +3094,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD ); console.log("🎯 격자 스냅 적용됨:", { + componentType: draggedComponent?.type, resolution: `${screenResolution.width}x${screenResolution.height}`, originalPosition: dragState.currentPosition, snappedPosition: finalPosition, @@ -3516,12 +3517,79 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD // 고유한 그룹 ID 생성 const newGroupId = generateGroupId(); - // 버튼들을 그룹으로 묶기 (설정 포함) + // 🔧 그룹 위치 및 버튼 재배치 계산 + const align = settings.align; + const direction = settings.direction; + const gap = settings.gap; + + const groupY = Math.min(...selectedComponents.map((b) => b.position.y)); + let anchorButton; // 기준이 되는 버튼 + let groupX: number; + + // align에 따라 기준 버튼과 그룹 시작점 결정 + if (direction === "horizontal") { + if (align === "end") { + // 끝점 정렬: 가장 오른쪽 버튼이 기준 + anchorButton = selectedComponents.reduce((max, btn) => { + const rightEdge = btn.position.x + (btn.size?.width || 100); + const maxRightEdge = max.position.x + (max.size?.width || 100); + return rightEdge > maxRightEdge ? btn : max; + }); + + // 전체 그룹 너비 계산 + const totalWidth = selectedComponents.reduce((total, btn, index) => { + const buttonWidth = btn.size?.width || 100; + const gapWidth = index < selectedComponents.length - 1 ? gap : 0; + return total + buttonWidth + gapWidth; + }, 0); + + // 그룹 시작점 = 기준 버튼의 오른쪽 끝 - 전체 그룹 너비 + groupX = anchorButton.position.x + (anchorButton.size?.width || 100) - totalWidth; + } else if (align === "center") { + // 중앙 정렬: 버튼들의 중심점을 기준으로 + const minX = Math.min(...selectedComponents.map((b) => b.position.x)); + const maxX = Math.max(...selectedComponents.map((b) => b.position.x + (b.size?.width || 100))); + const centerX = (minX + maxX) / 2; + + const totalWidth = selectedComponents.reduce((total, btn, index) => { + const buttonWidth = btn.size?.width || 100; + const gapWidth = index < selectedComponents.length - 1 ? gap : 0; + return total + buttonWidth + gapWidth; + }, 0); + + groupX = centerX - totalWidth / 2; + anchorButton = selectedComponents[0]; // 중앙 정렬은 첫 번째 버튼 기준 + } else { + // 시작점 정렬: 가장 왼쪽 버튼이 기준 + anchorButton = selectedComponents.reduce((min, btn) => { + return btn.position.x < min.position.x ? btn : min; + }); + groupX = anchorButton.position.x; + } + } else { + // 세로 정렬: 가장 위쪽 버튼이 기준 + anchorButton = selectedComponents.reduce((min, btn) => { + return btn.position.y < min.position.y ? btn : min; + }); + groupX = Math.min(...selectedComponents.map((b) => b.position.x)); + } + + // 🔧 버튼들의 위치를 그룹 기준으로 재배치 + // 기준 버튼의 절대 위치를 유지하고, FlexBox가 나머지를 자동 정렬 const groupedButtons = selectedComponents.map((button) => { const currentConfig = (button as any).webTypeConfig?.flowVisibilityConfig || {}; + // 모든 버튼을 그룹 시작점에 배치 + // FlexBox가 자동으로 정렬하여 기준 버튼의 위치가 유지됨 + const newPosition = { + x: groupX, + y: groupY, + z: button.position.z || 1, + }; + return { ...button, + position: newPosition, webTypeConfig: { ...(button as any).webTypeConfig, flowVisibilityConfig: { @@ -3558,7 +3626,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD console.log("✅ 플로우 버튼 그룹 생성 완료:", { groupId: newGroupId, buttonCount: selectedComponents.length, - buttons: selectedComponents.map((b) => b.id), + buttons: selectedComponents.map((b) => ({ id: b.id, position: b.position })), + groupPosition: { x: groupX, y: groupY }, settings, }); }, @@ -4316,86 +4385,109 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
🔍 {Math.round(zoomLevel * 100)}%
- {/* 🆕 플로우 버튼 그룹 제어 (다중 선택 시 표시) */} - {groupState.selectedComponents.length >= 2 && ( -
-
-
- - - - - - {groupState.selectedComponents.length}개 선택됨 + {/* 🆕 플로우 버튼 그룹 제어 (버튼 선택 시 표시) */} + {(() => { + // 선택된 컴포넌트들 + const selectedComps = layout.components.filter((c) => groupState.selectedComponents.includes(c.id)); + + // 버튼 컴포넌트만 필터링 + const selectedButtons = selectedComps.filter((comp) => areAllButtons([comp])); + + // 플로우 그룹에 속한 버튼이 있는지 확인 + const hasFlowGroupButton = selectedButtons.some((btn) => { + const flowConfig = (btn as any).webTypeConfig?.flowVisibilityConfig; + return flowConfig?.enabled && flowConfig.layoutBehavior === "auto-compact" && flowConfig.groupId; + }); + + // 버튼이 선택되었거나 플로우 그룹 버튼이 있으면 표시 + const shouldShow = selectedButtons.length >= 1 && (selectedButtons.length >= 2 || hasFlowGroupButton); + + if (!shouldShow) return null; + + return ( +
+
+
+ + + + + + {selectedButtons.length}개 버튼 선택됨 +
+ + {/* 그룹 생성 버튼 (2개 이상 선택 시) */} + {selectedButtons.length >= 2 && ( + + )} + + {/* 그룹 해제 버튼 (플로우 그룹 버튼이 있으면 항상 표시) */} + {hasFlowGroupButton && ( + + )} + + {/* 상태 표시 */} + {hasFlowGroupButton &&

✓ 플로우 그룹 버튼

}
- - - {areAllButtons(layout.components.filter((c) => groupState.selectedComponents.includes(c.id))) ? ( -

✓ 모두 버튼 컴포넌트

- ) : ( -

⚠ 버튼만 그룹 가능

- )}
-
- )} + ); + })()} {/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 */}
({ - x: Math.min(min.x, button.position.x), - y: Math.min(min.y, button.position.y), - z: min.z, - }), - { x: buttons[0].position.x, y: buttons[0].position.y, z: buttons[0].position.z || 2 }, - ); - - // 🆕 그룹의 크기 계산: 버튼들의 실제 크기 + 간격을 기준으로 계산 + // 🔧 그룹의 위치 및 크기 계산 + // 모든 버튼이 같은 위치(groupX, groupY)에 배치되어 있으므로 + // 첫 번째 버튼의 위치를 그룹 시작점으로 사용 const direction = groupConfig.groupDirection || "horizontal"; const gap = groupConfig.groupGap ?? 8; + const align = groupConfig.groupAlign || "start"; + const groupPosition = { + x: buttons[0].position.x, + y: buttons[0].position.y, + z: buttons[0].position.z || 2, + }; + + // 버튼들의 실제 크기 계산 let groupWidth = 0; let groupHeight = 0; @@ -4731,12 +4823,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const gapWidth = index < buttons.length - 1 ? gap : 0; return total + buttonWidth + gapWidth; }, 0); - // 세로는 가장 큰 버튼의 높이 groupHeight = Math.max(...buttons.map((b) => b.size?.height || 40)); } else { - // 세로 정렬: 가로는 가장 큰 버튼의 너비 + // 세로 정렬 groupWidth = Math.max(...buttons.map((b) => b.size?.width || 100)); - // 세로는 모든 버튼의 높이 + 간격 groupHeight = buttons.reduce((total, button, index) => { const buttonHeight = button.size?.height || 40; const gapHeight = index < buttons.length - 1 ? gap : 0; @@ -4744,6 +4834,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD }, 0); } + // 🆕 그룹 전체가 선택되었는지 확인 + const isGroupSelected = buttons.every( + (btn) => selectedComponent?.id === btn.id || groupState.selectedComponents.includes(btn.id), + ); + const hasAnySelected = buttons.some( + (btn) => selectedComponent?.id === btn.id || groupState.selectedComponents.includes(btn.id), + ); + return (
{ + onMouseDown={(e) => { + // 클릭이 아닌 드래그인 경우에만 드래그 시작 + e.preventDefault(); e.stopPropagation(); - handleComponentClick(button, e); + + const startX = e.clientX; + const startY = e.clientY; + let isDragging = false; + let dragStarted = false; + + const handleMouseMove = (moveEvent: MouseEvent) => { + const deltaX = Math.abs(moveEvent.clientX - startX); + const deltaY = Math.abs(moveEvent.clientY - startY); + + // 5픽셀 이상 움직이면 드래그로 간주 + if ((deltaX > 5 || deltaY > 5) && !dragStarted) { + isDragging = true; + dragStarted = true; + + // Shift 키를 누르지 않았으면 같은 그룹의 버튼들도 모두 선택 + if (!e.shiftKey) { + const buttonIds = buttons.map((b) => b.id); + setGroupState((prev) => ({ + ...prev, + selectedComponents: buttonIds, + })); + } + + // 드래그 시작 + startComponentDrag(button, e as any); + } + }; + + const handleMouseUp = () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + + // 드래그가 아니면 클릭으로 처리 + if (!isDragging) { + // Shift 키를 누르지 않았으면 같은 그룹의 버튼들도 모두 선택 + if (!e.shiftKey) { + const buttonIds = buttons.map((b) => b.id); + setGroupState((prev) => ({ + ...prev, + selectedComponents: buttonIds, + })); + } + handleComponentClick(button, e); + } + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); }} onDoubleClick={(e) => { e.stopPropagation(); @@ -4817,12 +4969,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD className={ selectedComponent?.id === button.id || groupState.selectedComponents.includes(button.id) - ? "outline outline-2 outline-offset-2 outline-blue-500" + ? "outline-1 outline-offset-1 outline-blue-400" : "" } > {/* 그룹 내부에서는 DynamicComponentRenderer로 직접 렌더링 */} -
+
0) { + const firstStep = sortedSteps[0]; + setSelectedStepId(firstStep.id); + setSelectedStep(flowComponentId, firstStep.id); + console.log("✅ [FlowWidget] 첫 번째 단계 자동 선택:", { + flowComponentId, + stepId: firstStep.id, + stepName: firstStep.stepName, + }); + + // 첫 번째 스텝의 데이터 로드 + try { + const response = await getStepDataList(flowId!, firstStep.id, 1, 100); + if (response.success) { + const rows = response.data?.records || []; + setStepData(rows); + if (rows.length > 0) { + setStepDataColumns(Object.keys(rows[0])); + } + } + } catch (err) { + console.error("첫 번째 스텝 데이터 로드 실패:", err); + } + } } } catch (err: any) { console.error("Failed to load flow data:", err); @@ -732,12 +758,12 @@ export function FlowWidget({
{/* 데스크톱: 테이블 뷰 - 고정 높이 + 스크롤 */} -
-
+
+
- + {allowDataMove && ( - + 0} onCheckedChange={toggleAllRows} @@ -747,7 +773,7 @@ export function FlowWidget({ {stepDataColumns.map((col) => ( {col} -- 2.43.0 From 6d54a4c9ea40066f3b68a12407d23cfd3b44f55b Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 24 Oct 2025 18:05:11 +0900 Subject: [PATCH 2/3] =?UTF-8?q?=EB=85=B8=EB=93=9C=EB=B3=84=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EA=B2=80=EC=83=89=EC=84=A0=ED=83=9D=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../properties/DeleteActionProperties.tsx | 263 ++- .../properties/InsertActionProperties.tsx | 312 +++- .../properties/ReferenceLookupProperties.tsx | 107 +- .../properties/UpdateActionProperties.tsx | 1594 ++++++++++------- .../properties/UpsertActionProperties.tsx | 316 +++- 5 files changed, 1723 insertions(+), 869 deletions(-) diff --git a/frontend/components/dataflow/node-editor/panels/properties/DeleteActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/DeleteActionProperties.tsx index 3c5995d7..16eca3cd 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/DeleteActionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/DeleteActionProperties.tsx @@ -67,6 +67,19 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP const [tablesOpen, setTablesOpen] = useState(false); const [selectedTableLabel, setSelectedTableLabel] = useState(data.targetTable); + // 내부 DB 컬럼 관련 상태 + interface ColumnInfo { + columnName: string; + columnLabel?: string; + dataType: string; + isNullable: boolean; + } + const [targetColumns, setTargetColumns] = useState([]); + const [columnsLoading, setColumnsLoading] = useState(false); + + // Combobox 열림 상태 관리 + const [fieldOpenState, setFieldOpenState] = useState([]); + useEffect(() => { setDisplayName(data.displayName || `${data.targetTable} 삭제`); setTargetTable(data.targetTable); @@ -101,6 +114,18 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP } }, [targetType, selectedExternalConnectionId, externalTargetTable]); + // 🔥 내부 DB 컬럼 로딩 + useEffect(() => { + if (targetType === "internal" && targetTable) { + loadColumns(targetTable); + } + }, [targetType, targetTable]); + + // whereConditions 변경 시 fieldOpenState 초기화 + useEffect(() => { + setFieldOpenState(new Array(whereConditions.length).fill(false)); + }, [whereConditions.length]); + const loadExternalConnections = async () => { try { setExternalConnectionsLoading(true); @@ -171,6 +196,28 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP } }; + // 🔥 내부 DB 컬럼 로딩 + const loadColumns = async (tableName: string) => { + try { + setColumnsLoading(true); + const response = await tableTypeApi.getColumns(tableName); + if (response && Array.isArray(response)) { + const columnInfos: ColumnInfo[] = response.map((col: any) => ({ + columnName: col.columnName || col.column_name, + columnLabel: col.columnLabel || col.column_label, + dataType: col.dataType || col.data_type || "text", + isNullable: col.isNullable !== undefined ? col.isNullable : true, + })); + setTargetColumns(columnInfos); + } + } catch (error) { + console.error("컬럼 로딩 실패:", error); + setTargetColumns([]); + } finally { + setColumnsLoading(false); + } + }; + const handleTableSelect = (tableName: string) => { const selectedTable = tables.find((t: any) => t.tableName === tableName); const label = (selectedTable as any)?.tableLabel || selectedTable?.displayName || tableName; @@ -186,18 +233,22 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP }; const handleAddCondition = () => { - setWhereConditions([ + const newConditions = [ ...whereConditions, { field: "", operator: "EQUALS", value: "", }, - ]); + ]; + setWhereConditions(newConditions); + setFieldOpenState(new Array(newConditions.length).fill(false)); }; const handleRemoveCondition = (index: number) => { - setWhereConditions(whereConditions.filter((_, i) => i !== index)); + const newConditions = whereConditions.filter((_, i) => i !== index); + setWhereConditions(newConditions); + setFieldOpenState(new Array(newConditions.length).fill(false)); }; const handleConditionChange = (index: number, field: string, value: any) => { @@ -639,64 +690,169 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP + {/* 컬럼 로딩 상태 */} + {targetType === "internal" && targetTable && columnsLoading && ( +
+ 컬럼 정보를 불러오는 중... +
+ )} + + {/* 테이블 미선택 안내 */} + {targetType === "internal" && !targetTable && ( +
+ 먼저 타겟 테이블을 선택하세요 +
+ )} + {whereConditions.length > 0 ? (
- {whereConditions.map((condition, index) => ( -
-
- 조건 #{index + 1} - -
+ {whereConditions.map((condition, index) => { + // 현재 타입에 따라 사용 가능한 컬럼 리스트 결정 + const availableColumns = + targetType === "internal" + ? targetColumns + : targetType === "external" + ? externalColumns.map((col) => ({ + columnName: col.column_name, + columnLabel: col.column_name, + dataType: col.data_type, + isNullable: true, + })) + : []; -
-
- - handleConditionChange(index, "field", e.target.value)} - placeholder="조건 필드명" - className="mt-1 h-8 text-xs" - /> -
- -
- - + +
-
- - handleConditionChange(index, "value", e.target.value)} - placeholder="비교 값" - className="mt-1 h-8 text-xs" - /> +
+ {/* 필드 - Combobox */} +
+ + {availableColumns.length > 0 ? ( + { + const newState = [...fieldOpenState]; + newState[index] = open; + setFieldOpenState(newState); + }} + > + + + + + + + + 필드를 찾을 수 없습니다. + + {availableColumns.map((col) => ( + { + handleConditionChange(index, "field", currentValue); + const newState = [...fieldOpenState]; + newState[index] = false; + setFieldOpenState(newState); + }} + className="text-xs sm:text-sm" + > + +
+ + {col.columnLabel || col.columnName} + + {col.dataType} +
+
+ ))} +
+
+
+
+
+ ) : ( + handleConditionChange(index, "field", e.target.value)} + placeholder="조건 필드명" + className="mt-1 h-8 text-xs" + /> + )} +
+ +
+ + +
+ +
+ + handleConditionChange(index, "value", e.target.value)} + placeholder="비교 값" + className="mt-1 h-8 text-xs" + /> +
-
- ))} + ); + })}
) : (
@@ -705,7 +861,6 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP )}
-
🚨 WHERE 조건 없이 삭제하면 테이블의 모든 데이터가 영구 삭제됩니다! diff --git a/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx index 44a3b7e2..5f3b3220 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx @@ -13,6 +13,7 @@ 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 { ScrollArea } from "@/components/ui/scroll-area"; import { cn } from "@/lib/utils"; import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; import { tableTypeApi } from "@/lib/api/screen"; @@ -37,6 +38,7 @@ interface ColumnInfo { columnLabel?: string; dataType: string; isNullable: boolean; + columnDefault?: string | null; } export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesProps) { @@ -63,6 +65,10 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP // REST API 소스 노드 연결 여부 const [hasRestAPISource, setHasRestAPISource] = useState(false); + // Combobox 열림 상태 관리 (필드 매핑) + const [mappingSourceFieldsOpenState, setMappingSourceFieldsOpenState] = useState([]); + const [mappingTargetFieldsOpenState, setMappingTargetFieldsOpenState] = useState([]); + // 🔥 외부 DB 관련 상태 const [externalConnections, setExternalConnections] = useState([]); const [externalConnectionsLoading, setExternalConnectionsLoading] = useState(false); @@ -118,6 +124,12 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP } }, [targetType, selectedExternalConnectionId]); + // fieldMappings 변경 시 Combobox 열림 상태 초기화 + useEffect(() => { + setMappingSourceFieldsOpenState(new Array(fieldMappings.length).fill(false)); + setMappingTargetFieldsOpenState(new Array(fieldMappings.length).fill(false)); + }, [fieldMappings.length]); + // 🔥 외부 테이블 변경 시 컬럼 로드 useEffect(() => { if (targetType === "external" && selectedExternalConnectionId && externalTargetTable) { @@ -340,12 +352,27 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP 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, - })); + const columnInfo: ColumnInfo[] = columns.map((col: any) => { + // is_nullable 파싱: "YES", true, 1 등을 true로, "NO", false, 0 등을 false로 변환 + const isNullableValue = col.is_nullable ?? col.isNullable; + let isNullable = true; // 기본값: nullable + + if (typeof isNullableValue === "boolean") { + isNullable = isNullableValue; + } else if (typeof isNullableValue === "string") { + isNullable = isNullableValue.toUpperCase() === "YES" || isNullableValue.toUpperCase() === "TRUE"; + } else if (typeof isNullableValue === "number") { + isNullable = isNullableValue !== 0; + } + + return { + columnName: col.column_name || col.columnName, + columnLabel: col.label_ko || col.columnLabel, + dataType: col.data_type || col.dataType || "unknown", + isNullable, + columnDefault: col.column_default ?? col.columnDefault ?? null, + }; + }); setTargetColumns(columnInfo); console.log(`✅ 컬럼 ${columnInfo.length}개 로딩 완료`); @@ -449,12 +476,20 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP ]; setFieldMappings(newMappings); updateNode(nodeId, { fieldMappings: newMappings }); + + // Combobox 열림 상태 배열 초기화 + setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false)); + setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false)); }; const handleRemoveMapping = (index: number) => { const newMappings = fieldMappings.filter((_, i) => i !== index); setFieldMappings(newMappings); updateNode(nodeId, { fieldMappings: newMappings }); + + // Combobox 열림 상태 배열도 업데이트 + setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false)); + setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false)); }; const handleMappingChange = (index: number, field: string, value: any) => { @@ -1077,35 +1112,87 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP className="mt-1 h-8 text-xs" /> ) : ( - // 일반 소스인 경우: 드롭다운 선택 - + + + + + + + + + 필드를 찾을 수 없습니다. + + + {sourceFields.map((field) => ( + { + handleMappingChange(index, "sourceField", currentValue || null); + const newState = [...mappingSourceFieldsOpenState]; + newState[index] = false; + setMappingSourceFieldsOpenState(newState); + }} + className="text-xs sm:text-sm" + > + +
+ {field.label || field.name} + {field.label && field.label !== field.name && ( + + {field.name} + + )} +
+
+ ))} +
+
+
+
+ )} {hasRestAPISource && (

API 응답 JSON의 필드명을 입력하세요

@@ -1116,43 +1203,134 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
- {/* 타겟 필드 드롭다운 (🔥 타입별 컬럼 사용) */} + {/* 타겟 필드 Combobox (🔥 타입별 컬럼 사용) */}
- + {/* 🔥 외부 DB 컬럼 */} + {targetType === "external" && + externalColumns.map((col) => ( + { + handleMappingChange(index, "targetField", currentValue); + const newState = [...mappingTargetFieldsOpenState]; + newState[index] = false; + setMappingTargetFieldsOpenState(newState); + }} + className="text-xs sm:text-sm" + > + +
+ {col.column_name} + {col.data_type} +
+
+ ))} + + + + +
{/* 정적 값 */} diff --git a/frontend/components/dataflow/node-editor/panels/properties/ReferenceLookupProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/ReferenceLookupProperties.tsx index 2f553608..b2bb51e0 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/ReferenceLookupProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/ReferenceLookupProperties.tsx @@ -5,14 +5,13 @@ */ import { useEffect, useState, useCallback } from "react"; -import { Plus, Trash2, Search } from "lucide-react"; +import { Plus, Trash2, Search, Check, ChevronsUpDown } from "lucide-react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; -import { Check } from "lucide-react"; import { cn } from "@/lib/utils"; import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; import type { ReferenceLookupNodeData } from "@/types/node-editor"; @@ -62,6 +61,9 @@ export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPrope const [referenceColumns, setReferenceColumns] = useState([]); const [columnsLoading, setColumnsLoading] = useState(false); + // Combobox 열림 상태 관리 + const [whereFieldOpenState, setWhereFieldOpenState] = useState([]); + // 데이터 변경 시 로컬 상태 동기화 useEffect(() => { setDisplayName(data.displayName || "참조 조회"); @@ -72,6 +74,11 @@ export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPrope setOutputFields(data.outputFields || []); }, [data]); + // whereConditions 변경 시 whereFieldOpenState 초기화 + useEffect(() => { + setWhereFieldOpenState(new Array(whereConditions.length).fill(false)); + }, [whereConditions.length]); + // 🔍 소스 필드 수집 (업스트림 노드에서) useEffect(() => { const incomingEdges = edges.filter((e) => e.target === nodeId); @@ -187,7 +194,7 @@ export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPrope // WHERE 조건 추가 const handleAddWhereCondition = () => { - setWhereConditions([ + const newConditions = [ ...whereConditions, { field: "", @@ -195,11 +202,15 @@ export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPrope value: "", valueType: "static", }, - ]); + ]; + setWhereConditions(newConditions); + setWhereFieldOpenState(new Array(newConditions.length).fill(false)); }; const handleRemoveWhereCondition = (index: number) => { - setWhereConditions(whereConditions.filter((_, i) => i !== index)); + const newConditions = whereConditions.filter((_, i) => i !== index); + setWhereConditions(newConditions); + setWhereFieldOpenState(new Array(newConditions.length).fill(false)); }; const handleWhereConditionChange = (index: number, field: string, value: any) => { @@ -455,23 +466,81 @@ export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPrope
+ {/* 필드 - Combobox */}
- + + + + + + + + 필드를 찾을 수 없습니다. + + {referenceColumns.map((field) => ( + { + handleWhereConditionChange(index, "field", currentValue); + const newState = [...whereFieldOpenState]; + newState[index] = false; + setWhereFieldOpenState(newState); + }} + className="text-xs sm:text-sm" + > + +
+ {field.label || field.name} + {field.type && ( + {field.type} + )} +
+
+ ))} +
+
+
+
+
diff --git a/frontend/components/dataflow/node-editor/panels/properties/UpdateActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/UpdateActionProperties.tsx index d39ed167..7d6d2e5a 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/UpdateActionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/UpdateActionProperties.tsx @@ -37,6 +37,7 @@ interface ColumnInfo { columnLabel?: string; dataType: string; isNullable: boolean; + columnDefault?: string | null; } const OPERATORS = [ @@ -79,6 +80,14 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP // REST API 소스 노드 연결 여부 const [hasRestAPISource, setHasRestAPISource] = useState(false); + // Combobox 열림 상태 관리 (WHERE 조건) + const [sourceFieldsOpenState, setSourceFieldsOpenState] = useState([]); + const [targetFieldsOpenState, setTargetFieldsOpenState] = useState([]); + + // Combobox 열림 상태 관리 (필드 매핑) + const [mappingSourceFieldsOpenState, setMappingSourceFieldsOpenState] = useState([]); + const [mappingTargetFieldsOpenState, setMappingTargetFieldsOpenState] = useState([]); + // 🔥 외부 DB 관련 상태 const [externalConnections, setExternalConnections] = useState([]); const [externalConnectionsLoading, setExternalConnectionsLoading] = useState(false); @@ -142,6 +151,18 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP } }, [targetType, selectedExternalConnectionId, externalTargetTable]); + // whereConditions 변경 시 Combobox 열림 상태 초기화 + useEffect(() => { + setSourceFieldsOpenState(new Array(whereConditions.length).fill(false)); + setTargetFieldsOpenState(new Array(whereConditions.length).fill(false)); + }, [whereConditions.length]); + + // fieldMappings 변경 시 Combobox 열림 상태 초기화 + useEffect(() => { + setMappingSourceFieldsOpenState(new Array(fieldMappings.length).fill(false)); + setMappingTargetFieldsOpenState(new Array(fieldMappings.length).fill(false)); + }, [fieldMappings.length]); + // 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색) useEffect(() => { const getAllSourceFields = ( @@ -331,12 +352,27 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP 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, - })); + const columnInfo: ColumnInfo[] = columns.map((col: any) => { + // is_nullable 파싱: "YES", true, 1 등을 true로, "NO", false, 0 등을 false로 변환 + const isNullableValue = col.is_nullable ?? col.isNullable; + let isNullable = true; // 기본값: nullable + + if (typeof isNullableValue === "boolean") { + isNullable = isNullableValue; + } else if (typeof isNullableValue === "string") { + isNullable = isNullableValue.toUpperCase() === "YES" || isNullableValue.toUpperCase() === "TRUE"; + } else if (typeof isNullableValue === "number") { + isNullable = isNullableValue !== 0; + } + + return { + columnName: col.column_name || col.columnName, + columnLabel: col.label_ko || col.columnLabel, + dataType: col.data_type || col.dataType || "unknown", + isNullable, + columnDefault: col.column_default ?? col.columnDefault ?? null, + }; + }); setTargetColumns(columnInfo); console.log(`✅ UPDATE 노드 - 컬럼 ${columnInfo.length}개 로딩 완료`); @@ -379,12 +415,20 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP ]; setFieldMappings(newMappings); updateNode(nodeId, { fieldMappings: newMappings }); + + // Combobox 열림 상태 배열 초기화 + setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false)); + setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false)); }; const handleRemoveMapping = (index: number) => { const newMappings = fieldMappings.filter((_, i) => i !== index); setFieldMappings(newMappings); updateNode(nodeId, { fieldMappings: newMappings }); + + // Combobox 열림 상태 배열도 업데이트 + setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false)); + setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false)); }; const handleMappingChange = (index: number, field: string, value: any) => { @@ -452,12 +496,20 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP ]; setWhereConditions(newConditions); updateNode(nodeId, { whereConditions: newConditions }); + + // Combobox 열림 상태 배열 초기화 + setSourceFieldsOpenState(new Array(newConditions.length).fill(false)); + setTargetFieldsOpenState(new Array(newConditions.length).fill(false)); }; const handleRemoveCondition = (index: number) => { const newConditions = whereConditions.filter((_, i) => i !== index); setWhereConditions(newConditions); updateNode(nodeId, { whereConditions: newConditions }); + + // Combobox 열림 상태 배열도 업데이트 + setSourceFieldsOpenState(new Array(newConditions.length).fill(false)); + setTargetFieldsOpenState(new Array(newConditions.length).fill(false)); }; const handleConditionChange = (index: number, field: string, value: any) => { @@ -506,177 +558,207 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP return (
- {/* 기본 정보 */} -
-

기본 정보

+ {/* 기본 정보 */} +
+

기본 정보

-
-
- - setDisplayName(e.target.value)} - className="mt-1" - placeholder="노드 표시 이름" - /> +
+
+ + setDisplayName(e.target.value)} + className="mt-1" + placeholder="노드 표시 이름" + /> +
+ + {/* 🔥 타겟 타입 선택 */} +
+ +
+ {/* 내부 데이터베이스 */} + + + {/* 외부 데이터베이스 */} + + + {/* REST API */} +
+
- {/* 🔥 타겟 타입 선택 */} + {/* 내부 DB: 타겟 테이블 Combobox */} + {targetType === "internal" && (
- -
- {/* 내부 데이터베이스 */} - - - {/* 외부 데이터베이스 */} - - - {/* REST API */} - -
+ + + + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((table) => ( + handleTableSelect(table.tableName)} + > + +
+ {table.label || table.displayName} + {table.tableName} +
+
+ ))} +
+
+
+
+
+ )} - {/* 내부 DB: 타겟 테이블 Combobox */} - {targetType === "internal" && ( + {/* 🔥 외부 DB 설정 (INSERT 노드와 동일 패턴) */} + {targetType === "external" && ( + <> + {/* 외부 커넥션 선택 */}
- - - - - - - - - 테이블을 찾을 수 없습니다. - - - {tables.map((table) => ( - handleTableSelect(table.tableName)} - > - -
- {table.label || table.displayName} - {table.tableName} -
-
- ))} -
-
-
-
-
+ +
- )} - {/* 🔥 외부 DB 설정 (INSERT 노드와 동일 패턴) */} - {targetType === "external" && ( - <> - {/* 외부 커넥션 선택 */} + {/* 외부 테이블 선택 */} + {selectedExternalConnectionId && (
- +
+ )} - {/* 외부 테이블 선택 */} - {selectedExternalConnectionId && ( -
- - { + 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 }); }} - > - - - - - {externalTablesLoading ? ( -
로딩 중...
- ) : externalTables.length === 0 ? ( -
테이블이 없습니다
- ) : ( - externalTables.map((table) => ( - -
- {table.table_name} - {table.schema && ({table.schema})} -
-
- )) - )} -
- -
- )} + className="h-8 text-xs" + /> + )} - {/* 외부 컬럼 표시 */} - {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" && ( + {apiAuthType === "basic" && ( +
{ - const newConfig = { token: e.target.value }; + 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 === "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" - /> - -
- ))} - -
+ {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" + /> +
+ )}
+ )} - {/* 요청 바디 설정 */} -
- -