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/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" + /> +
+ )}
+ )} - {/* 요청 바디 설정 */} -
- -