diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index 58e742fa..12cefea0 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -21,7 +21,7 @@ "imap": "^0.8.19", "joi": "^17.11.0", "jsonwebtoken": "^9.0.2", - "mailparser": "^3.7.4", + "mailparser": "^3.7.5", "mssql": "^11.0.1", "multer": "^1.4.5-lts.1", "mysql2": "^3.15.0", @@ -7733,48 +7733,52 @@ } }, "node_modules/mailparser": { - "version": "3.7.4", - "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.7.4.tgz", - "integrity": "sha512-Beh4yyR4jLq3CZZ32asajByrXnW8dLyKCAQD3WvtTiBnMtFWhxO+wa93F6sJNjDmfjxXs4NRNjw3XAGLqZR3Vg==", + "version": "3.7.5", + "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.7.5.tgz", + "integrity": "sha512-o59RgZC+4SyCOn4xRH1mtRiZ1PbEmi6si6Ufnd3tbX/V9zmZN1qcqu8xbXY62H6CwIclOT3ppm5u/wV2nujn4g==", "license": "MIT", "dependencies": { "encoding-japanese": "2.2.0", "he": "1.2.0", "html-to-text": "9.0.5", - "iconv-lite": "0.6.3", + "iconv-lite": "0.7.0", "libmime": "5.3.7", "linkify-it": "5.0.0", - "mailsplit": "5.4.5", - "nodemailer": "7.0.4", + "mailsplit": "5.4.6", + "nodemailer": "7.0.9", "punycode.js": "2.3.1", - "tlds": "1.259.0" + "tlds": "1.260.0" } }, "node_modules/mailparser/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/mailparser/node_modules/nodemailer": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.4.tgz", - "integrity": "sha512-9O00Vh89/Ld2EcVCqJ/etd7u20UhME0f/NToPfArwPEe1Don1zy4mAIz6ariRr7mJ2RDxtaDzN0WJVdVXPtZaw==", + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.9.tgz", + "integrity": "sha512-9/Qm0qXIByEP8lEV2qOqcAW7bRpL8CR9jcTwk3NBnHJNmP9fIJ86g2fgmIXqHY+nj55ZEMwWqYAT2QTDpRUYiQ==", "license": "MIT-0", "engines": { "node": ">=6.0.0" } }, "node_modules/mailsplit": { - "version": "5.4.5", - "resolved": "https://registry.npmjs.org/mailsplit/-/mailsplit-5.4.5.tgz", - "integrity": "sha512-oMfhmvclR689IIaQmIcR5nODnZRRVwAKtqFT407TIvmhX2OLUBnshUTcxzQBt3+96sZVDud9NfSe1NxAkUNXEQ==", + "version": "5.4.6", + "resolved": "https://registry.npmjs.org/mailsplit/-/mailsplit-5.4.6.tgz", + "integrity": "sha512-M+cqmzaPG/mEiCDmqQUz8L177JZLZmXAUpq38owtpq2xlXlTSw+kntnxRt2xsxVFFV6+T8Mj/U0l5s7s6e0rNw==", "license": "(MIT OR EUPL-1.1+)", "dependencies": { "libbase64": "1.3.0", @@ -9797,9 +9801,9 @@ "license": "MIT" }, "node_modules/tlds": { - "version": "1.259.0", - "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.259.0.tgz", - "integrity": "sha512-AldGGlDP0PNgwppe2quAvuBl18UcjuNtOnDuUkqhd6ipPqrYYBt3aTxK1QTsBVknk97lS2JcafWMghjGWFtunw==", + "version": "1.260.0", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.260.0.tgz", + "integrity": "sha512-78+28EWBhCEE7qlyaHA9OR3IPvbCLiDh3Ckla593TksfFc9vfTsgvH7eS+dr3o9qr31gwGbogcI16yN91PoRjQ==", "license": "MIT", "bin": { "tlds": "bin.js" diff --git a/backend-node/package.json b/backend-node/package.json index b306260c..befdcb15 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -35,7 +35,7 @@ "imap": "^0.8.19", "joi": "^17.11.0", "jsonwebtoken": "^9.0.2", - "mailparser": "^3.7.4", + "mailparser": "^3.7.5", "mssql": "^11.0.1", "multer": "^1.4.5-lts.1", "mysql2": "^3.15.0", diff --git a/backend-node/src/services/batchService.ts b/backend-node/src/services/batchService.ts index 00d87d66..31368be9 100644 --- a/backend-node/src/services/batchService.ts +++ b/backend-node/src/services/batchService.ts @@ -61,23 +61,31 @@ export class BatchService { // 배치 설정 조회 (매핑 포함 - 서브쿼리 사용) const batchConfigs = await query( - `SELECT bc.*, + `SELECT bc.id, bc.batch_name, bc.description, bc.cron_schedule, + bc.is_active, bc.company_code, bc.created_date, bc.created_by, + bc.updated_date, bc.updated_by, COALESCE( json_agg( json_build_object( - 'mapping_id', bm.mapping_id, - 'batch_id', bm.batch_id, - 'source_column', bm.source_column, - 'target_column', bm.target_column, - 'transformation_rule', bm.transformation_rule + 'id', bm.id, + 'batch_config_id', bm.batch_config_id, + 'from_connection_type', bm.from_connection_type, + 'from_connection_id', bm.from_connection_id, + 'from_table_name', bm.from_table_name, + 'from_column_name', bm.from_column_name, + 'to_connection_type', bm.to_connection_type, + 'to_connection_id', bm.to_connection_id, + 'to_table_name', bm.to_table_name, + 'to_column_name', bm.to_column_name, + 'mapping_order', bm.mapping_order ) - ) FILTER (WHERE bm.mapping_id IS NOT NULL), + ) FILTER (WHERE bm.id IS NOT NULL), '[]' ) as batch_mappings FROM batch_configs bc - LEFT JOIN batch_mappings bm ON bc.batch_id = bm.batch_id + LEFT JOIN batch_mappings bm ON bc.id = bm.batch_config_id ${whereClause} - GROUP BY bc.batch_id + GROUP BY bc.id ORDER BY bc.is_active DESC, bc.batch_name ASC LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, [...values, limit, offset] @@ -85,7 +93,7 @@ export class BatchService { // 전체 개수 조회 const countResult = await queryOne<{ count: string }>( - `SELECT COUNT(DISTINCT bc.batch_id) as count + `SELECT COUNT(DISTINCT bc.id) as count FROM batch_configs bc ${whereClause}`, values @@ -121,29 +129,34 @@ export class BatchService { ): Promise> { try { const batchConfig = await queryOne( - `SELECT bc.*, + `SELECT bc.id, bc.batch_name, bc.description, bc.cron_schedule, + bc.is_active, bc.company_code, bc.created_date, bc.created_by, + bc.updated_date, bc.updated_by, COALESCE( json_agg( json_build_object( - 'mapping_id', bm.mapping_id, - 'batch_id', bm.batch_id, + 'id', bm.id, + 'batch_config_id', bm.batch_config_id, + 'from_connection_type', bm.from_connection_type, + 'from_connection_id', bm.from_connection_id, 'from_table_name', bm.from_table_name, 'from_column_name', bm.from_column_name, + 'from_column_type', bm.from_column_type, + 'to_connection_type', bm.to_connection_type, + 'to_connection_id', bm.to_connection_id, 'to_table_name', bm.to_table_name, 'to_column_name', bm.to_column_name, - 'mapping_order', bm.mapping_order, - 'source_column', bm.source_column, - 'target_column', bm.target_column, - 'transformation_rule', bm.transformation_rule + 'to_column_type', bm.to_column_type, + 'mapping_order', bm.mapping_order ) ORDER BY bm.from_table_name ASC, bm.from_column_name ASC, bm.mapping_order ASC - ) FILTER (WHERE bm.mapping_id IS NOT NULL), + ) FILTER (WHERE bm.id IS NOT NULL), '[]' ) as batch_mappings FROM batch_configs bc - LEFT JOIN batch_mappings bm ON bc.batch_id = bm.batch_id + LEFT JOIN batch_mappings bm ON bc.id = bm.batch_config_id WHERE bc.id = $1 - GROUP BY bc.batch_id`, + GROUP BY bc.id`, [id] ); @@ -268,16 +281,16 @@ export class BatchService { COALESCE( json_agg( json_build_object( - 'mapping_id', bm.mapping_id, - 'batch_id', bm.batch_id + 'id', bm.id, + 'batch_config_id', bm.batch_config_id ) - ) FILTER (WHERE bm.mapping_id IS NOT NULL), + ) FILTER (WHERE bm.id IS NOT NULL), '[]' ) as batch_mappings FROM batch_configs bc - LEFT JOIN batch_mappings bm ON bc.batch_id = bm.batch_id + LEFT JOIN batch_mappings bm ON bc.id = bm.batch_config_id WHERE bc.id = $1 - GROUP BY bc.batch_id`, + GROUP BY bc.id`, [id] ); diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index b27e6784..cb5767bf 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -10,6 +10,7 @@ import { query, queryOne, transaction } from "../database/db"; import { logger } from "../utils/logger"; +import axios from "axios"; // ===== 타입 정의 ===== @@ -410,6 +411,9 @@ export class NodeFlowExecutionService { case "tableSource": return this.executeTableSource(node, context); + case "restAPISource": + return this.executeRestAPISource(node, context); + case "dataTransform": return this.executeDataTransform(node, inputData, context); @@ -440,6 +444,123 @@ export class NodeFlowExecutionService { } } + /** + * REST API 소스 노드 실행 + */ + private static async executeRestAPISource( + node: FlowNode, + context: ExecutionContext + ): Promise { + const { + url, + method = "GET", + headers = {}, + body, + timeout = 30000, + responseMapping, + authentication, + } = node.data; + + if (!url) { + throw new Error("REST API URL이 설정되지 않았습니다."); + } + + logger.info(`🌐 REST API 호출: ${method} ${url}`); + + try { + // 헤더 설정 + const requestHeaders: any = { ...headers }; + + // 인증 헤더 추가 + if (authentication) { + if (authentication.type === "bearer" && authentication.token) { + requestHeaders["Authorization"] = `Bearer ${authentication.token}`; + } else if ( + authentication.type === "basic" && + authentication.username && + authentication.password + ) { + const credentials = Buffer.from( + `${authentication.username}:${authentication.password}` + ).toString("base64"); + requestHeaders["Authorization"] = `Basic ${credentials}`; + } else if (authentication.type === "apikey" && authentication.token) { + const headerName = authentication.apiKeyHeader || "X-API-Key"; + requestHeaders[headerName] = authentication.token; + } + } + + if (!requestHeaders["Content-Type"]) { + requestHeaders["Content-Type"] = "application/json"; + } + + // API 호출 + const response = await axios({ + method: method.toLowerCase(), + url, + headers: requestHeaders, + data: body, + timeout, + }); + + logger.info(`✅ REST API 응답 수신: ${response.status}`); + + let responseData = response.data; + + // 🔥 표준 API 응답 형식 자동 감지 { success, message, data } + if ( + !responseMapping && + responseData && + typeof responseData === "object" && + "success" in responseData && + "data" in responseData + ) { + logger.info("🔍 표준 API 응답 형식 감지, data 속성 자동 추출"); + responseData = responseData.data; + } + + // responseMapping이 있으면 해당 경로의 데이터 추출 + if (responseMapping && responseData) { + logger.info(`🔍 응답 매핑 적용: ${responseMapping}`); + const path = responseMapping.split("."); + for (const key of path) { + if ( + responseData && + typeof responseData === "object" && + key in responseData + ) { + responseData = responseData[key]; + } else { + logger.warn( + `⚠️ 응답 매핑 경로를 찾을 수 없습니다: ${responseMapping}` + ); + break; + } + } + } + + // 배열이 아니면 배열로 변환 + if (!Array.isArray(responseData)) { + logger.info("🔄 단일 객체를 배열로 변환"); + responseData = [responseData]; + } + + logger.info(`📦 REST API 데이터 ${responseData.length}건 반환`); + + // 첫 번째 데이터 샘플 상세 로깅 + if (responseData.length > 0) { + console.log("🔍 REST API 응답 데이터 샘플 (첫 번째 항목):"); + console.log(JSON.stringify(responseData[0], null, 2)); + console.log("🔑 사용 가능한 필드명:", Object.keys(responseData[0])); + } + + return responseData; + } catch (error: any) { + logger.error(`❌ REST API 호출 실패:`, error.message); + throw new Error(`REST API 호출 실패: ${error.message}`); + } + } + /** * 테이블 소스 노드 실행 */ @@ -521,6 +642,19 @@ export class NodeFlowExecutionService { ): Promise { const { targetTable, fieldMappings } = node.data; + logger.info(`💾 INSERT 노드 실행: ${targetTable}`); + console.log( + "📥 입력 데이터 타입:", + typeof inputData, + Array.isArray(inputData) ? `배열(${inputData.length}건)` : "단일 객체" + ); + + if (inputData && inputData.length > 0) { + console.log("📄 첫 번째 입력 데이터:"); + console.log(JSON.stringify(inputData[0], null, 2)); + console.log("🔑 입력 데이터 필드명:", Object.keys(inputData[0])); + } + return transaction(async (client) => { const dataArray = Array.isArray(inputData) ? inputData : [inputData]; let insertedCount = 0; @@ -529,12 +663,17 @@ export class NodeFlowExecutionService { const fields: string[] = []; const values: any[] = []; + console.log("🗺️ 필드 매핑 처리 중..."); fieldMappings.forEach((mapping: any) => { fields.push(mapping.targetField); const value = mapping.staticValue !== undefined ? mapping.staticValue : data[mapping.sourceField]; + + console.log( + ` ${mapping.sourceField} → ${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}` + ); values.push(value); }); @@ -543,6 +682,9 @@ export class NodeFlowExecutionService { VALUES (${fields.map((_, i) => `$${i + 1}`).join(", ")}) `; + console.log("📝 실행할 SQL:", sql); + console.log("📊 바인딩 값:", values); + await client.query(sql, values); insertedCount++; } @@ -682,7 +824,6 @@ export class NodeFlowExecutionService { logger.info(`🌐 REST API INSERT 시작: ${apiMethod} ${apiEndpoint}`); - const axios = require("axios"); const dataArray = Array.isArray(inputData) ? inputData : [inputData]; const results: any[] = []; @@ -895,6 +1036,19 @@ export class NodeFlowExecutionService { ): Promise { const { targetTable, fieldMappings, whereConditions } = node.data; + logger.info(`🔄 UPDATE 노드 실행: ${targetTable}`); + console.log( + "📥 입력 데이터 타입:", + typeof inputData, + Array.isArray(inputData) ? `배열(${inputData.length}건)` : "단일 객체" + ); + + if (inputData && inputData.length > 0) { + console.log("📄 첫 번째 입력 데이터:"); + console.log(JSON.stringify(inputData[0], null, 2)); + console.log("🔑 입력 데이터 필드명:", Object.keys(inputData[0])); + } + return transaction(async (client) => { const dataArray = Array.isArray(inputData) ? inputData : [inputData]; let updatedCount = 0; @@ -904,11 +1058,16 @@ export class NodeFlowExecutionService { const values: any[] = []; let paramIndex = 1; + console.log("🗺️ 필드 매핑 처리 중..."); fieldMappings.forEach((mapping: any) => { const value = mapping.staticValue !== undefined ? mapping.staticValue : data[mapping.sourceField]; + + console.log( + ` ${mapping.sourceField} → ${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}` + ); setClauses.push(`${mapping.targetField} = $${paramIndex}`); values.push(value); paramIndex++; @@ -926,6 +1085,9 @@ export class NodeFlowExecutionService { ${whereClause} `; + console.log("📝 실행할 SQL:", sql); + console.log("📊 바인딩 값:", values); + const result = await client.query(sql, values); updatedCount += result.rowCount || 0; } @@ -1086,7 +1248,6 @@ export class NodeFlowExecutionService { logger.info(`🌐 REST API UPDATE 시작: ${apiMethod} ${apiEndpoint}`); - const axios = require("axios"); const dataArray = Array.isArray(inputData) ? inputData : [inputData]; const results: any[] = []; @@ -1197,15 +1358,31 @@ export class NodeFlowExecutionService { ): Promise { const { targetTable, whereConditions } = node.data; + logger.info(`🗑️ DELETE 노드 실행: ${targetTable}`); + console.log( + "📥 입력 데이터 타입:", + typeof inputData, + Array.isArray(inputData) ? `배열(${inputData.length}건)` : "단일 객체" + ); + + if (inputData && inputData.length > 0) { + console.log("📄 첫 번째 입력 데이터:"); + console.log(JSON.stringify(inputData[0], null, 2)); + console.log("🔑 입력 데이터 필드명:", Object.keys(inputData[0])); + } + return transaction(async (client) => { const dataArray = Array.isArray(inputData) ? inputData : [inputData]; let deletedCount = 0; for (const data of dataArray) { + console.log("🔍 WHERE 조건 처리 중..."); const whereClause = this.buildWhereClause(whereConditions, data, 1); const sql = `DELETE FROM ${targetTable} ${whereClause}`; + console.log("📝 실행할 SQL:", sql); + const result = await client.query(sql, []); deletedCount += result.rowCount || 0; } @@ -1339,7 +1516,6 @@ export class NodeFlowExecutionService { logger.info(`🌐 REST API DELETE 시작: ${apiEndpoint}`); - const axios = require("axios"); const dataArray = Array.isArray(inputData) ? inputData : [inputData]; const results: any[] = []; @@ -1440,6 +1616,20 @@ export class NodeFlowExecutionService { throw new Error("UPSERT 액션에 충돌 키(Conflict Keys)가 필요합니다."); } + logger.info(`🔀 UPSERT 노드 실행: ${targetTable}`); + console.log( + "📥 입력 데이터 타입:", + typeof inputData, + Array.isArray(inputData) ? `배열(${inputData.length}건)` : "단일 객체" + ); + + if (inputData && inputData.length > 0) { + console.log("📄 첫 번째 입력 데이터:"); + console.log(JSON.stringify(inputData[0], null, 2)); + console.log("🔑 입력 데이터 필드명:", Object.keys(inputData[0])); + } + console.log("🔑 충돌 키:", conflictKeys); + return transaction(async (client) => { const dataArray = Array.isArray(inputData) ? inputData : [inputData]; let insertedCount = 0; @@ -1466,7 +1656,10 @@ export class NodeFlowExecutionService { (key: string) => conflictKeyValues[key] ); - const checkSql = `SELECT id FROM ${targetTable} WHERE ${whereConditions} LIMIT 1`; + console.log("🔍 존재 여부 확인 - WHERE 조건:", whereConditions); + console.log("🔍 존재 여부 확인 - 바인딩 값:", whereValues); + + const checkSql = `SELECT 1 FROM ${targetTable} WHERE ${whereConditions} LIMIT 1`; const existingRow = await client.query(checkSql, whereValues); if (existingRow.rows.length > 0) { @@ -1780,7 +1973,6 @@ export class NodeFlowExecutionService { logger.info(`🌐 REST API UPSERT 시작: ${apiMethod} ${apiEndpoint}`); - const axios = require("axios"); const dataArray = Array.isArray(inputData) ? inputData : [inputData]; const results: any[] = []; @@ -1977,6 +2169,20 @@ export class NodeFlowExecutionService { const success = summary.failed === 0; + // 실패한 노드 상세 로깅 + if (!success) { + const failedNodes = nodeSummaries.filter((n) => n.status === "failed"); + logger.error( + `❌ 실패한 노드들:`, + failedNodes.map((n) => ({ + nodeId: n.nodeId, + nodeName: n.nodeName, + nodeType: n.nodeType, + error: n.error, + })) + ); + } + return { success, message: success diff --git a/docker-compose.backend.win.yml b/docker-compose.backend.win.yml index bef844dc..35dbf42a 100644 --- a/docker-compose.backend.win.yml +++ b/docker-compose.backend.win.yml @@ -15,6 +15,7 @@ services: - DATABASE_URL=postgresql://postgres:ph0909!!@39.117.244.52:11132/plm - JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024 - JWT_EXPIRES_IN=24h + - ENCRYPTION_KEY=ilshin-plm-encryption-key-2024-secure-32bytes - CORS_ORIGIN=http://localhost:9771 - CORS_CREDENTIALS=true - LOG_LEVEL=debug @@ -26,7 +27,18 @@ services: - pms-network restart: unless-stopped healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health", "||", "exit", "1"] + test: + [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://localhost:8080/health", + "||", + "exit", + "1", + ] interval: 30s timeout: 15s retries: 5 diff --git a/frontend/app/(main)/admin/batchmng/page.tsx b/frontend/app/(main)/admin/batchmng/page.tsx index cee17c38..184ae578 100644 --- a/frontend/app/(main)/admin/batchmng/page.tsx +++ b/frontend/app/(main)/admin/batchmng/page.tsx @@ -4,7 +4,6 @@ import React, { useState, 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 { Badge } from "@/components/ui/badge"; import { Table, TableBody, @@ -16,15 +15,8 @@ import { import { Plus, Search, - Play, - Pause, - Edit, - Trash2, RefreshCw, - Clock, - Database, - ArrowRight, - Globe + Database } from "lucide-react"; import { toast } from "sonner"; import { useRouter } from "next/navigation"; @@ -33,6 +25,7 @@ import { BatchConfig, BatchMapping, } from "@/lib/api/batch"; +import BatchCard from "@/components/admin/BatchCard"; export default function BatchManagementPage() { const router = useRouter(); @@ -185,7 +178,7 @@ export default function BatchManagementPage() { }; return ( -
+
{/* 헤더 */}
@@ -203,7 +196,7 @@ export default function BatchManagementPage() { {/* 검색 및 필터 */} - +
@@ -254,100 +247,21 @@ export default function BatchManagementPage() { )}
) : ( -
+
{batchConfigs.map((batch) => ( -
- {/* 배치 기본 정보 */} -
-
-
-

{batch.batch_name}

- - {batch.is_active === 'Y' ? '활성' : '비활성'} - -
- {batch.description && ( -

{batch.description}

- )} -
-
- - {batch.cron_schedule} -
-
- 생성일: {new Date(batch.created_date).toLocaleDateString()} -
-
-
- - {/* 액션 버튼들 */} -
- - - - - - - -
-
- - {/* 매핑 정보 */} - {batch.batch_mappings && batch.batch_mappings.length > 0 && ( -
-

- 매핑 정보 ({batch.batch_mappings.length}개) -

-
- {getMappingSummary(batch.batch_mappings)} -
-
- )} -
+ { + console.log("🖱️ 비활성화/활성화 버튼 클릭:", { batchId, currentStatus }); + toggleBatchStatus(batchId, currentStatus); + }} + onEdit={(batchId) => router.push(`/admin/batchmng/edit/${batchId}`)} + onDelete={deleteBatch} + getMappingSummary={getMappingSummary} + /> ))}
)} diff --git a/frontend/components/admin/BatchCard.tsx b/frontend/components/admin/BatchCard.tsx new file mode 100644 index 00000000..ed8dd94e --- /dev/null +++ b/frontend/components/admin/BatchCard.tsx @@ -0,0 +1,185 @@ +"use client"; + +import React from "react"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Play, + Pause, + Edit, + Trash2, + RefreshCw, + Clock, + Database, + ArrowRight, + Globe, + Calendar, + Activity, + Settings +} from "lucide-react"; +import { BatchConfig } from "@/lib/api/batch"; + +interface BatchCardProps { + batch: BatchConfig; + executingBatch: number | null; + onExecute: (batchId: number) => void; + onToggleStatus: (batchId: number, currentStatus: string) => void; + onEdit: (batchId: number) => void; + onDelete: (batchId: number, batchName: string) => void; + getMappingSummary: (mappings: any[]) => string; +} + +export default function BatchCard({ + batch, + executingBatch, + onExecute, + onToggleStatus, + onEdit, + onDelete, + getMappingSummary +}: BatchCardProps) { + // 상태에 따른 색상 및 스타일 결정 + const getStatusColor = () => { + if (executingBatch === batch.id) return "bg-blue-50 border-blue-200"; + if (batch.is_active === 'Y') return "bg-green-50 border-green-200"; + return "bg-gray-50 border-gray-200"; + }; + + const getStatusBadge = () => { + if (executingBatch === batch.id) { + return 실행 중; + } + return ( + + {batch.is_active === 'Y' ? '활성' : '비활성'} + + ); + }; + + return ( + + + {/* 헤더 섹션 */} +
+
+
+ +

{batch.batch_name}

+
+ {getStatusBadge()} +
+ +

+ {batch.description || '\u00A0'} +

+
+ + {/* 정보 섹션 */} +
+ {/* 스케줄 정보 */} +
+ + {batch.cron_schedule} +
+ + {/* 생성일 정보 */} +
+ + + {new Date(batch.created_date).toLocaleDateString('ko-KR')} + +
+
+ + {/* 매핑 정보 섹션 */} + {batch.batch_mappings && batch.batch_mappings.length > 0 && ( +
+
+ + + 매핑 ({batch.batch_mappings.length}) + +
+
+ {getMappingSummary(batch.batch_mappings)} +
+
+ )} + + {/* 액션 버튼 섹션 */} +
+ {/* 실행 버튼 */} + + + {/* 활성화/비활성화 버튼 */} + + + {/* 수정 버튼 */} + + + {/* 삭제 버튼 */} + +
+ + {/* 실행 중일 때 프로그레스 표시 */} + {executingBatch === batch.id && ( +
+
+ + 실행 중... +
+
+
+
+
+ )} +
+
+ ); +} diff --git a/frontend/components/dataflow/node-editor/FlowEditor.tsx b/frontend/components/dataflow/node-editor/FlowEditor.tsx index bf1b03a6..62211179 100644 --- a/frontend/components/dataflow/node-editor/FlowEditor.tsx +++ b/frontend/components/dataflow/node-editor/FlowEditor.tsx @@ -118,6 +118,16 @@ function FlowEditorInner() { displayName: `새 ${type} 노드`, }; + // REST API 소스 노드의 경우 + if (type === "restAPISource") { + defaultData.method = "GET"; + defaultData.url = ""; + defaultData.headers = {}; + defaultData.timeout = 30000; + defaultData.responseFields = []; // 빈 배열로 초기화 + defaultData.responseMapping = ""; + } + // 액션 노드의 경우 targetType 기본값 설정 if (["insertAction", "updateAction", "deleteAction", "upsertAction"].includes(type)) { defaultData.targetType = "internal"; // 기본값: 내부 DB diff --git a/frontend/components/dataflow/node-editor/nodes/ConditionNode.tsx b/frontend/components/dataflow/node-editor/nodes/ConditionNode.tsx index e662d474..5418fcab 100644 --- a/frontend/components/dataflow/node-editor/nodes/ConditionNode.tsx +++ b/frontend/components/dataflow/node-editor/nodes/ConditionNode.tsx @@ -78,35 +78,33 @@ export const ConditionNode = memo(({ data, selected }: NodeProps {/* 분기 출력 핸들 */} -
-
- {/* TRUE 출력 */} -
-
- - TRUE -
- +
+ {/* TRUE 출력 - 오른쪽 위 */} +
+
+ + TRUE
+ +
- {/* FALSE 출력 */} -
-
- - FALSE -
- + {/* FALSE 출력 - 오른쪽 아래 */} +
+
+ + FALSE
+
diff --git a/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx index a73b410b..0ee91d06 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx @@ -64,6 +64,8 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP // 소스 필드 목록 (연결된 입력 노드에서 가져오기) const [sourceFields, setSourceFields] = useState>([]); + // REST API 소스 노드 연결 여부 + const [hasRestAPISource, setHasRestAPISource] = useState(false); // 🔥 외부 DB 관련 상태 const [externalConnections, setExternalConnections] = useState([]); @@ -135,9 +137,9 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP const getAllSourceFields = ( targetNodeId: string, visitedNodes = new Set(), - ): Array<{ name: string; label?: string }> => { + ): { fields: Array<{ name: string; label?: string }>; hasRestAPI: boolean } => { if (visitedNodes.has(targetNodeId)) { - return []; + return { fields: [], hasRestAPI: false }; } visitedNodes.add(targetNodeId); @@ -146,6 +148,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP const sourceNodes = nodes.filter((node) => sourceNodeIds.includes(node.id)); const fields: Array<{ name: string; label?: string }> = []; + let foundRestAPI = false; sourceNodes.forEach((node) => { console.log(`🔍 노드 ${node.id} 타입: ${node.type}`); @@ -153,18 +156,20 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP // 데이터 변환 노드인 경우: 변환된 필드 + 상위 노드의 원본 필드 if (node.type === "dataTransform") { - console.log(`✅ 데이터 변환 노드 발견`); + console.log("✅ 데이터 변환 노드 발견"); // 상위 노드의 원본 필드 먼저 수집 - const upperFields = getAllSourceFields(node.id, visitedNodes); + const upperResult = getAllSourceFields(node.id, visitedNodes); + const upperFields = upperResult.fields; + foundRestAPI = foundRestAPI || upperResult.hasRestAPI; console.log(` 📤 상위 노드에서 ${upperFields.length}개 필드 가져옴`); // 변환된 필드 추가 (in-place 변환 고려) - if (node.data.transformations && Array.isArray(node.data.transformations)) { - console.log(` 📊 ${node.data.transformations.length}개 변환 발견`); + if ((node.data as any).transformations && Array.isArray((node.data as any).transformations)) { + console.log(` 📊 ${(node.data as any).transformations.length}개 변환 발견`); const inPlaceFields = new Set(); // in-place 변환된 필드 추적 - node.data.transformations.forEach((transform: any) => { + (node.data as any).transformations.forEach((transform: any) => { const targetField = transform.targetField || transform.sourceField; const isInPlace = !transform.targetField || transform.targetField === transform.sourceField; @@ -196,9 +201,31 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP fields.push(...upperFields); } } - // 일반 소스 노드인 경우 + // REST API 소스 노드인 경우 + else if (node.type === "restAPISource") { + console.log("✅ REST API 소스 노드 발견"); + foundRestAPI = true; + const responseFields = (node.data as any).responseFields; + + if (responseFields && Array.isArray(responseFields)) { + console.log(`✅ REST API 노드에서 ${responseFields.length}개 필드 발견`); + responseFields.forEach((field: any) => { + const fieldName = field.name || field.fieldName; + const fieldLabel = field.label || field.displayName; + if (fieldName) { + fields.push({ + name: fieldName, + label: fieldLabel, + }); + } + }); + } else { + console.log("⚠️ REST API 노드에 responseFields 없음"); + } + } + // 일반 소스 노드인 경우 (테이블 소스 등) else { - const nodeFields = node.data.fields || node.data.outputFields; + const nodeFields = (node.data as any).fields || (node.data as any).outputFields; if (nodeFields && Array.isArray(nodeFields)) { console.log(`✅ 노드 ${node.id}에서 ${nodeFields.length}개 필드 발견`); @@ -218,17 +245,19 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP } }); - return fields; + return { fields, hasRestAPI: foundRestAPI }; }; console.log("🔍 INSERT 노드 ID:", nodeId); - const allFields = getAllSourceFields(nodeId); + const result = getAllSourceFields(nodeId); // 중복 제거 - const uniqueFields = Array.from(new Map(allFields.map((field) => [field.name, field])).values()); + const uniqueFields = Array.from(new Map(result.fields.map((field) => [field.name, field])).values()); setSourceFields(uniqueFields); + setHasRestAPISource(result.hasRestAPI); console.log("✅ 최종 소스 필드 목록:", uniqueFields); + console.log("✅ REST API 소스 연결:", result.hasRestAPI); }, [nodeId, nodes, edges]); /** @@ -924,10 +953,10 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP