From e0777d0fc38055bc1dfe9aaf824393b8e77f9822 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 26 Sep 2025 17:52:11 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=A7=A4?= =?UTF-8?q?=ED=95=91=20=EC=84=A4=EC=A0=95=20=EC=A4=91=EA=B0=84=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/add-data-mapping-column.js | 34 ++ .../src/services/dataMappingService.ts | 575 ++++++++++++++++++ .../src/services/externalCallService.ts | 146 ++++- backend-node/src/types/dataMappingTypes.ts | 82 +++ .../external-call/DataMappingSettings.tsx | 382 ++++++++++++ .../external-call/ExternalCallPanel.tsx | 73 ++- .../external-call/FieldMappingEditor.tsx | 400 ++++++++++++ .../types/external-call/DataMappingTypes.ts | 150 +++++ vexplor.png | Bin 6866 -> 0 bytes 외부호출_데이터_매핑_시스템_설계서.md | 293 +++++++++ 10 files changed, 2129 insertions(+), 6 deletions(-) create mode 100644 backend-node/scripts/add-data-mapping-column.js create mode 100644 backend-node/src/services/dataMappingService.ts create mode 100644 backend-node/src/types/dataMappingTypes.ts create mode 100644 frontend/components/dataflow/external-call/DataMappingSettings.tsx create mode 100644 frontend/components/dataflow/external-call/FieldMappingEditor.tsx create mode 100644 frontend/types/external-call/DataMappingTypes.ts delete mode 100644 vexplor.png create mode 100644 외부호출_데이터_매핑_시스템_설계서.md diff --git a/backend-node/scripts/add-data-mapping-column.js b/backend-node/scripts/add-data-mapping-column.js new file mode 100644 index 00000000..cd7ee154 --- /dev/null +++ b/backend-node/scripts/add-data-mapping-column.js @@ -0,0 +1,34 @@ +const { PrismaClient } = require("@prisma/client"); + +const prisma = new PrismaClient(); + +async function addDataMappingColumn() { + try { + console.log( + "🔄 external_call_configs 테이블에 data_mapping_config 컬럼 추가 중..." + ); + + // data_mapping_config JSONB 컬럼 추가 + await prisma.$executeRaw` + ALTER TABLE external_call_configs + ADD COLUMN IF NOT EXISTS data_mapping_config JSONB + `; + + console.log("✅ data_mapping_config 컬럼이 성공적으로 추가되었습니다."); + + // 기존 레코드에 기본값 설정 + await prisma.$executeRaw` + UPDATE external_call_configs + SET data_mapping_config = '{"direction": "none"}'::jsonb + WHERE data_mapping_config IS NULL + `; + + console.log("✅ 기존 레코드에 기본값이 설정되었습니다."); + } catch (error) { + console.error("❌ 컬럼 추가 실패:", error); + } finally { + await prisma.$disconnect(); + } +} + +addDataMappingColumn(); diff --git a/backend-node/src/services/dataMappingService.ts b/backend-node/src/services/dataMappingService.ts new file mode 100644 index 00000000..af7e9759 --- /dev/null +++ b/backend-node/src/services/dataMappingService.ts @@ -0,0 +1,575 @@ +import { PrismaClient } from "@prisma/client"; +import { + DataMappingConfig, + InboundMapping, + OutboundMapping, + FieldMapping, + DataMappingResult, + MappingValidationResult, + FieldTransform, + DataType, +} from "../types/dataMappingTypes"; + +export class DataMappingService { + private prisma: PrismaClient; + + constructor() { + this.prisma = new PrismaClient(); + } + + /** + * Inbound 데이터 매핑 처리 (외부 → 내부) + */ + async processInboundData( + externalData: any, + mapping: InboundMapping + ): Promise { + const startTime = Date.now(); + const result: DataMappingResult = { + success: false, + direction: "inbound", + recordsProcessed: 0, + recordsInserted: 0, + recordsUpdated: 0, + recordsSkipped: 0, + errors: [], + executionTime: 0, + timestamp: new Date().toISOString(), + }; + + try { + console.log(`📥 [DataMappingService] Inbound 매핑 시작:`, { + targetTable: mapping.targetTable, + insertMode: mapping.insertMode, + fieldMappings: mapping.fieldMappings.length, + }); + + // 데이터 배열로 변환 + const dataArray = Array.isArray(externalData) + ? externalData + : [externalData]; + result.recordsProcessed = dataArray.length; + + // 각 레코드 처리 + for (const record of dataArray) { + try { + const mappedData = await this.mapInboundRecord(record, mapping); + + if (Object.keys(mappedData).length === 0) { + result.recordsSkipped!++; + continue; + } + + // 데이터베이스에 저장 + await this.saveInboundRecord(mappedData, mapping); + + if (mapping.insertMode === "insert") { + result.recordsInserted!++; + } else { + result.recordsUpdated!++; + } + } catch (error) { + console.error(`❌ [DataMappingService] 레코드 처리 실패:`, error); + result.errors!.push( + `레코드 처리 실패: ${error instanceof Error ? error.message : String(error)}` + ); + result.recordsSkipped!++; + } + } + + result.success = + result.errors!.length === 0 || + result.recordsInserted! > 0 || + result.recordsUpdated! > 0; + } catch (error) { + console.error(`❌ [DataMappingService] Inbound 매핑 실패:`, error); + result.errors!.push( + `매핑 처리 실패: ${error instanceof Error ? error.message : String(error)}` + ); + } + + result.executionTime = Date.now() - startTime; + + console.log(`✅ [DataMappingService] Inbound 매핑 완료:`, result); + return result; + } + + /** + * Outbound 데이터 매핑 처리 (내부 → 외부) + */ + async processOutboundData( + mapping: OutboundMapping, + filter?: any + ): Promise { + console.log(`📤 [DataMappingService] Outbound 매핑 시작:`, { + sourceTable: mapping.sourceTable, + fieldMappings: mapping.fieldMappings.length, + filter, + }); + + try { + // 소스 데이터 조회 + const sourceData = await this.getSourceData(mapping, filter); + + if ( + !sourceData || + (Array.isArray(sourceData) && sourceData.length === 0) + ) { + console.log(`⚠️ [DataMappingService] 소스 데이터가 없습니다.`); + return null; + } + + // 데이터 매핑 + const mappedData = Array.isArray(sourceData) + ? await Promise.all( + sourceData.map((record) => this.mapOutboundRecord(record, mapping)) + ) + : await this.mapOutboundRecord(sourceData, mapping); + + console.log(`✅ [DataMappingService] Outbound 매핑 완료:`, { + recordCount: Array.isArray(mappedData) ? mappedData.length : 1, + }); + + return mappedData; + } catch (error) { + console.error(`❌ [DataMappingService] Outbound 매핑 실패:`, error); + throw error; + } + } + + /** + * 단일 Inbound 레코드 매핑 + */ + private async mapInboundRecord( + sourceRecord: any, + mapping: InboundMapping + ): Promise> { + const mappedRecord: Record = {}; + + for (const fieldMapping of mapping.fieldMappings) { + try { + const sourceValue = sourceRecord[fieldMapping.sourceField]; + + // 필수 필드 체크 + if ( + fieldMapping.required && + (sourceValue === undefined || sourceValue === null) + ) { + if (fieldMapping.defaultValue !== undefined) { + mappedRecord[fieldMapping.targetField] = fieldMapping.defaultValue; + } else { + throw new Error( + `필수 필드 '${fieldMapping.sourceField}'가 누락되었습니다.` + ); + } + continue; + } + + // 값이 없으면 기본값 사용 + if (sourceValue === undefined || sourceValue === null) { + if (fieldMapping.defaultValue !== undefined) { + mappedRecord[fieldMapping.targetField] = fieldMapping.defaultValue; + } + continue; + } + + // 데이터 변환 적용 + const transformedValue = await this.transformFieldValue( + sourceValue, + fieldMapping.dataType, + fieldMapping.transform + ); + + mappedRecord[fieldMapping.targetField] = transformedValue; + } catch (error) { + console.error( + `❌ [DataMappingService] 필드 매핑 실패 (${fieldMapping.sourceField} → ${fieldMapping.targetField}):`, + error + ); + throw error; + } + } + + return mappedRecord; + } + + /** + * 단일 Outbound 레코드 매핑 + */ + private async mapOutboundRecord( + sourceRecord: any, + mapping: OutboundMapping + ): Promise> { + const mappedRecord: Record = {}; + + for (const fieldMapping of mapping.fieldMappings) { + try { + const sourceValue = sourceRecord[fieldMapping.sourceField]; + + // 데이터 변환 적용 + const transformedValue = await this.transformFieldValue( + sourceValue, + fieldMapping.dataType, + fieldMapping.transform + ); + + mappedRecord[fieldMapping.targetField] = transformedValue; + } catch (error) { + console.error( + `❌ [DataMappingService] 필드 매핑 실패 (${fieldMapping.sourceField} → ${fieldMapping.targetField}):`, + error + ); + throw error; + } + } + + return mappedRecord; + } + + /** + * 필드 값 변환 + */ + private async transformFieldValue( + value: any, + targetDataType: DataType, + transform?: FieldTransform + ): Promise { + let transformedValue = value; + + // 1. 변환 함수 적용 + if (transform) { + switch (transform.type) { + case "constant": + transformedValue = transform.value; + break; + + case "format": + if (targetDataType === "date" && transform.format) { + transformedValue = this.formatDate(value, transform.format); + } + break; + + case "function": + if (transform.functionName) { + transformedValue = await this.applyCustomFunction( + value, + transform.functionName + ); + } + break; + } + } + + // 2. 데이터 타입 변환 + return this.convertDataType(transformedValue, targetDataType); + } + + /** + * 데이터 타입 변환 + */ + private convertDataType(value: any, targetType: DataType): any { + if (value === null || value === undefined) return value; + + switch (targetType) { + case "string": + return String(value); + case "number": + const num = Number(value); + return isNaN(num) ? null : num; + case "boolean": + if (typeof value === "boolean") return value; + if (typeof value === "string") { + return ( + value.toLowerCase() === "true" || value === "1" || value === "Y" + ); + } + return Boolean(value); + case "date": + return new Date(value); + case "json": + return typeof value === "string" ? JSON.parse(value) : value; + default: + return value; + } + } + + /** + * 날짜 포맷 변환 + */ + private formatDate(value: any, format: string): string { + const date = new Date(value); + if (isNaN(date.getTime())) return value; + + // 간단한 날짜 포맷 변환 + switch (format) { + case "YYYY-MM-DD": + return date.toISOString().split("T")[0]; + case "YYYY-MM-DD HH:mm:ss": + return date + .toISOString() + .replace("T", " ") + .replace(/\.\d{3}Z$/, ""); + default: + return date.toISOString(); + } + } + + /** + * 커스텀 함수 적용 + */ + private async applyCustomFunction( + value: any, + functionName: string + ): Promise { + // 추후 확장 가능한 커스텀 함수들 + switch (functionName) { + case "upperCase": + return String(value).toUpperCase(); + case "lowerCase": + return String(value).toLowerCase(); + case "trim": + return String(value).trim(); + default: + console.warn( + `⚠️ [DataMappingService] 알 수 없는 함수: ${functionName}` + ); + return value; + } + } + + /** + * Inbound 데이터 저장 + */ + private async saveInboundRecord( + mappedData: Record, + mapping: InboundMapping + ): Promise { + const tableName = mapping.targetTable; + + try { + switch (mapping.insertMode) { + case "insert": + await this.executeInsert(tableName, mappedData); + break; + + case "upsert": + await this.executeUpsert( + tableName, + mappedData, + mapping.keyFields || [] + ); + break; + + case "update": + await this.executeUpdate( + tableName, + mappedData, + mapping.keyFields || [] + ); + break; + } + } catch (error) { + console.error( + `❌ [DataMappingService] 데이터 저장 실패 (${tableName}):`, + error + ); + throw error; + } + } + + /** + * 소스 데이터 조회 + */ + private async getSourceData( + mapping: OutboundMapping, + filter?: any + ): Promise { + const tableName = mapping.sourceTable; + + try { + // 동적 테이블 쿼리 (Prisma의 경우 런타임에서 제한적) + // 실제 구현에서는 각 테이블별 모델을 사용하거나 Raw SQL을 사용해야 함 + + let whereClause = {}; + if (mapping.sourceFilter) { + // 간단한 필터 파싱 (실제로는 더 정교한 파싱 필요) + console.log( + `🔍 [DataMappingService] 필터 조건: ${mapping.sourceFilter}` + ); + // TODO: 필터 조건 파싱 및 적용 + } + + if (filter) { + whereClause = { ...whereClause, ...filter }; + } + + // Raw SQL을 사용한 동적 쿼리 + const query = `SELECT * FROM ${tableName}${mapping.sourceFilter ? ` WHERE ${mapping.sourceFilter}` : ""}`; + console.log(`🔍 [DataMappingService] 쿼리 실행: ${query}`); + + const result = await this.prisma.$queryRawUnsafe(query); + return result; + } catch (error) { + console.error( + `❌ [DataMappingService] 소스 데이터 조회 실패 (${tableName}):`, + error + ); + throw error; + } + } + + /** + * INSERT 실행 + */ + private async executeInsert( + tableName: string, + data: Record + ): Promise { + const columns = Object.keys(data); + const values = Object.values(data); + const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); + + const query = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${placeholders})`; + + console.log(`📝 [DataMappingService] INSERT 실행:`, { + table: tableName, + columns, + query, + }); + await this.prisma.$executeRawUnsafe(query, ...values); + } + + /** + * UPSERT 실행 + */ + private async executeUpsert( + tableName: string, + data: Record, + keyFields: string[] + ): Promise { + if (keyFields.length === 0) { + throw new Error("UPSERT 모드에서는 키 필드가 필요합니다."); + } + + const columns = Object.keys(data); + const values = Object.values(data); + const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); + + const updateClauses = columns + .filter((col) => !keyFields.includes(col)) + .map((col) => `${col} = EXCLUDED.${col}`) + .join(", "); + + const query = ` + INSERT INTO ${tableName} (${columns.join(", ")}) + VALUES (${placeholders}) + ON CONFLICT (${keyFields.join(", ")}) + DO UPDATE SET ${updateClauses} + `; + + console.log(`🔄 [DataMappingService] UPSERT 실행:`, { + table: tableName, + keyFields, + query, + }); + await this.prisma.$executeRawUnsafe(query, ...values); + } + + /** + * UPDATE 실행 + */ + private async executeUpdate( + tableName: string, + data: Record, + keyFields: string[] + ): Promise { + if (keyFields.length === 0) { + throw new Error("UPDATE 모드에서는 키 필드가 필요합니다."); + } + + const updateColumns = Object.keys(data).filter( + (col) => !keyFields.includes(col) + ); + const updateClauses = updateColumns + .map((col, i) => `${col} = $${i + 1}`) + .join(", "); + + const whereConditions = keyFields + .map((field, i) => `${field} = $${updateColumns.length + i + 1}`) + .join(" AND "); + + const values = [ + ...updateColumns.map((col) => data[col]), + ...keyFields.map((field) => data[field]), + ]; + + const query = `UPDATE ${tableName} SET ${updateClauses} WHERE ${whereConditions}`; + + console.log(`✏️ [DataMappingService] UPDATE 실행:`, { + table: tableName, + keyFields, + query, + }); + await this.prisma.$executeRawUnsafe(query, ...values); + } + + /** + * 매핑 설정 검증 + */ + validateMappingConfig(config: DataMappingConfig): MappingValidationResult { + const result: MappingValidationResult = { + isValid: true, + errors: [], + warnings: [], + }; + + if (config.direction === "none") { + return result; + } + + // Inbound 매핑 검증 + if ( + (config.direction === "inbound" || + config.direction === "bidirectional") && + config.inboundMapping + ) { + if (!config.inboundMapping.targetTable) { + result.errors.push("Inbound 매핑에 대상 테이블이 필요합니다."); + } + if (config.inboundMapping.fieldMappings.length === 0) { + result.errors.push("Inbound 매핑에 필드 매핑이 필요합니다."); + } + if ( + config.inboundMapping.insertMode !== "insert" && + (!config.inboundMapping.keyFields || + config.inboundMapping.keyFields.length === 0) + ) { + result.errors.push("UPSERT/UPDATE 모드에서는 키 필드가 필요합니다."); + } + } + + // Outbound 매핑 검증 + if ( + (config.direction === "outbound" || + config.direction === "bidirectional") && + config.outboundMapping + ) { + if (!config.outboundMapping.sourceTable) { + result.errors.push("Outbound 매핑에 소스 테이블이 필요합니다."); + } + if (config.outboundMapping.fieldMappings.length === 0) { + result.errors.push("Outbound 매핑에 필드 매핑이 필요합니다."); + } + } + + result.isValid = result.errors.length === 0; + return result; + } + + /** + * 리소스 정리 + */ + async disconnect(): Promise { + await this.prisma.$disconnect(); + } +} diff --git a/backend-node/src/services/externalCallService.ts b/backend-node/src/services/externalCallService.ts index 54c0cbf9..e5cb2dd5 100644 --- a/backend-node/src/services/externalCallService.ts +++ b/backend-node/src/services/externalCallService.ts @@ -10,6 +10,11 @@ import { SupportedExternalCallSettings, TemplateOptions, } from "../types/externalCallTypes"; +import { DataMappingService } from "./dataMappingService"; +import { + DataMappingConfig, + DataMappingResult, +} from "../types/dataMappingTypes"; /** * 외부 호출 서비스 @@ -18,10 +23,149 @@ import { export class ExternalCallService { private readonly DEFAULT_TIMEOUT = 30000; // 30초 private readonly DEFAULT_RETRY_COUNT = 3; + private dataMappingService: DataMappingService; private readonly DEFAULT_RETRY_DELAY = 1000; // 1초 + constructor() { + this.dataMappingService = new DataMappingService(); + } + /** - * 외부 호출 실행 + * 데이터 매핑과 함께 외부 호출 실행 + */ + async executeWithDataMapping( + config: ExternalCallConfig, + dataMappingConfig?: DataMappingConfig, + triggerData?: any + ): Promise<{ + callResult: ExternalCallResult; + mappingResult?: DataMappingResult; + }> { + const startTime = Date.now(); + + console.log(`🚀 [ExternalCallService] 데이터 매핑 포함 외부 호출 시작:`, { + callType: config.callType, + hasMappingConfig: !!dataMappingConfig, + mappingDirection: dataMappingConfig?.direction, + }); + + try { + let requestData = config; + + // Outbound 매핑 처리 (내부 → 외부) + if ( + dataMappingConfig?.direction === "outbound" && + dataMappingConfig.outboundMapping + ) { + console.log(`📤 [ExternalCallService] Outbound 매핑 처리 시작`); + + const outboundData = await this.dataMappingService.processOutboundData( + dataMappingConfig.outboundMapping, + triggerData + ); + + // API 요청 바디에 매핑된 데이터 포함 + if (config.callType === "rest-api") { + // GenericApiSettings로 타입 캐스팅 + const apiConfig = config as GenericApiSettings; + const bodyTemplate = apiConfig.body || "{}"; + + // 템플릿에 데이터 삽입 + const processedBody = this.processTemplate(bodyTemplate, { + mappedData: outboundData, + triggerData, + ...outboundData, + }); + + requestData = { + ...config, + body: processedBody, + } as GenericApiSettings; + } + } + + // 외부 호출 실행 + const callRequest: ExternalCallRequest = { + diagramId: 0, // 임시값 + relationshipId: "data-mapping", // 임시값 + settings: requestData, + templateData: triggerData, + }; + const callResult = await this.executeExternalCall(callRequest); + + let mappingResult: DataMappingResult | undefined; + + // Inbound 매핑 처리 (외부 → 내부) + if ( + callResult.success && + dataMappingConfig?.direction === "inbound" && + dataMappingConfig.inboundMapping + ) { + console.log(`📥 [ExternalCallService] Inbound 매핑 처리 시작`); + + try { + // 응답 데이터 파싱 + let responseData = callResult.response; + if (typeof responseData === "string") { + try { + responseData = JSON.parse(responseData); + } catch { + console.warn( + `⚠️ [ExternalCallService] 응답 데이터 JSON 파싱 실패, 문자열로 처리` + ); + } + } + + mappingResult = await this.dataMappingService.processInboundData( + responseData, + dataMappingConfig.inboundMapping + ); + + console.log(`✅ [ExternalCallService] Inbound 매핑 완료:`, { + recordsProcessed: mappingResult.recordsProcessed, + recordsInserted: mappingResult.recordsInserted, + }); + } catch (error) { + console.error(`❌ [ExternalCallService] Inbound 매핑 실패:`, error); + mappingResult = { + success: false, + direction: "inbound", + errors: [error instanceof Error ? error.message : String(error)], + executionTime: Date.now() - startTime, + timestamp: new Date().toISOString(), + }; + } + } + + // 양방향 매핑 처리 + if (dataMappingConfig?.direction === "bidirectional") { + // 필요한 경우 양방향 매핑 로직 구현 + console.log(`🔄 [ExternalCallService] 양방향 매핑은 향후 구현 예정`); + } + + const result = { + callResult, + mappingResult, + }; + + console.log(`✅ [ExternalCallService] 데이터 매핑 포함 외부 호출 완료:`, { + callSuccess: callResult.success, + mappingSuccess: mappingResult?.success, + totalExecutionTime: Date.now() - startTime, + }); + + return result; + } catch (error) { + console.error( + `❌ [ExternalCallService] 데이터 매핑 포함 외부 호출 실패:`, + error + ); + throw error; + } + } + + /** + * 기존 외부 호출 실행 (매핑 없음) */ async executeExternalCall( request: ExternalCallRequest diff --git a/backend-node/src/types/dataMappingTypes.ts b/backend-node/src/types/dataMappingTypes.ts new file mode 100644 index 00000000..8296fe3c --- /dev/null +++ b/backend-node/src/types/dataMappingTypes.ts @@ -0,0 +1,82 @@ +/** + * 백엔드 데이터 매핑 관련 타입 정의 + */ + +export type DataDirection = "none" | "inbound" | "outbound" | "bidirectional"; +export type InsertMode = "insert" | "upsert" | "update"; +export type TransformType = "none" | "constant" | "format" | "function"; +export type DataType = "string" | "number" | "boolean" | "date" | "json"; + +export interface FieldTransform { + type: TransformType; + value?: any; + format?: string; + functionName?: string; +} + +export interface FieldMapping { + id: string; + sourceField: string; + targetField: string; + dataType: DataType; + transform?: FieldTransform; + required?: boolean; + defaultValue?: any; +} + +export interface InboundMapping { + targetTable: string; + targetSchema?: string; + fieldMappings: FieldMapping[]; + insertMode: InsertMode; + keyFields?: string[]; + batchSize?: number; +} + +export interface OutboundMapping { + sourceTable: string; + sourceSchema?: string; + sourceFilter?: string; + fieldMappings: FieldMapping[]; + triggerCondition?: string; +} + +export interface DataMappingConfig { + direction: DataDirection; + inboundMapping?: InboundMapping; + outboundMapping?: OutboundMapping; +} + +export interface TableInfo { + name: string; + schema?: string; + displayName?: string; + fields: FieldInfo[]; +} + +export interface FieldInfo { + name: string; + dataType: DataType; + nullable: boolean; + isPrimaryKey?: boolean; + displayName?: string; + description?: string; +} + +export interface DataMappingResult { + success: boolean; + direction: DataDirection; + recordsProcessed?: number; + recordsInserted?: number; + recordsUpdated?: number; + recordsSkipped?: number; + errors?: string[]; + executionTime: number; + timestamp: string; +} + +export interface MappingValidationResult { + isValid: boolean; + errors: string[]; + warnings: string[]; +} diff --git a/frontend/components/dataflow/external-call/DataMappingSettings.tsx b/frontend/components/dataflow/external-call/DataMappingSettings.tsx new file mode 100644 index 00000000..a21a974c --- /dev/null +++ b/frontend/components/dataflow/external-call/DataMappingSettings.tsx @@ -0,0 +1,382 @@ +"use client"; + +import React, { useState, useCallback, useEffect } from "react"; +import { Card, 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { Plus, Trash2, Database, ArrowRight, Settings } from "lucide-react"; + +import { + DataMappingConfig, + DataDirection, + TableInfo, + FieldMapping, + InboundMapping, + OutboundMapping, + DATA_DIRECTION_OPTIONS, + INSERT_MODE_OPTIONS, +} from "@/types/external-call/DataMappingTypes"; + +import { FieldMappingEditor } from "./FieldMappingEditor"; + +interface DataMappingSettingsProps { + config: DataMappingConfig; + onConfigChange: (config: DataMappingConfig) => void; + httpMethod: string; + availableTables?: TableInfo[]; + readonly?: boolean; +} + +export const DataMappingSettings: React.FC = ({ + config, + onConfigChange, + httpMethod, + availableTables = [], + readonly = false, +}) => { + const [localConfig, setLocalConfig] = useState(config); + + // 컴포넌트 변경 시 로컬 상태 동기화 + useEffect(() => { + setLocalConfig(config); + }, [config]); + + // HTTP 메서드에 따른 권장 방향 결정 + const getRecommendedDirection = useCallback((method: string): DataDirection => { + const upperMethod = method.toUpperCase(); + if (upperMethod === "GET") return "inbound"; + if (["POST", "PUT", "PATCH"].includes(upperMethod)) return "outbound"; + return "none"; + }, []); + + // 방향 변경 핸들러 + const handleDirectionChange = useCallback( + (direction: DataDirection) => { + const newConfig = { + ...localConfig, + direction, + // 방향에 따라 불필요한 매핑 제거 + inboundMapping: + direction === "inbound" || direction === "bidirectional" + ? localConfig.inboundMapping || { + targetTable: "", + fieldMappings: [], + insertMode: "insert" as const, + } + : undefined, + outboundMapping: + direction === "outbound" || direction === "bidirectional" + ? localConfig.outboundMapping || { + sourceTable: "", + fieldMappings: [], + } + : undefined, + }; + setLocalConfig(newConfig); + onConfigChange(newConfig); + }, + [localConfig, onConfigChange], + ); + + // Inbound 매핑 업데이트 + const handleInboundMappingChange = useCallback( + (mapping: Partial) => { + const newConfig = { + ...localConfig, + inboundMapping: { + ...localConfig.inboundMapping!, + ...mapping, + }, + }; + setLocalConfig(newConfig); + onConfigChange(newConfig); + }, + [localConfig, onConfigChange], + ); + + // Outbound 매핑 업데이트 + const handleOutboundMappingChange = useCallback( + (mapping: Partial) => { + const newConfig = { + ...localConfig, + outboundMapping: { + ...localConfig.outboundMapping!, + ...mapping, + }, + }; + setLocalConfig(newConfig); + onConfigChange(newConfig); + }, + [localConfig, onConfigChange], + ); + + // 필드 매핑 업데이트 (Inbound) + const handleInboundFieldMappingsChange = useCallback( + (fieldMappings: FieldMapping[]) => { + handleInboundMappingChange({ fieldMappings }); + }, + [handleInboundMappingChange], + ); + + // 필드 매핑 업데이트 (Outbound) + const handleOutboundFieldMappingsChange = useCallback( + (fieldMappings: FieldMapping[]) => { + handleOutboundMappingChange({ fieldMappings }); + }, + [handleOutboundMappingChange], + ); + + // 검증 함수 + const isConfigValid = useCallback(() => { + if (localConfig.direction === "none") return true; + + if ( + (localConfig.direction === "inbound" || localConfig.direction === "bidirectional") && + localConfig.inboundMapping + ) { + if (!localConfig.inboundMapping.targetTable) return false; + if (localConfig.inboundMapping.fieldMappings.length === 0) return false; + } + + if ( + (localConfig.direction === "outbound" || localConfig.direction === "bidirectional") && + localConfig.outboundMapping + ) { + if (!localConfig.outboundMapping.sourceTable) return false; + if (localConfig.outboundMapping.fieldMappings.length === 0) return false; + } + + return true; + }, [localConfig]); + + const recommendedDirection = getRecommendedDirection(httpMethod); + + return ( + + + + + 데이터 매핑 설정 + {!isConfigValid() && 설정 필요} + {isConfigValid() && localConfig.direction !== "none" && 설정 완료} + +

외부 API와 내부 테이블 간의 데이터 매핑을 설정합니다.

+
+ + {/* 매핑 방향 선택 */} +
+ + + {localConfig.direction !== recommendedDirection && recommendedDirection !== "none" && ( +

+ 💡 {httpMethod} 요청에는 "{DATA_DIRECTION_OPTIONS.find((o) => o.value === recommendedDirection)?.label}" + 방향이 권장됩니다. +

+ )} +
+ + {/* 매핑 설정 탭 */} + {localConfig.direction !== "none" && ( + + + {(localConfig.direction === "inbound" || localConfig.direction === "bidirectional") && ( + + + 외부 → 내부 + + )} + {(localConfig.direction === "outbound" || localConfig.direction === "bidirectional") && ( + + + 내부 → 외부 + + )} + + + {/* Inbound 매핑 설정 */} + {(localConfig.direction === "inbound" || localConfig.direction === "bidirectional") && ( + +
+
+ + +
+ +
+ + +
+
+ + {/* 키 필드 설정 (upsert/update 모드일 때) */} + {localConfig.inboundMapping?.insertMode !== "insert" && ( +
+ + + handleInboundMappingChange({ + keyFields: e.target.value + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + }) + } + placeholder="id, code" + disabled={readonly} + /> +

+ 업데이트/업서트 시 사용할 키 필드를 쉼표로 구분하여 입력하세요. +

+
+ )} + + {/* 필드 매핑 에디터 */} + {localConfig.inboundMapping?.targetTable && ( + t.name === localConfig.inboundMapping?.targetTable)} + readonly={readonly} + /> + )} +
+ )} + + {/* Outbound 매핑 설정 */} + {(localConfig.direction === "outbound" || localConfig.direction === "bidirectional") && ( + +
+
+ + +
+
+ + {/* 소스 필터 조건 */} +
+ +