diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index f3a0b65a..bb64aaea 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -88,16 +88,19 @@ app.use( // Rate Limiting (개발 환경에서는 완화) const limiter = rateLimit({ windowMs: 1 * 60 * 1000, // 1분 - max: config.nodeEnv === "development" ? 5000 : 100, // 개발환경에서는 5000으로 증가, 운영환경에서는 100 + max: config.nodeEnv === "development" ? 10000 : 100, // 개발환경에서는 10000으로 증가, 운영환경에서는 100 message: { error: "너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요.", }, skip: (req) => { - // 헬스 체크와 테이블/컬럼 조회는 Rate Limiting 완화 + // 헬스 체크와 자주 호출되는 API들은 Rate Limiting 완화 return ( req.path === "/health" || req.path.includes("/table-management/") || - req.path.includes("/external-db-connections/") + req.path.includes("/external-db-connections/") || + req.path.includes("/screen-management/") || + req.path.includes("/multi-connection/") || + req.path.includes("/dataflow-diagrams/") ); }, }); diff --git a/backend-node/src/controllers/commonCodeController.ts b/backend-node/src/controllers/commonCodeController.ts index cf9637d3..482ac6d1 100644 --- a/backend-node/src/controllers/commonCodeController.ts +++ b/backend-node/src/controllers/commonCodeController.ts @@ -63,9 +63,19 @@ export class CommonCodeController { size: size ? parseInt(size as string) : undefined, }); + // 프론트엔드가 기대하는 형식으로 데이터 변환 + const transformedData = result.data.map((code: any) => ({ + codeValue: code.code_value, + codeName: code.code_name, + description: code.description, + sortOrder: code.sort_order, + isActive: code.is_active === "Y", + useYn: code.is_active, + })); + return res.json({ success: true, - data: result.data, + data: transformedData, total: result.total, message: `코드 목록 조회 성공 (${categoryCode})`, }); diff --git a/backend-node/src/services/multiConnectionQueryService.ts b/backend-node/src/services/multiConnectionQueryService.ts index 7febb5ea..74ba1994 100644 --- a/backend-node/src/services/multiConnectionQueryService.ts +++ b/backend-node/src/services/multiConnectionQueryService.ts @@ -617,18 +617,56 @@ export class MultiConnectionQueryService { `✅ 메인 DB 컬럼 조회 성공: ${columnsResult.columns.length}개` ); - return columnsResult.columns.map((column) => ({ + // 디버깅: inputType이 'code'인 컬럼들 확인 + const codeColumns = columnsResult.columns.filter( + (col) => col.inputType === "code" + ); + console.log( + "🔍 메인 DB 코드 타입 컬럼들:", + codeColumns.map((col) => ({ + columnName: col.columnName, + inputType: col.inputType, + webType: col.webType, + codeCategory: col.codeCategory, + })) + ); + + const mappedColumns = columnsResult.columns.map((column) => ({ columnName: column.columnName, displayName: column.displayName || column.columnName, // 라벨이 있으면 라벨 사용, 없으면 컬럼명 dataType: column.dataType, dbType: column.dataType, // dataType을 dbType으로 사용 webType: column.webType || "text", // webType 사용, 기본값 text + inputType: column.inputType || "direct", // column_labels의 input_type 추가 + codeCategory: column.codeCategory, // 코드 카테고리 정보 추가 isNullable: column.isNullable === "Y", isPrimaryKey: column.isPrimaryKey || false, defaultValue: column.defaultValue, maxLength: column.maxLength, description: column.description, + connectionId: 0, // 메인 DB 구분용 })); + + // 디버깅: 매핑된 컬럼 정보 확인 + console.log( + "🔍 매핑된 컬럼 정보 샘플:", + mappedColumns.slice(0, 3).map((col) => ({ + columnName: col.columnName, + inputType: col.inputType, + webType: col.webType, + connectionId: col.connectionId, + })) + ); + + // status 컬럼 특별 확인 + const statusColumn = mappedColumns.find( + (col) => col.columnName === "status" + ); + if (statusColumn) { + console.log("🔍 status 컬럼 상세 정보:", statusColumn); + } + + return mappedColumns; } // 외부 DB 연결 정보 가져오기 @@ -701,6 +739,7 @@ export class MultiConnectionQueryService { dataType: dataType, dbType: dataType, webType: this.mapDataTypeToWebType(dataType), + inputType: "direct", // 외부 DB는 항상 direct (코드 타입 없음) isNullable: column.nullable === "YES" || // MSSQL (MSSQLConnector alias) column.is_nullable === "YES" || // PostgreSQL @@ -715,6 +754,7 @@ export class MultiConnectionQueryService { column.max_length || // MSSQL (MSSQLConnector alias) column.character_maximum_length || // PostgreSQL column.CHARACTER_MAXIMUM_LENGTH, + connectionId: connectionId, // 외부 DB 구분용 description: columnComment, }; }); diff --git a/frontend/components/dataflow/DataFlowList.tsx b/frontend/components/dataflow/DataFlowList.tsx index aafcea3c..f040c87d 100644 --- a/frontend/components/dataflow/DataFlowList.tsx +++ b/frontend/components/dataflow/DataFlowList.tsx @@ -52,22 +52,45 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) { const response = await DataFlowAPI.getJsonDataFlowDiagrams(currentPage, 20, searchTerm, companyCode); // JSON API 응답을 기존 형식으로 변환 - const convertedDiagrams = response.diagrams.map((diagram) => ({ - diagramId: diagram.diagram_id, - relationshipId: diagram.diagram_id, // 호환성을 위해 추가 - diagramName: diagram.diagram_name, - connectionType: "json-based", // 새로운 JSON 기반 타입 - relationshipType: "multi-relationship", // 다중 관계 타입 - relationshipCount: diagram.relationships?.relationships?.length || 0, - tableCount: diagram.relationships?.tables?.length || 0, - tables: diagram.relationships?.tables || [], - companyCode: diagram.company_code, // 회사 코드 추가 - createdAt: new Date(diagram.created_at || new Date()), - createdBy: diagram.created_by || "SYSTEM", - updatedAt: new Date(diagram.updated_at || diagram.created_at || new Date()), - updatedBy: diagram.updated_by || "SYSTEM", - lastUpdated: diagram.updated_at || diagram.created_at || new Date().toISOString(), - })); + const convertedDiagrams = response.diagrams.map((diagram) => { + // relationships 구조 분석 + const relationships = diagram.relationships || {}; + + // 테이블 정보 추출 + const tables: string[] = []; + if (relationships.fromTable?.tableName) { + tables.push(relationships.fromTable.tableName); + } + if ( + relationships.toTable?.tableName && + relationships.toTable.tableName !== relationships.fromTable?.tableName + ) { + tables.push(relationships.toTable.tableName); + } + + // 관계 수 계산 (actionGroups 기준) + const actionGroups = relationships.actionGroups || []; + const relationshipCount = actionGroups.reduce((count: number, group: any) => { + return count + (group.actions?.length || 0); + }, 0); + + return { + diagramId: diagram.diagram_id, + relationshipId: diagram.diagram_id, // 호환성을 위해 추가 + diagramName: diagram.diagram_name, + connectionType: relationships.connectionType || "data_save", // 실제 연결 타입 사용 + relationshipType: "multi-relationship", // 다중 관계 타입 + relationshipCount: relationshipCount || 1, // 최소 1개는 있다고 가정 + tableCount: tables.length, + tables: tables, + companyCode: diagram.company_code, // 회사 코드 추가 + createdAt: new Date(diagram.created_at || new Date()), + createdBy: diagram.created_by || "SYSTEM", + updatedAt: new Date(diagram.updated_at || diagram.created_at || new Date()), + updatedBy: diagram.updated_by || "SYSTEM", + lastUpdated: diagram.updated_at || diagram.created_at || new Date().toISOString(), + }; + }); setDiagrams(convertedDiagrams); setTotal(response.pagination.total || 0); diff --git a/frontend/components/dataflow/connection/redesigned/DataConnectionDesigner.tsx b/frontend/components/dataflow/connection/redesigned/DataConnectionDesigner.tsx index f14740dc..8eafb51d 100644 --- a/frontend/components/dataflow/connection/redesigned/DataConnectionDesigner.tsx +++ b/frontend/components/dataflow/connection/redesigned/DataConnectionDesigner.tsx @@ -7,7 +7,8 @@ import { toast } from "sonner"; import { X, ArrowLeft } from "lucide-react"; // API import -import { saveDataflowRelationship } from "@/lib/api/dataflowSave"; +import { saveDataflowRelationship, checkRelationshipNameDuplicate } from "@/lib/api/dataflowSave"; +import { getColumnsFromConnection } from "@/lib/api/multiConnection"; // 타입 import import { @@ -26,7 +27,6 @@ import { ColumnInfo, Connection, TableInfo } from "@/lib/types/multiConnection"; // 컴포넌트 import import LeftPanel from "./LeftPanel/LeftPanel"; import RightPanel from "./RightPanel/RightPanel"; -import SaveRelationshipDialog from "./SaveRelationshipDialog"; /** * 🎨 데이터 연결 설정 메인 디자이너 @@ -74,6 +74,7 @@ const DataConnectionDesigner: React.FC = ({ isEnabled: true, }, ], + groupsLogicalOperator: "AND" as "AND" | "OR", // 기존 호환성 필드들 (deprecated) actionType: "insert", @@ -81,11 +82,15 @@ const DataConnectionDesigner: React.FC = ({ actionFieldMappings: [], isLoading: false, validationErrors: [], + + // 컬럼 정보 초기화 + fromColumns: [], + toColumns: [], ...initialData, })); - // 💾 저장 다이얼로그 상태 - const [showSaveDialog, setShowSaveDialog] = useState(false); + // 🔧 수정 모드 감지 (initialData에 diagramId가 있으면 수정 모드) + const diagramId = initialData?.diagramId; // 🔄 초기 데이터 로드 useEffect(() => { @@ -96,15 +101,50 @@ const DataConnectionDesigner: React.FC = ({ setState((prev) => ({ ...prev, connectionType: initialData.connectionType || prev.connectionType, + + // 🔧 관계 정보 로드 + relationshipName: initialData.relationshipName || prev.relationshipName, + description: initialData.description || prev.description, + groupsLogicalOperator: initialData.groupsLogicalOperator || prev.groupsLogicalOperator, + fromConnection: initialData.fromConnection || prev.fromConnection, toConnection: initialData.toConnection || prev.toConnection, fromTable: initialData.fromTable || prev.fromTable, toTable: initialData.toTable || prev.toTable, - actionType: initialData.actionType || prev.actionType, controlConditions: initialData.controlConditions || prev.controlConditions, - actionConditions: initialData.actionConditions || prev.actionConditions, fieldMappings: initialData.fieldMappings || prev.fieldMappings, - currentStep: initialData.fromConnection && initialData.toConnection ? 2 : 1, // 연결 정보가 있으면 2단계부터 시작 + + // 🔧 액션 그룹 데이터 로드 (기존 호환성 포함) + actionGroups: + initialData.actionGroups || + // 기존 단일 액션 데이터를 그룹으로 변환 + (initialData.actionType || initialData.actionConditions + ? [ + { + id: "group_1", + name: "기본 액션 그룹", + logicalOperator: "AND" as const, + actions: [ + { + id: "action_1", + name: "액션 1", + actionType: initialData.actionType || ("insert" as const), + conditions: initialData.actionConditions || [], + fieldMappings: initialData.actionFieldMappings || [], + isEnabled: true, + }, + ], + isEnabled: true, + }, + ] + : prev.actionGroups), + + // 기존 호환성 필드들 + actionType: initialData.actionType || prev.actionType, + actionConditions: initialData.actionConditions || prev.actionConditions, + actionFieldMappings: initialData.actionFieldMappings || prev.actionFieldMappings, + + currentStep: initialData.fromConnection && initialData.toConnection ? 4 : 1, // 연결 정보가 있으면 4단계부터 시작 })); console.log("✅ 초기 데이터 로드 완료"); @@ -130,6 +170,26 @@ const DataConnectionDesigner: React.FC = ({ toast.success(`연결 타입이 ${type === "data_save" ? "데이터 저장" : "외부 호출"}로 변경되었습니다.`); }, []), + // 🔧 관계 정보 설정 + setRelationshipName: useCallback((name: string) => { + setState((prev) => ({ + ...prev, + relationshipName: name, + })); + }, []), + + setDescription: useCallback((description: string) => { + setState((prev) => ({ + ...prev, + description: description, + })); + }, []), + + setGroupsLogicalOperator: useCallback((operator: "AND" | "OR") => { + setState((prev) => ({ ...prev, groupsLogicalOperator: operator })); + console.log("🔄 그룹 간 논리 연산자 변경:", operator); + }, []), + // 단계 이동 goToStep: useCallback((step: 1 | 2 | 3 | 4) => { setState((prev) => ({ ...prev, currentStep: step })); @@ -152,21 +212,75 @@ const DataConnectionDesigner: React.FC = ({ setState((prev) => ({ ...prev, [type === "from" ? "fromTable" : "toTable"]: table, - // 테이블 변경 시 매핑 초기화 + // 테이블 변경 시 매핑과 컬럼 정보 초기화 fieldMappings: [], + fromColumns: type === "from" ? [] : prev.fromColumns, + toColumns: type === "to" ? [] : prev.toColumns, })); toast.success( `${type === "from" ? "소스" : "대상"} 테이블이 선택되었습니다: ${table.displayName || table.tableName}`, ); }, []), - // 필드 매핑 생성 + // 컬럼 정보 로드 (중앙 관리) + loadColumns: useCallback(async () => { + if (!state.fromConnection || !state.toConnection || !state.fromTable || !state.toTable) { + console.log("❌ 컬럼 로드: 필수 정보 누락"); + return; + } + + // 이미 로드된 경우 스킵 (배열 길이로 확인) + if (state.fromColumns && state.toColumns && state.fromColumns.length > 0 && state.toColumns.length > 0) { + console.log("✅ 컬럼 정보 이미 로드됨, 스킵", { + fromColumns: state.fromColumns.length, + toColumns: state.toColumns.length, + }); + return; + } + + console.log("🔄 중앙 컬럼 로드 시작:", { + from: `${state.fromConnection.id}/${state.fromTable.tableName}`, + to: `${state.toConnection.id}/${state.toTable.tableName}`, + }); + + setState((prev) => ({ + ...prev, + isLoading: true, + fromColumns: [], + toColumns: [], + })); + + try { + const [fromCols, toCols] = await Promise.all([ + getColumnsFromConnection(state.fromConnection.id, state.fromTable.tableName), + getColumnsFromConnection(state.toConnection.id, state.toTable.tableName), + ]); + + console.log("✅ 중앙 컬럼 로드 완료:", { + fromColumns: fromCols.length, + toColumns: toCols.length, + }); + + setState((prev) => ({ + ...prev, + fromColumns: Array.isArray(fromCols) ? fromCols : [], + toColumns: Array.isArray(toCols) ? toCols : [], + isLoading: false, + })); + } catch (error) { + console.error("❌ 중앙 컬럼 로드 실패:", error); + setState((prev) => ({ ...prev, isLoading: false })); + toast.error("컬럼 정보를 불러오는데 실패했습니다."); + } + }, [state.fromConnection, state.toConnection, state.fromTable, state.toTable, state.fromColumns, state.toColumns]), + + // 필드 매핑 생성 (호환성용 - 실제로는 각 액션에서 직접 관리) createMapping: useCallback((fromField: ColumnInfo, toField: ColumnInfo) => { const newMapping: FieldMapping = { id: `${fromField.columnName}_to_${toField.columnName}_${Date.now()}`, fromField, toField, - isValid: true, // 기본적으로 유효하다고 가정, 나중에 검증 + isValid: true, validationMessage: undefined, }; @@ -175,6 +289,11 @@ const DataConnectionDesigner: React.FC = ({ fieldMappings: [...prev.fieldMappings, newMapping], })); + console.log("🔗 전역 매핑 생성 (호환성):", { + newMapping, + fieldName: `${fromField.columnName} → ${toField.columnName}`, + }); + toast.success(`매핑이 생성되었습니다: ${fromField.columnName} → ${toField.columnName}`); }, []), @@ -188,12 +307,14 @@ const DataConnectionDesigner: React.FC = ({ })); }, []), - // 필드 매핑 삭제 + // 필드 매핑 삭제 (호환성용 - 실제로는 각 액션에서 직접 관리) deleteMapping: useCallback((mappingId: string) => { setState((prev) => ({ ...prev, fieldMappings: prev.fieldMappings.filter((mapping) => mapping.id !== mappingId), })); + + console.log("🗑️ 전역 매핑 삭제 (호환성):", { mappingId }); toast.success("매핑이 삭제되었습니다."); }, []), @@ -404,10 +525,73 @@ const DataConnectionDesigner: React.FC = ({ toast.success("액션이 삭제되었습니다."); }, []), - // 매핑 저장 (다이얼로그 표시) + // 매핑 저장 (직접 저장) saveMappings: useCallback(async () => { - setShowSaveDialog(true); - }, []), + // 관계명과 설명이 없으면 저장할 수 없음 + if (!state.relationshipName?.trim()) { + toast.error("관계 이름을 입력해주세요."); + actions.goToStep(1); // 첫 번째 단계로 이동 + return; + } + + // 중복 체크 (수정 모드가 아닌 경우에만) + if (!diagramId) { + try { + const duplicateCheck = await checkRelationshipNameDuplicate(state.relationshipName, diagramId); + if (duplicateCheck.isDuplicate) { + toast.error(`"${state.relationshipName}" 이름이 이미 사용 중입니다. 다른 이름을 사용해주세요.`); + actions.goToStep(1); // 첫 번째 단계로 이동 + return; + } + } catch (error) { + console.error("중복 체크 실패:", error); + toast.error("관계명 중복 체크 중 오류가 발생했습니다."); + return; + } + } + + setState((prev) => ({ ...prev, isLoading: true })); + + try { + // 실제 저장 로직 구현 + const saveData = { + relationshipName: state.relationshipName, + description: state.description, + connectionType: state.connectionType, + fromConnection: state.fromConnection, + toConnection: state.toConnection, + fromTable: state.fromTable, + toTable: state.toTable, + // 🔧 멀티 액션 그룹 데이터 포함 + actionGroups: state.actionGroups, + groupsLogicalOperator: state.groupsLogicalOperator, + // 기존 호환성을 위한 필드들 (첫 번째 액션 그룹의 첫 번째 액션에서 추출) + actionType: state.actionGroups[0]?.actions[0]?.actionType || state.actionType || "insert", + controlConditions: state.controlConditions, + actionConditions: state.actionGroups[0]?.actions[0]?.conditions || state.actionConditions || [], + fieldMappings: state.actionGroups[0]?.actions[0]?.fieldMappings || state.fieldMappings || [], + }; + + console.log("💾 직접 저장 시작:", { saveData, diagramId, isEdit: !!diagramId }); + + // 백엔드 API 호출 (수정 모드인 경우 diagramId 전달) + const result = await saveDataflowRelationship(saveData, diagramId); + + console.log("✅ 저장 완료:", result); + + setState((prev) => ({ ...prev, isLoading: false })); + toast.success(`"${state.relationshipName}" 관계가 성공적으로 저장되었습니다.`); + + // 저장 후 닫기 + if (onClose) { + onClose(); + } + } catch (error: any) { + console.error("❌ 저장 실패:", error); + setState((prev) => ({ ...prev, isLoading: false })); + toast.error(error.message || "저장 중 오류가 발생했습니다."); + } + }, [state, diagramId, onClose]), // 테스트 실행 testExecution: useCallback(async (): Promise => { @@ -434,52 +618,6 @@ const DataConnectionDesigner: React.FC = ({ }, []), }; - // 💾 실제 저장 함수 - const handleSaveWithName = useCallback( - async (relationshipName: string, description?: string) => { - setState((prev) => ({ ...prev, isLoading: true })); - - try { - // 실제 저장 로직 구현 - const saveData = { - relationshipName, - description, - connectionType: state.connectionType, - fromConnection: state.fromConnection, - toConnection: state.toConnection, - fromTable: state.fromTable, - toTable: state.toTable, - actionType: state.actionType, - controlConditions: state.controlConditions, - actionConditions: state.actionConditions, - fieldMappings: state.fieldMappings, - }; - - console.log("💾 저장 시작:", saveData); - - // 백엔드 API 호출 - const result = await saveDataflowRelationship(saveData); - - console.log("✅ 저장 완료:", result); - - setState((prev) => ({ ...prev, isLoading: false })); - toast.success(`"${relationshipName}" 관계가 성공적으로 저장되었습니다.`); - - // 저장 후 상위 컴포넌트에 알림 (필요한 경우) - if (onClose) { - onClose(); - } - } catch (error: any) { - setState((prev) => ({ ...prev, isLoading: false })); - - const errorMessage = error.message || "저장 중 오류가 발생했습니다."; - toast.error(errorMessage); - console.error("❌ 저장 오류:", error); - } - }, - [state, onClose], - ); - return (
{/* 상단 네비게이션 */} @@ -514,16 +652,6 @@ const DataConnectionDesigner: React.FC = ({
- - {/* 💾 저장 다이얼로그 */} - ); }; diff --git a/frontend/components/dataflow/connection/redesigned/LeftPanel/LeftPanel.tsx b/frontend/components/dataflow/connection/redesigned/LeftPanel/LeftPanel.tsx index ab79d374..32ab354b 100644 --- a/frontend/components/dataflow/connection/redesigned/LeftPanel/LeftPanel.tsx +++ b/frontend/components/dataflow/connection/redesigned/LeftPanel/LeftPanel.tsx @@ -11,8 +11,6 @@ import { LeftPanelProps } from "../types/redesigned"; import ConnectionTypeSelector from "./ConnectionTypeSelector"; import MappingDetailList from "./MappingDetailList"; import ActionSummaryPanel from "./ActionSummaryPanel"; -import AdvancedSettings from "./AdvancedSettings"; -import ActionButtons from "./ActionButtons"; /** * 📋 좌측 패널 (30% 너비) @@ -35,45 +33,53 @@ const LeftPanel: React.FC = ({ state, actions }) => { {/* 매핑 상세 목록 */} - {state.fieldMappings.length > 0 && ( - <> -
-

매핑 상세 목록

- { - // TODO: 선택된 매핑 상태 업데이트 - }} - onUpdateMapping={actions.updateMapping} - onDeleteMapping={actions.deleteMapping} - /> -
+ {(() => { + // 액션 그룹에서 모든 매핑 수집 + const allMappings = state.actionGroups.flatMap((group) => + group.actions.flatMap((action) => action.fieldMappings || []), + ); - - - )} + // 기존 fieldMappings와 병합 (중복 제거) + const combinedMappings = [...state.fieldMappings, ...allMappings]; + const uniqueMappings = combinedMappings.filter( + (mapping, index, arr) => arr.findIndex((m) => m.id === mapping.id) === index, + ); + + console.log("🔍 LeftPanel - 매핑 데이터 수집:", { + stateFieldMappings: state.fieldMappings, + actionGroupMappings: allMappings, + combinedMappings: uniqueMappings, + }); + + return ( + uniqueMappings.length > 0 && ( + <> +
+

매핑 상세 목록

+ { + // TODO: 선택된 매핑 상태 업데이트 + }} + onUpdateMapping={actions.updateMapping} + onDeleteMapping={actions.deleteMapping} + /> +
+ + + + ) + ); + })()} {/* 액션 설정 요약 */}

액션 설정

- - - - {/* 고급 설정 */} -
-

고급 설정

- -
- - {/* 하단 액션 버튼들 - 고정 위치 */} -
- -
); }; diff --git a/frontend/components/dataflow/connection/redesigned/LeftPanel/MappingDetailList.tsx b/frontend/components/dataflow/connection/redesigned/LeftPanel/MappingDetailList.tsx index a47842b8..8a8cf831 100644 --- a/frontend/components/dataflow/connection/redesigned/LeftPanel/MappingDetailList.tsx +++ b/frontend/components/dataflow/connection/redesigned/LeftPanel/MappingDetailList.tsx @@ -28,80 +28,102 @@ const MappingDetailList: React.FC = ({
- {mappings.map((mapping, index) => ( -
onSelectMapping(mapping.id)} - > - {/* 매핑 헤더 */} -
-
-

- {index + 1}. {mapping.fromField.displayName || mapping.fromField.columnName} →{" "} - {mapping.toField.displayName || mapping.toField.columnName} -

-
- {mapping.isValid ? ( - - - {mapping.fromField.webType} → {mapping.toField.webType} - - ) : ( - - - 타입 불일치 - - )} + {(() => { + console.log("🔍 MappingDetailList - 전체 매핑 데이터:", mappings); + + const validMappings = mappings.filter((mapping) => { + const isValid = + mapping.fromField && mapping.toField && mapping.fromField.columnName && mapping.toField.columnName; + console.log(`🔍 매핑 유효성 검사:`, { + mapping, + isValid, + hasFromField: !!mapping.fromField, + hasToField: !!mapping.toField, + fromColumnName: mapping.fromField?.columnName, + toColumnName: mapping.toField?.columnName, + }); + return isValid; + }); + + if (validMappings.length === 0) { + return ( +
+
+

매핑된 필드가 없습니다

+

INSERT 액션이 있을 때 필드 매핑을 설정하세요

+
+
+ ); + } + + return validMappings.map((mapping, index) => ( +
onSelectMapping(mapping.id)} + > + {/* 매핑 헤더 */} +
+
+

+ {index + 1}. {mapping.fromField?.displayName || mapping.fromField?.columnName || "Unknown"} →{" "} + {mapping.toField?.displayName || mapping.toField?.columnName || "Unknown"} +

+
+ {mapping.isValid ? ( + + + {mapping.fromField?.webType || "Unknown"} → {mapping.toField?.webType || "Unknown"} + + ) : ( + + + 타입 불일치 + + )} +
+
+ +
+ +
-
- - -
+ {/* 변환 규칙 */} + {mapping.transformRule && ( +
변환: {mapping.transformRule}
+ )} + + {/* 검증 메시지 */} + {mapping.validationMessage && ( +
{mapping.validationMessage}
+ )}
- - {/* 변환 규칙 */} - {mapping.transformRule && ( -
변환: {mapping.transformRule}
- )} - - {/* 검증 메시지 */} - {mapping.validationMessage && ( -
{mapping.validationMessage}
- )} -
- ))} - - {mappings.length === 0 && ( -
-

매핑된 필드가 없습니다.

-

우측에서 필드를 연결해주세요.

-
- )} + )); + })()}
diff --git a/frontend/components/dataflow/connection/redesigned/RightPanel/ActionConfig/ActionConditionBuilder.tsx b/frontend/components/dataflow/connection/redesigned/RightPanel/ActionConfig/ActionConditionBuilder.tsx index 6b4eabf7..15411fdd 100644 --- a/frontend/components/dataflow/connection/redesigned/RightPanel/ActionConfig/ActionConditionBuilder.tsx +++ b/frontend/components/dataflow/connection/redesigned/RightPanel/ActionConfig/ActionConditionBuilder.tsx @@ -36,6 +36,7 @@ interface ActionConditionBuilderProps { toColumns: ColumnInfo[]; conditions: ActionCondition[]; fieldMappings: FieldValueMapping[]; + columnMappings?: any[]; // 컬럼 매핑 정보 (이미 매핑된 필드들) onConditionsChange: (conditions: ActionCondition[]) => void; onFieldMappingsChange: (mappings: FieldValueMapping[]) => void; showFieldMappings?: boolean; // 필드 매핑 섹션 표시 여부 @@ -53,12 +54,41 @@ const ActionConditionBuilder: React.FC = ({ toColumns, conditions, fieldMappings, + columnMappings = [], onConditionsChange, onFieldMappingsChange, showFieldMappings = true, }) => { const [availableCodes, setAvailableCodes] = useState>({}); + // 컬럼 매핑인지 필드값 매핑인지 구분하는 함수 + const isColumnMapping = (mapping: any): boolean => { + return mapping.fromField && mapping.toField && mapping.fromField.columnName && mapping.toField.columnName; + }; + + // 이미 컬럼 매핑된 필드들을 가져오는 함수 + const getMappedFieldNames = (): string[] => { + if (!columnMappings || columnMappings.length === 0) return []; + return columnMappings.filter((mapping) => isColumnMapping(mapping)).map((mapping) => mapping.toField.columnName); + }; + + // 매핑되지 않은 필드들만 필터링하는 함수 + const getUnmappedToColumns = (): ColumnInfo[] => { + const mappedFieldNames = getMappedFieldNames(); + return toColumns.filter((column) => !mappedFieldNames.includes(column.columnName)); + }; + + // 필드값 설정에서 사용 가능한 필드들 (이미 필드값 설정에서 사용된 필드 제외) + const getAvailableFieldsForMapping = (currentIndex?: number): ColumnInfo[] => { + const unmappedColumns = getUnmappedToColumns(); + const usedFieldNames = fieldMappings + .filter((_, index) => index !== currentIndex) // 현재 편집 중인 항목 제외 + .map((mapping) => mapping.targetField) + .filter((field) => field); // 빈 값 제외 + + return unmappedColumns.filter((column) => !usedFieldNames.includes(column.columnName)); + }; + const operators = [ { value: "=", label: "같음 (=)" }, { value: "!=", label: "다름 (!=)" }, @@ -75,9 +105,25 @@ const ActionConditionBuilder: React.FC = ({ // 코드 정보 로드 useEffect(() => { const loadCodes = async () => { - const codeFields = [...fromColumns, ...toColumns].filter( - (col) => col.webType === "code" || col.dataType?.toLowerCase().includes("code"), + const codeFields = [...fromColumns, ...toColumns].filter((col) => { + // 메인 DB(connectionId === 0 또는 undefined)인 경우: column_labels의 input_type이 'code'인 경우만 + if (col.connectionId === 0 || col.connectionId === undefined) { + return col.inputType === "code"; + } + // 외부 DB인 경우: 코드 타입 없음 + return false; + }); + + console.log( + "🔍 ActionConditionBuilder - 모든 컬럼 정보:", + [...fromColumns, ...toColumns].map((col) => ({ + columnName: col.columnName, + connectionId: col.connectionId, + inputType: col.inputType, + webType: col.webType, + })), ); + console.log("🔍 ActionConditionBuilder - 코드 타입 컬럼들:", codeFields); for (const field of codeFields) { try { @@ -100,6 +146,23 @@ const ActionConditionBuilder: React.FC = ({ } }, [fromColumns, toColumns]); + // 컬럼 매핑이 변경될 때 필드값 설정에서 이미 매핑된 필드들 제거 + useEffect(() => { + const mappedFieldNames = getMappedFieldNames(); + if (mappedFieldNames.length > 0) { + const updatedFieldMappings = fieldMappings.filter((mapping) => !mappedFieldNames.includes(mapping.targetField)); + + // 변경된 내용이 있으면 업데이트 + if (updatedFieldMappings.length !== fieldMappings.length) { + console.log("🧹 매핑된 필드들을 필드값 설정에서 제거:", { + removed: fieldMappings.filter((mapping) => mappedFieldNames.includes(mapping.targetField)), + remaining: updatedFieldMappings, + }); + onFieldMappingsChange(updatedFieldMappings); + } + } + }, [columnMappings]); // columnMappings 변경 시에만 실행 + // 조건 추가 const addCondition = () => { const newCondition: ActionCondition = { @@ -129,6 +192,20 @@ const ActionConditionBuilder: React.FC = ({ // 필드 매핑 추가 const addFieldMapping = () => { + // 임시로 검증을 완화 - 매핑되지 않은 필드가 있으면 추가 허용 + const unmappedColumns = getUnmappedToColumns(); + console.log("🔍 필드 추가 시도:", { + unmappedColumns, + unmappedColumnsCount: unmappedColumns.length, + fieldMappings, + columnMappings, + }); + + if (unmappedColumns.length === 0) { + console.warn("매핑되지 않은 필드가 없습니다."); + return; + } + const newMapping: FieldValueMapping = { id: Date.now().toString(), targetField: "", @@ -136,6 +213,7 @@ const ActionConditionBuilder: React.FC = ({ value: "", }; + console.log("✅ 새 필드 매핑 추가:", newMapping); onFieldMappingsChange([...fieldMappings, newMapping]); }; @@ -153,7 +231,11 @@ const ActionConditionBuilder: React.FC = ({ // 필드의 값 입력 컴포넌트 렌더링 const renderValueInput = (mapping: FieldValueMapping, index: number, targetColumn?: ColumnInfo) => { - if (mapping.valueType === "code" && targetColumn?.webType === "code") { + if ( + mapping.valueType === "code" && + (targetColumn?.connectionId === 0 || targetColumn?.connectionId === undefined) && + targetColumn?.inputType === "code" + ) { const codes = availableCodes[targetColumn.columnName] || []; return ( @@ -164,12 +246,7 @@ const ActionConditionBuilder: React.FC = ({ {codes.map((code) => ( -
- - {code.code} - - {code.name} -
+ {code.name}
))}
@@ -227,6 +304,129 @@ const ActionConditionBuilder: React.FC = ({ ); } + // 날짜 타입에 대한 특별 처리 + if ( + targetColumn?.webType === "date" || + targetColumn?.webType === "datetime" || + targetColumn?.dataType?.toLowerCase().includes("date") + ) { + return ( +
+ {/* 날짜 타입 선택 */} + + + {/* 직접 입력이 선택된 경우 */} + {(!mapping.value?.startsWith("#") || mapping.value === "#custom") && ( +
+ updateFieldMapping(index, { value: e.target.value })} + /> +
+ 상대적 날짜: +7D (7일 후), -30D (30일 전), +1M (1개월 후), +1Y (1년 후) +
+
+ )} + + {/* 선택된 날짜 타입에 대한 설명 */} + {mapping.value?.startsWith("#") && mapping.value !== "#custom" && ( +
+ {mapping.value === "#NOW" && "⏰ 현재 날짜와 시간이 저장됩니다"} + {mapping.value === "#TODAY" && "📅 현재 날짜 (00:00:00)가 저장됩니다"} + {mapping.value === "#YESTERDAY" && "📅 어제 날짜가 저장됩니다"} + {mapping.value === "#TOMORROW" && "📅 내일 날짜가 저장됩니다"} + {mapping.value === "#WEEK_START" && "📅 이번 주 월요일이 저장됩니다"} + {mapping.value === "#MONTH_START" && "📅 이번 달 1일이 저장됩니다"} + {mapping.value === "#YEAR_START" && "📅 올해 1월 1일이 저장됩니다"} +
+ )} +
+ ); + } + + // 숫자 타입에 대한 특별 처리 + if ( + targetColumn?.webType === "number" || + targetColumn?.webType === "decimal" || + targetColumn?.dataType?.toLowerCase().includes("int") || + targetColumn?.dataType?.toLowerCase().includes("decimal") || + targetColumn?.dataType?.toLowerCase().includes("numeric") + ) { + return ( +
+ {/* 숫자 타입 선택 */} + + + {/* 직접 입력이 선택된 경우 */} + {(!mapping.value?.startsWith("#") || mapping.value === "#custom") && ( + updateFieldMapping(index, { value: e.target.value })} + /> + )} + + {/* 선택된 숫자 타입에 대한 설명 */} + {mapping.value?.startsWith("#") && mapping.value !== "#custom" && ( +
+ {mapping.value === "#AUTO_INCREMENT" && "🔢 데이터베이스에서 자동으로 증가하는 값이 할당됩니다"} + {mapping.value === "#RANDOM_INT" && "🎲 1부터 1000 사이의 랜덤한 정수가 생성됩니다"} + {mapping.value === "#ZERO" && "0️⃣ 0 값이 저장됩니다"} + {mapping.value === "#ONE" && "1️⃣ 1 값이 저장됩니다"} + {mapping.value === "#SEQUENCE" && "📈 시퀀스에서 다음 값을 가져옵니다"} +
+ )} +
+ ); + } + return ( = ({ - 필드 값 설정 (SET) - @@ -476,65 +684,98 @@ const ActionConditionBuilder: React.FC = ({ - {fieldMappings.length === 0 ? ( + {/* 매핑되지 않은 필드가 없는 경우 */} + {getUnmappedToColumns().length === 0 ? ( +
+
✅ 모든 필드가 매핑되었습니다
+

+ 컬럼 매핑으로 모든 TO 테이블 필드가 처리되고 있어 별도의 기본값 설정이 필요하지 않습니다. +

+
+ ) : fieldMappings.length === 0 ? (
-

조건을 만족할 때 설정할 필드 값을 지정하세요

+

매핑되지 않은 필드의 기본값을 설정하세요

+

+ 컬럼 매핑으로 처리되지 않은 필드들만 여기서 설정됩니다 +

+

+ 현재 {getUnmappedToColumns().length}개 필드가 매핑되지 않음 +

) : ( - fieldMappings.map((mapping, index) => { - const targetColumn = toColumns.find((col) => col.columnName === mapping.targetField); + (() => { + console.log("🎨 필드값 설정 렌더링:", { + fieldMappings, + fieldMappingsCount: fieldMappings.length, + }); + return fieldMappings.map((mapping, index) => { + const targetColumn = toColumns.find((col) => col.columnName === mapping.targetField); - return ( -
- {/* 대상 필드 */} - + return ( +
+ {/* 대상 필드 */} + - {/* 값 타입 */} - + {/* 값 타입 */} + - {/* 값 입력 */} -
{renderValueInput(mapping, index, targetColumn)}
+ {/* 값 입력 */} +
{renderValueInput(mapping, index, targetColumn)}
- {/* 삭제 버튼 */} - -
- ); - }) + {/* 삭제 버튼 */} + +
+ ); + }); + })() )}
diff --git a/frontend/components/dataflow/connection/redesigned/RightPanel/ConnectionStep.tsx b/frontend/components/dataflow/connection/redesigned/RightPanel/ConnectionStep.tsx index 1c47163f..9129f690 100644 --- a/frontend/components/dataflow/connection/redesigned/RightPanel/ConnectionStep.tsx +++ b/frontend/components/dataflow/connection/redesigned/RightPanel/ConnectionStep.tsx @@ -1,15 +1,19 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useCallback } from "react"; import { CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Badge } from "@/components/ui/badge"; -import { ArrowRight, Database, Globe, Loader2 } from "lucide-react"; +import { ArrowRight, Database, Globe, Loader2, AlertTriangle, CheckCircle } from "lucide-react"; import { toast } from "sonner"; // API import import { getActiveConnections, ConnectionInfo } from "@/lib/api/multiConnection"; +import { checkRelationshipNameDuplicate } from "@/lib/api/dataflowSave"; // 타입 import import { Connection } from "@/lib/types/multiConnection"; @@ -18,7 +22,12 @@ interface ConnectionStepProps { connectionType: "data_save" | "external_call"; fromConnection?: Connection; toConnection?: Connection; + relationshipName?: string; + description?: string; + diagramId?: number; // 🔧 수정 모드 감지용 onSelectConnection: (type: "from" | "to", connection: Connection) => void; + onSetRelationshipName: (name: string) => void; + onSetDescription: (description: string) => void; onNext: () => void; } @@ -29,9 +38,21 @@ interface ConnectionStepProps { * - 지연시간 정보 */ const ConnectionStep: React.FC = React.memo( - ({ connectionType, fromConnection, toConnection, onSelectConnection, onNext }) => { + ({ + connectionType, + fromConnection, + toConnection, + relationshipName, + description, + diagramId, + onSelectConnection, + onSetRelationshipName, + onSetDescription, + onNext, + }) => { const [connections, setConnections] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [nameCheckStatus, setNameCheckStatus] = useState<"idle" | "checking" | "valid" | "duplicate">("idle"); // API 응답을 Connection 타입으로 변환 const convertToConnection = (connectionInfo: ConnectionInfo): Connection => ({ @@ -48,6 +69,45 @@ const ConnectionStep: React.FC = React.memo( updatedDate: connectionInfo.updated_date, }); + // 🔍 관계명 중복 체크 (디바운스 적용) + const checkNameDuplicate = useCallback( + async (name: string) => { + if (!name.trim()) { + setNameCheckStatus("idle"); + return; + } + + setNameCheckStatus("checking"); + + try { + const result = await checkRelationshipNameDuplicate(name, diagramId); + setNameCheckStatus(result.isDuplicate ? "duplicate" : "valid"); + + if (result.isDuplicate) { + toast.warning(`"${name}" 이름이 이미 사용 중입니다. (${result.duplicateCount}개 발견)`); + } + } catch (error) { + console.error("중복 체크 실패:", error); + setNameCheckStatus("idle"); + } + }, + [diagramId], + ); + + // 관계명 변경 시 중복 체크 (디바운스) + useEffect(() => { + if (!relationshipName) { + setNameCheckStatus("idle"); + return; + } + + const timeoutId = setTimeout(() => { + checkNameDuplicate(relationshipName); + }, 500); // 500ms 디바운스 + + return () => clearTimeout(timeoutId); + }, [relationshipName, checkNameDuplicate]); + // 연결 목록 로드 useEffect(() => { const loadConnections = async () => { @@ -150,6 +210,50 @@ const ConnectionStep: React.FC = React.memo( + {/* 관계 정보 입력 */} +
+

관계 정보

+
+
+ +
+ onSetRelationshipName(e.target.value)} + className={`pr-10 ${ + nameCheckStatus === "duplicate" + ? "border-red-500 focus:border-red-500" + : nameCheckStatus === "valid" + ? "border-green-500 focus:border-green-500" + : "" + }`} + /> +
+ {nameCheckStatus === "checking" && ( + + )} + {nameCheckStatus === "valid" && } + {nameCheckStatus === "duplicate" && } +
+
+ {nameCheckStatus === "duplicate" &&

이미 사용 중인 이름입니다.

} + {nameCheckStatus === "valid" &&

사용 가능한 이름입니다.

} +
+
+ +