"use client"; /** * 조건 분기 노드 속성 편집 */ import { useEffect, useState, useCallback } from "react"; import { Plus, Trash2, Database, 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 { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; import type { ConditionNodeData, ConditionOperator } from "@/types/node-editor"; import { tableManagementApi } from "@/lib/api/tableManagement"; import { cn } from "@/lib/utils"; // 필드 정의 interface FieldDefinition { name: string; label?: string; type?: string; } // 테이블 정보 interface TableInfo { tableName: string; tableLabel: string; } // 테이블 컬럼 정보 interface ColumnInfo { columnName: string; columnLabel: string; dataType: string; } interface ConditionPropertiesProps { nodeId: string; data: ConditionNodeData; } const OPERATORS = [ { value: "EQUALS", label: "같음 (=)" }, { value: "NOT_EQUALS", label: "같지 않음 (≠)" }, { value: "GREATER_THAN", label: "보다 큼 (>)" }, { value: "LESS_THAN", label: "보다 작음 (<)" }, { value: "GREATER_THAN_OR_EQUAL", label: "크거나 같음 (≥)" }, { value: "LESS_THAN_OR_EQUAL", label: "작거나 같음 (≤)" }, { value: "LIKE", label: "포함 (LIKE)" }, { value: "NOT_LIKE", label: "미포함 (NOT LIKE)" }, { value: "IN", label: "IN" }, { value: "NOT_IN", label: "NOT IN" }, { value: "IS_NULL", label: "NULL" }, { value: "IS_NOT_NULL", label: "NOT NULL" }, { value: "EXISTS_IN", label: "다른 테이블에 존재함" }, { value: "NOT_EXISTS_IN", label: "다른 테이블에 존재하지 않음" }, ] as const; // EXISTS 계열 연산자인지 확인 const isExistsOperator = (operator: string): boolean => { return operator === "EXISTS_IN" || operator === "NOT_EXISTS_IN"; }; // 테이블 선택용 검색 가능한 Combobox function TableCombobox({ tables, value, onSelect, placeholder = "테이블 검색...", }: { tables: TableInfo[]; value: string; onSelect: (value: string) => void; placeholder?: string; }) { const [open, setOpen] = useState(false); const selectedTable = tables.find((t) => t.tableName === value); return ( 테이블을 찾을 수 없습니다. {tables.map((table) => ( { onSelect(table.tableName); setOpen(false); }} className="text-xs" >
{table.tableLabel} {table.tableName}
))}
); } // 컬럼 선택용 검색 가능한 Combobox function ColumnCombobox({ columns, value, onSelect, placeholder = "컬럼 검색...", }: { columns: ColumnInfo[]; value: string; onSelect: (value: string) => void; placeholder?: string; }) { const [open, setOpen] = useState(false); const selectedColumn = columns.find((c) => c.columnName === value); return ( 컬럼을 찾을 수 없습니다. {columns.map((col) => ( { onSelect(col.columnName); setOpen(false); }} className="text-xs" > {col.columnLabel} ({col.columnName}) ))} ); } // 컬럼 선택 섹션 (자동 로드 포함) function ColumnSelectSection({ lookupTable, lookupField, tableColumnsCache, loadingColumns, loadTableColumns, onSelect, }: { lookupTable: string; lookupField: string; tableColumnsCache: Record; loadingColumns: Record; loadTableColumns: (tableName: string) => Promise; onSelect: (value: string) => void; }) { // 캐시에 없고 로딩 중이 아니면 자동으로 로드 useEffect(() => { if (lookupTable && !tableColumnsCache[lookupTable] && !loadingColumns[lookupTable]) { loadTableColumns(lookupTable); } }, [lookupTable, tableColumnsCache, loadingColumns, loadTableColumns]); const isLoading = loadingColumns[lookupTable]; const columns = tableColumnsCache[lookupTable]; return (
{isLoading ? (
컬럼 목록 로딩 중...
) : columns && columns.length > 0 ? ( ) : (
컬럼 목록을 로드할 수 없습니다
)}
); } export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) { const { updateNode, nodes, edges } = useFlowEditorStore(); const [displayName, setDisplayName] = useState(data.displayName || "조건 분기"); const [conditions, setConditions] = useState(data.conditions || []); const [logic, setLogic] = useState<"AND" | "OR">(data.logic || "AND"); const [availableFields, setAvailableFields] = useState([]); // EXISTS 연산자용 상태 const [allTables, setAllTables] = useState([]); const [tableColumnsCache, setTableColumnsCache] = useState>({}); const [loadingTables, setLoadingTables] = useState(false); const [loadingColumns, setLoadingColumns] = useState>({}); // 데이터 변경 시 로컬 상태 업데이트 useEffect(() => { setDisplayName(data.displayName || "조건 분기"); setConditions(data.conditions || []); setLogic(data.logic || "AND"); }, [data]); // 전체 테이블 목록 로드 (EXISTS 연산자용) useEffect(() => { const loadAllTables = async () => { // 이미 EXISTS 연산자가 있거나 로드된 적이 있으면 스킵 if (allTables.length > 0) return; // EXISTS 연산자가 하나라도 있으면 테이블 목록 로드 const hasExistsOperator = conditions.some((c) => isExistsOperator(c.operator)); if (!hasExistsOperator) return; setLoadingTables(true); try { const response = await tableManagementApi.getTableList(); if (response.success && response.data) { setAllTables( response.data.map((t: any) => ({ tableName: t.tableName, tableLabel: t.tableLabel || t.tableName, })) ); } } catch (error) { console.error("테이블 목록 로드 실패:", error); } finally { setLoadingTables(false); } }; loadAllTables(); }, [conditions, allTables.length]); // 테이블 컬럼 로드 함수 const loadTableColumns = useCallback( async (tableName: string): Promise => { // 캐시에 있으면 반환 if (tableColumnsCache[tableName]) { return tableColumnsCache[tableName]; } // 이미 로딩 중이면 스킵 if (loadingColumns[tableName]) { return []; } // 로딩 상태 설정 setLoadingColumns((prev) => ({ ...prev, [tableName]: true })); try { // getColumnList 반환: { success, data: { columns, total, ... } } const response = await tableManagementApi.getColumnList(tableName); if (response.success && response.data && response.data.columns) { const columns = response.data.columns.map((c: any) => ({ columnName: c.columnName, columnLabel: c.columnLabel || c.columnName, dataType: c.dataType, })); setTableColumnsCache((prev) => ({ ...prev, [tableName]: columns })); console.log(`✅ 테이블 ${tableName} 컬럼 로드 완료:`, columns.length, "개"); return columns; } else { console.warn(`⚠️ 테이블 ${tableName} 컬럼 조회 실패:`, response); } } catch (error) { console.error(`❌ 테이블 ${tableName} 컬럼 로드 실패:`, error); } finally { setLoadingColumns((prev) => ({ ...prev, [tableName]: false })); } return []; }, [tableColumnsCache, loadingColumns] ); // EXISTS 연산자 선택 시 테이블 목록 강제 로드 const ensureTablesLoaded = useCallback(async () => { if (allTables.length > 0) return; setLoadingTables(true); try { const response = await tableManagementApi.getTableList(); if (response.success && response.data) { setAllTables( response.data.map((t: any) => ({ tableName: t.tableName, tableLabel: t.tableLabel || t.tableName, })) ); } } catch (error) { console.error("테이블 목록 로드 실패:", error); } finally { setLoadingTables(false); } }, [allTables.length]); // 🔥 연결된 소스 노드의 필드를 재귀적으로 수집 useEffect(() => { const getAllSourceFields = (currentNodeId: string, visited: Set = new Set()): FieldDefinition[] => { if (visited.has(currentNodeId)) return []; visited.add(currentNodeId); const fields: FieldDefinition[] = []; // 현재 노드로 들어오는 엣지 찾기 const incomingEdges = edges.filter((e) => e.target === currentNodeId); for (const edge of incomingEdges) { const sourceNode = nodes.find((n) => n.id === edge.source); if (!sourceNode) continue; const sourceData = sourceNode.data as any; // 소스 노드 타입별 필드 수집 if (sourceNode.type === "tableSource") { // Table Source: fields 사용 if (sourceData.fields && Array.isArray(sourceData.fields)) { console.log("🔍 [ConditionProperties] Table Source 필드:", sourceData.fields); fields.push(...sourceData.fields); } else { console.log("⚠️ [ConditionProperties] Table Source에 필드 없음:", sourceData); } } else if (sourceNode.type === "externalDBSource") { // External DB Source: outputFields 사용 if (sourceData.outputFields && Array.isArray(sourceData.outputFields)) { console.log("🔍 [ConditionProperties] External DB 필드:", sourceData.outputFields); fields.push(...sourceData.outputFields); } else { console.log("⚠️ [ConditionProperties] External DB에 필드 없음:", sourceData); } } else if (sourceNode.type === "dataTransform") { // Data Transform: 재귀적으로 상위 노드 필드 수집 const upperFields = getAllSourceFields(sourceNode.id, visited); // Data Transform의 변환 결과 추가 if (sourceData.transformations && Array.isArray(sourceData.transformations)) { const inPlaceFields = new Set(); for (const transform of sourceData.transformations) { const { sourceField, targetField } = transform; // In-place 변환인지 확인 if (!targetField || targetField === sourceField) { inPlaceFields.add(sourceField); } else { // 새로운 필드 생성 fields.push({ name: targetField, label: targetField }); } } // 원본 필드 중 in-place 변환되지 않은 것들 추가 for (const field of upperFields) { if (!inPlaceFields.has(field.name)) { fields.push(field); } else { // In-place 변환된 필드는 원본 이름으로 유지 fields.push(field); } } } else { fields.push(...upperFields); } } else if (sourceNode.type === "restAPISource") { // REST API Source: responseFields 사용 if (sourceData.responseFields && Array.isArray(sourceData.responseFields)) { console.log("🔍 [ConditionProperties] REST API 필드:", sourceData.responseFields); fields.push( ...sourceData.responseFields.map((f: any) => ({ name: f.name || f.fieldName, label: f.label || f.displayName || f.name, type: f.dataType || f.type, })), ); } else { console.log("⚠️ [ConditionProperties] REST API에 필드 없음:", sourceData); } } else if (sourceNode.type === "condition") { // 조건 노드: 재귀적으로 상위 노드 필드 수집 (통과 노드) console.log("✅ [ConditionProperties] 조건 노드 통과 → 상위 탐색"); fields.push(...getAllSourceFields(sourceNode.id, visited)); } else if ( sourceNode.type === "insertAction" || sourceNode.type === "updateAction" || sourceNode.type === "deleteAction" || sourceNode.type === "upsertAction" ) { // Action 노드: 재귀적으로 상위 노드 필드 수집 fields.push(...getAllSourceFields(sourceNode.id, visited)); } else { // 기타 모든 노드: 재귀적으로 상위 노드 필드 수집 (통과 노드로 처리) console.log(`✅ [ConditionProperties] 통과 노드 (${sourceNode.type}) → 상위 탐색`); fields.push(...getAllSourceFields(sourceNode.id, visited)); } } // 중복 제거 const uniqueFields = Array.from(new Map(fields.map((f) => [f.name, f])).values()); return uniqueFields; }; const fields = getAllSourceFields(nodeId); console.log("✅ [ConditionProperties] 최종 수집된 필드:", fields); console.log("🔍 [ConditionProperties] 현재 노드 ID:", nodeId); console.log( "🔍 [ConditionProperties] 연결된 엣지:", edges.filter((e) => e.target === nodeId), ); setAvailableFields(fields); }, [nodeId, nodes, edges]); const handleAddCondition = () => { const newCondition = { field: "", operator: "EQUALS" as ConditionOperator, value: "", valueType: "static" as "static" | "field", // EXISTS 연산자용 필드는 초기값 없음 lookupTable: undefined, lookupTableLabel: undefined, lookupField: undefined, lookupFieldLabel: undefined, }; setConditions([...conditions, newCondition]); }; const handleRemoveCondition = (index: number) => { const newConditions = conditions.filter((_, i) => i !== index); setConditions(newConditions); updateNode(nodeId, { conditions: newConditions, }); }; const handleDisplayNameChange = (newDisplayName: string) => { setDisplayName(newDisplayName); updateNode(nodeId, { displayName: newDisplayName, }); }; const handleConditionChange = async (index: number, field: string, value: any) => { const newConditions = [...conditions]; newConditions[index] = { ...newConditions[index], [field]: value }; // EXISTS 연산자로 변경 시 테이블 목록 로드 및 기존 value/valueType 초기화 if (field === "operator" && isExistsOperator(value)) { await ensureTablesLoaded(); // EXISTS 연산자에서는 value, valueType이 필요 없으므로 초기화 newConditions[index].value = ""; newConditions[index].valueType = undefined; } // EXISTS 연산자에서 다른 연산자로 변경 시 lookup 필드들 초기화 if (field === "operator" && !isExistsOperator(value)) { newConditions[index].lookupTable = undefined; newConditions[index].lookupTableLabel = undefined; newConditions[index].lookupField = undefined; newConditions[index].lookupFieldLabel = undefined; } // lookupTable 변경 시 컬럼 목록 로드 및 라벨 설정 if (field === "lookupTable" && value) { const tableInfo = allTables.find((t) => t.tableName === value); if (tableInfo) { newConditions[index].lookupTableLabel = tableInfo.tableLabel; } // 테이블 변경 시 필드 초기화 newConditions[index].lookupField = undefined; newConditions[index].lookupFieldLabel = undefined; // 컬럼 목록 미리 로드 await loadTableColumns(value); } // lookupField 변경 시 라벨 설정 if (field === "lookupField" && value) { const tableName = newConditions[index].lookupTable; if (tableName && tableColumnsCache[tableName]) { const columnInfo = tableColumnsCache[tableName].find((c) => c.columnName === value); if (columnInfo) { newConditions[index].lookupFieldLabel = columnInfo.columnLabel; } } } setConditions(newConditions); updateNode(nodeId, { conditions: newConditions, }); }; const handleLogicChange = (newLogic: "AND" | "OR") => { setLogic(newLogic); updateNode(nodeId, { logic: newLogic, }); }; return (
{/* 기본 정보 */}

기본 정보

handleDisplayNameChange(e.target.value)} className="mt-1" placeholder="노드 표시 이름" />
{/* 조건식 */}

조건식

{conditions.length > 0 ? (
{conditions.map((condition, index) => (
조건 #{index + 1} {index > 0 && ( {logic} )}
{availableFields.length > 0 ? ( ) : (
소스 노드를 연결하세요
)}
{/* EXISTS 연산자인 경우: 테이블/필드 선택 UI (검색 가능한 Combobox) */} {isExistsOperator(condition.operator) && ( <>
{loadingTables ? (
테이블 목록 로딩 중...
) : allTables.length > 0 ? ( handleConditionChange(index, "lookupTable", value)} placeholder="테이블 검색..." /> ) : (
테이블 목록을 로드할 수 없습니다
)}
{(condition as any).lookupTable && ( handleConditionChange(index, "lookupField", value)} /> )}
{condition.operator === "EXISTS_IN" ? `소스의 "${condition.field || "..."}" 값이 "${(condition as any).lookupTableLabel || "..."}" 테이블의 "${(condition as any).lookupFieldLabel || "..."}" 컬럼에 존재하면 TRUE` : `소스의 "${condition.field || "..."}" 값이 "${(condition as any).lookupTableLabel || "..."}" 테이블의 "${(condition as any).lookupFieldLabel || "..."}" 컬럼에 존재하지 않으면 TRUE`}
)} {/* 일반 연산자인 경우: 기존 비교값 UI */} {condition.operator !== "IS_NULL" && condition.operator !== "IS_NOT_NULL" && !isExistsOperator(condition.operator) && ( <>
{(condition as any).valueType === "field" ? ( // 필드 참조: 드롭다운으로 선택 availableFields.length > 0 ? ( ) : (
소스 노드를 연결하세요
) ) : ( // 고정값: 직접 입력 handleConditionChange(index, "value", e.target.value)} placeholder="비교할 값" className="mt-1 h-8 text-xs" /> )}
)}
))}
) : (
조건식이 없습니다. "추가" 버튼을 클릭하세요.
)}
{/* 안내 */}
소스 노드 연결: 테이블/외부DB 노드를 연결하면 자동으로 필드 목록이 표시됩니다.
비교 값 타입:
- 고정값: 직접 입력한 값과 비교 (예: age > 30)
- 필드 참조: 다른 필드의 값과 비교 (예: 주문수량 > 재고수량)
테이블 존재 여부 검사:
- 다른 테이블에 존재함: 값이 다른 테이블에 있으면 TRUE
- 다른 테이블에 존재하지 않음: 값이 다른 테이블에 없으면 TRUE
(예: 품명이 품목정보 테이블에 없으면 자동 등록)
AND: 모든 조건이 참이어야 TRUE 출력
OR: 하나라도 참이면 TRUE 출력
TRUE 출력은 오른쪽 위, FALSE 출력은 오른쪽 아래입니다.
); }