Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/report
This commit is contained in:
commit
d1b2e6c010
|
|
@ -21,7 +21,7 @@
|
||||||
"imap": "^0.8.19",
|
"imap": "^0.8.19",
|
||||||
"joi": "^17.11.0",
|
"joi": "^17.11.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"mailparser": "^3.7.4",
|
"mailparser": "^3.7.5",
|
||||||
"mssql": "^11.0.1",
|
"mssql": "^11.0.1",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"mysql2": "^3.15.0",
|
"mysql2": "^3.15.0",
|
||||||
|
|
@ -7733,48 +7733,52 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mailparser": {
|
"node_modules/mailparser": {
|
||||||
"version": "3.7.4",
|
"version": "3.7.5",
|
||||||
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.7.5.tgz",
|
||||||
"integrity": "sha512-Beh4yyR4jLq3CZZ32asajByrXnW8dLyKCAQD3WvtTiBnMtFWhxO+wa93F6sJNjDmfjxXs4NRNjw3XAGLqZR3Vg==",
|
"integrity": "sha512-o59RgZC+4SyCOn4xRH1mtRiZ1PbEmi6si6Ufnd3tbX/V9zmZN1qcqu8xbXY62H6CwIclOT3ppm5u/wV2nujn4g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"encoding-japanese": "2.2.0",
|
"encoding-japanese": "2.2.0",
|
||||||
"he": "1.2.0",
|
"he": "1.2.0",
|
||||||
"html-to-text": "9.0.5",
|
"html-to-text": "9.0.5",
|
||||||
"iconv-lite": "0.6.3",
|
"iconv-lite": "0.7.0",
|
||||||
"libmime": "5.3.7",
|
"libmime": "5.3.7",
|
||||||
"linkify-it": "5.0.0",
|
"linkify-it": "5.0.0",
|
||||||
"mailsplit": "5.4.5",
|
"mailsplit": "5.4.6",
|
||||||
"nodemailer": "7.0.4",
|
"nodemailer": "7.0.9",
|
||||||
"punycode.js": "2.3.1",
|
"punycode.js": "2.3.1",
|
||||||
"tlds": "1.259.0"
|
"tlds": "1.260.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mailparser/node_modules/iconv-lite": {
|
"node_modules/mailparser/node_modules/iconv-lite": {
|
||||||
"version": "0.6.3",
|
"version": "0.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
|
||||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mailparser/node_modules/nodemailer": {
|
"node_modules/mailparser/node_modules/nodemailer": {
|
||||||
"version": "7.0.4",
|
"version": "7.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.9.tgz",
|
||||||
"integrity": "sha512-9O00Vh89/Ld2EcVCqJ/etd7u20UhME0f/NToPfArwPEe1Don1zy4mAIz6ariRr7mJ2RDxtaDzN0WJVdVXPtZaw==",
|
"integrity": "sha512-9/Qm0qXIByEP8lEV2qOqcAW7bRpL8CR9jcTwk3NBnHJNmP9fIJ86g2fgmIXqHY+nj55ZEMwWqYAT2QTDpRUYiQ==",
|
||||||
"license": "MIT-0",
|
"license": "MIT-0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mailsplit": {
|
"node_modules/mailsplit": {
|
||||||
"version": "5.4.5",
|
"version": "5.4.6",
|
||||||
"resolved": "https://registry.npmjs.org/mailsplit/-/mailsplit-5.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/mailsplit/-/mailsplit-5.4.6.tgz",
|
||||||
"integrity": "sha512-oMfhmvclR689IIaQmIcR5nODnZRRVwAKtqFT407TIvmhX2OLUBnshUTcxzQBt3+96sZVDud9NfSe1NxAkUNXEQ==",
|
"integrity": "sha512-M+cqmzaPG/mEiCDmqQUz8L177JZLZmXAUpq38owtpq2xlXlTSw+kntnxRt2xsxVFFV6+T8Mj/U0l5s7s6e0rNw==",
|
||||||
"license": "(MIT OR EUPL-1.1+)",
|
"license": "(MIT OR EUPL-1.1+)",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"libbase64": "1.3.0",
|
"libbase64": "1.3.0",
|
||||||
|
|
@ -9797,9 +9801,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/tlds": {
|
"node_modules/tlds": {
|
||||||
"version": "1.259.0",
|
"version": "1.260.0",
|
||||||
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.259.0.tgz",
|
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.260.0.tgz",
|
||||||
"integrity": "sha512-AldGGlDP0PNgwppe2quAvuBl18UcjuNtOnDuUkqhd6ipPqrYYBt3aTxK1QTsBVknk97lS2JcafWMghjGWFtunw==",
|
"integrity": "sha512-78+28EWBhCEE7qlyaHA9OR3IPvbCLiDh3Ckla593TksfFc9vfTsgvH7eS+dr3o9qr31gwGbogcI16yN91PoRjQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tlds": "bin.js"
|
"tlds": "bin.js"
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@
|
||||||
"imap": "^0.8.19",
|
"imap": "^0.8.19",
|
||||||
"joi": "^17.11.0",
|
"joi": "^17.11.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"mailparser": "^3.7.4",
|
"mailparser": "^3.7.5",
|
||||||
"mssql": "^11.0.1",
|
"mssql": "^11.0.1",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"mysql2": "^3.15.0",
|
"mysql2": "^3.15.0",
|
||||||
|
|
|
||||||
|
|
@ -61,23 +61,31 @@ export class BatchService {
|
||||||
|
|
||||||
// 배치 설정 조회 (매핑 포함 - 서브쿼리 사용)
|
// 배치 설정 조회 (매핑 포함 - 서브쿼리 사용)
|
||||||
const batchConfigs = await query<any>(
|
const batchConfigs = await query<any>(
|
||||||
`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(
|
COALESCE(
|
||||||
json_agg(
|
json_agg(
|
||||||
json_build_object(
|
json_build_object(
|
||||||
'mapping_id', bm.mapping_id,
|
'id', bm.id,
|
||||||
'batch_id', bm.batch_id,
|
'batch_config_id', bm.batch_config_id,
|
||||||
'source_column', bm.source_column,
|
'from_connection_type', bm.from_connection_type,
|
||||||
'target_column', bm.target_column,
|
'from_connection_id', bm.from_connection_id,
|
||||||
'transformation_rule', bm.transformation_rule
|
'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
|
) as batch_mappings
|
||||||
FROM batch_configs bc
|
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}
|
${whereClause}
|
||||||
GROUP BY bc.batch_id
|
GROUP BY bc.id
|
||||||
ORDER BY bc.is_active DESC, bc.batch_name ASC
|
ORDER BY bc.is_active DESC, bc.batch_name ASC
|
||||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
[...values, limit, offset]
|
[...values, limit, offset]
|
||||||
|
|
@ -85,7 +93,7 @@ export class BatchService {
|
||||||
|
|
||||||
// 전체 개수 조회
|
// 전체 개수 조회
|
||||||
const countResult = await queryOne<{ count: string }>(
|
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
|
FROM batch_configs bc
|
||||||
${whereClause}`,
|
${whereClause}`,
|
||||||
values
|
values
|
||||||
|
|
@ -121,29 +129,34 @@ export class BatchService {
|
||||||
): Promise<ApiResponse<BatchConfig>> {
|
): Promise<ApiResponse<BatchConfig>> {
|
||||||
try {
|
try {
|
||||||
const batchConfig = await queryOne<any>(
|
const batchConfig = await queryOne<any>(
|
||||||
`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(
|
COALESCE(
|
||||||
json_agg(
|
json_agg(
|
||||||
json_build_object(
|
json_build_object(
|
||||||
'mapping_id', bm.mapping_id,
|
'id', bm.id,
|
||||||
'batch_id', bm.batch_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_table_name', bm.from_table_name,
|
||||||
'from_column_name', bm.from_column_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_table_name', bm.to_table_name,
|
||||||
'to_column_name', bm.to_column_name,
|
'to_column_name', bm.to_column_name,
|
||||||
'mapping_order', bm.mapping_order,
|
'to_column_type', bm.to_column_type,
|
||||||
'source_column', bm.source_column,
|
'mapping_order', bm.mapping_order
|
||||||
'target_column', bm.target_column,
|
|
||||||
'transformation_rule', bm.transformation_rule
|
|
||||||
)
|
)
|
||||||
ORDER BY bm.from_table_name ASC, bm.from_column_name ASC, bm.mapping_order ASC
|
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
|
) as batch_mappings
|
||||||
FROM batch_configs bc
|
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
|
WHERE bc.id = $1
|
||||||
GROUP BY bc.batch_id`,
|
GROUP BY bc.id`,
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -268,16 +281,16 @@ export class BatchService {
|
||||||
COALESCE(
|
COALESCE(
|
||||||
json_agg(
|
json_agg(
|
||||||
json_build_object(
|
json_build_object(
|
||||||
'mapping_id', bm.mapping_id,
|
'id', bm.id,
|
||||||
'batch_id', bm.batch_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
|
) as batch_mappings
|
||||||
FROM batch_configs bc
|
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
|
WHERE bc.id = $1
|
||||||
GROUP BY bc.batch_id`,
|
GROUP BY bc.id`,
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
|
|
||||||
import { query, queryOne, transaction } from "../database/db";
|
import { query, queryOne, transaction } from "../database/db";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
// ===== 타입 정의 =====
|
// ===== 타입 정의 =====
|
||||||
|
|
||||||
|
|
@ -410,6 +411,9 @@ export class NodeFlowExecutionService {
|
||||||
case "tableSource":
|
case "tableSource":
|
||||||
return this.executeTableSource(node, context);
|
return this.executeTableSource(node, context);
|
||||||
|
|
||||||
|
case "restAPISource":
|
||||||
|
return this.executeRestAPISource(node, context);
|
||||||
|
|
||||||
case "dataTransform":
|
case "dataTransform":
|
||||||
return this.executeDataTransform(node, inputData, context);
|
return this.executeDataTransform(node, inputData, context);
|
||||||
|
|
||||||
|
|
@ -440,6 +444,123 @@ export class NodeFlowExecutionService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST API 소스 노드 실행
|
||||||
|
*/
|
||||||
|
private static async executeRestAPISource(
|
||||||
|
node: FlowNode,
|
||||||
|
context: ExecutionContext
|
||||||
|
): Promise<any[]> {
|
||||||
|
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<any> {
|
): Promise<any> {
|
||||||
const { targetTable, fieldMappings } = node.data;
|
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) => {
|
return transaction(async (client) => {
|
||||||
const dataArray = Array.isArray(inputData) ? inputData : [inputData];
|
const dataArray = Array.isArray(inputData) ? inputData : [inputData];
|
||||||
let insertedCount = 0;
|
let insertedCount = 0;
|
||||||
|
|
@ -529,12 +663,17 @@ export class NodeFlowExecutionService {
|
||||||
const fields: string[] = [];
|
const fields: string[] = [];
|
||||||
const values: any[] = [];
|
const values: any[] = [];
|
||||||
|
|
||||||
|
console.log("🗺️ 필드 매핑 처리 중...");
|
||||||
fieldMappings.forEach((mapping: any) => {
|
fieldMappings.forEach((mapping: any) => {
|
||||||
fields.push(mapping.targetField);
|
fields.push(mapping.targetField);
|
||||||
const value =
|
const value =
|
||||||
mapping.staticValue !== undefined
|
mapping.staticValue !== undefined
|
||||||
? mapping.staticValue
|
? mapping.staticValue
|
||||||
: data[mapping.sourceField];
|
: data[mapping.sourceField];
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
` ${mapping.sourceField} → ${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}`
|
||||||
|
);
|
||||||
values.push(value);
|
values.push(value);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -543,6 +682,9 @@ export class NodeFlowExecutionService {
|
||||||
VALUES (${fields.map((_, i) => `$${i + 1}`).join(", ")})
|
VALUES (${fields.map((_, i) => `$${i + 1}`).join(", ")})
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
console.log("📝 실행할 SQL:", sql);
|
||||||
|
console.log("📊 바인딩 값:", values);
|
||||||
|
|
||||||
await client.query(sql, values);
|
await client.query(sql, values);
|
||||||
insertedCount++;
|
insertedCount++;
|
||||||
}
|
}
|
||||||
|
|
@ -682,7 +824,6 @@ export class NodeFlowExecutionService {
|
||||||
|
|
||||||
logger.info(`🌐 REST API INSERT 시작: ${apiMethod} ${apiEndpoint}`);
|
logger.info(`🌐 REST API INSERT 시작: ${apiMethod} ${apiEndpoint}`);
|
||||||
|
|
||||||
const axios = require("axios");
|
|
||||||
const dataArray = Array.isArray(inputData) ? inputData : [inputData];
|
const dataArray = Array.isArray(inputData) ? inputData : [inputData];
|
||||||
const results: any[] = [];
|
const results: any[] = [];
|
||||||
|
|
||||||
|
|
@ -895,6 +1036,19 @@ export class NodeFlowExecutionService {
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const { targetTable, fieldMappings, whereConditions } = node.data;
|
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) => {
|
return transaction(async (client) => {
|
||||||
const dataArray = Array.isArray(inputData) ? inputData : [inputData];
|
const dataArray = Array.isArray(inputData) ? inputData : [inputData];
|
||||||
let updatedCount = 0;
|
let updatedCount = 0;
|
||||||
|
|
@ -904,11 +1058,16 @@ export class NodeFlowExecutionService {
|
||||||
const values: any[] = [];
|
const values: any[] = [];
|
||||||
let paramIndex = 1;
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
console.log("🗺️ 필드 매핑 처리 중...");
|
||||||
fieldMappings.forEach((mapping: any) => {
|
fieldMappings.forEach((mapping: any) => {
|
||||||
const value =
|
const value =
|
||||||
mapping.staticValue !== undefined
|
mapping.staticValue !== undefined
|
||||||
? mapping.staticValue
|
? mapping.staticValue
|
||||||
: data[mapping.sourceField];
|
: data[mapping.sourceField];
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
` ${mapping.sourceField} → ${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}`
|
||||||
|
);
|
||||||
setClauses.push(`${mapping.targetField} = $${paramIndex}`);
|
setClauses.push(`${mapping.targetField} = $${paramIndex}`);
|
||||||
values.push(value);
|
values.push(value);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
|
|
@ -926,6 +1085,9 @@ export class NodeFlowExecutionService {
|
||||||
${whereClause}
|
${whereClause}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
console.log("📝 실행할 SQL:", sql);
|
||||||
|
console.log("📊 바인딩 값:", values);
|
||||||
|
|
||||||
const result = await client.query(sql, values);
|
const result = await client.query(sql, values);
|
||||||
updatedCount += result.rowCount || 0;
|
updatedCount += result.rowCount || 0;
|
||||||
}
|
}
|
||||||
|
|
@ -1086,7 +1248,6 @@ export class NodeFlowExecutionService {
|
||||||
|
|
||||||
logger.info(`🌐 REST API UPDATE 시작: ${apiMethod} ${apiEndpoint}`);
|
logger.info(`🌐 REST API UPDATE 시작: ${apiMethod} ${apiEndpoint}`);
|
||||||
|
|
||||||
const axios = require("axios");
|
|
||||||
const dataArray = Array.isArray(inputData) ? inputData : [inputData];
|
const dataArray = Array.isArray(inputData) ? inputData : [inputData];
|
||||||
const results: any[] = [];
|
const results: any[] = [];
|
||||||
|
|
||||||
|
|
@ -1197,15 +1358,31 @@ export class NodeFlowExecutionService {
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const { targetTable, whereConditions } = node.data;
|
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) => {
|
return transaction(async (client) => {
|
||||||
const dataArray = Array.isArray(inputData) ? inputData : [inputData];
|
const dataArray = Array.isArray(inputData) ? inputData : [inputData];
|
||||||
let deletedCount = 0;
|
let deletedCount = 0;
|
||||||
|
|
||||||
for (const data of dataArray) {
|
for (const data of dataArray) {
|
||||||
|
console.log("🔍 WHERE 조건 처리 중...");
|
||||||
const whereClause = this.buildWhereClause(whereConditions, data, 1);
|
const whereClause = this.buildWhereClause(whereConditions, data, 1);
|
||||||
|
|
||||||
const sql = `DELETE FROM ${targetTable} ${whereClause}`;
|
const sql = `DELETE FROM ${targetTable} ${whereClause}`;
|
||||||
|
|
||||||
|
console.log("📝 실행할 SQL:", sql);
|
||||||
|
|
||||||
const result = await client.query(sql, []);
|
const result = await client.query(sql, []);
|
||||||
deletedCount += result.rowCount || 0;
|
deletedCount += result.rowCount || 0;
|
||||||
}
|
}
|
||||||
|
|
@ -1339,7 +1516,6 @@ export class NodeFlowExecutionService {
|
||||||
|
|
||||||
logger.info(`🌐 REST API DELETE 시작: ${apiEndpoint}`);
|
logger.info(`🌐 REST API DELETE 시작: ${apiEndpoint}`);
|
||||||
|
|
||||||
const axios = require("axios");
|
|
||||||
const dataArray = Array.isArray(inputData) ? inputData : [inputData];
|
const dataArray = Array.isArray(inputData) ? inputData : [inputData];
|
||||||
const results: any[] = [];
|
const results: any[] = [];
|
||||||
|
|
||||||
|
|
@ -1440,6 +1616,20 @@ export class NodeFlowExecutionService {
|
||||||
throw new Error("UPSERT 액션에 충돌 키(Conflict Keys)가 필요합니다.");
|
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) => {
|
return transaction(async (client) => {
|
||||||
const dataArray = Array.isArray(inputData) ? inputData : [inputData];
|
const dataArray = Array.isArray(inputData) ? inputData : [inputData];
|
||||||
let insertedCount = 0;
|
let insertedCount = 0;
|
||||||
|
|
@ -1466,7 +1656,10 @@ export class NodeFlowExecutionService {
|
||||||
(key: string) => conflictKeyValues[key]
|
(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);
|
const existingRow = await client.query(checkSql, whereValues);
|
||||||
|
|
||||||
if (existingRow.rows.length > 0) {
|
if (existingRow.rows.length > 0) {
|
||||||
|
|
@ -1780,7 +1973,6 @@ export class NodeFlowExecutionService {
|
||||||
|
|
||||||
logger.info(`🌐 REST API UPSERT 시작: ${apiMethod} ${apiEndpoint}`);
|
logger.info(`🌐 REST API UPSERT 시작: ${apiMethod} ${apiEndpoint}`);
|
||||||
|
|
||||||
const axios = require("axios");
|
|
||||||
const dataArray = Array.isArray(inputData) ? inputData : [inputData];
|
const dataArray = Array.isArray(inputData) ? inputData : [inputData];
|
||||||
const results: any[] = [];
|
const results: any[] = [];
|
||||||
|
|
||||||
|
|
@ -1977,6 +2169,20 @@ export class NodeFlowExecutionService {
|
||||||
|
|
||||||
const success = summary.failed === 0;
|
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 {
|
return {
|
||||||
success,
|
success,
|
||||||
message: success
|
message: success
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ services:
|
||||||
- DATABASE_URL=postgresql://postgres:ph0909!!@39.117.244.52:11132/plm
|
- DATABASE_URL=postgresql://postgres:ph0909!!@39.117.244.52:11132/plm
|
||||||
- JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024
|
- JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024
|
||||||
- JWT_EXPIRES_IN=24h
|
- JWT_EXPIRES_IN=24h
|
||||||
|
- ENCRYPTION_KEY=ilshin-plm-encryption-key-2024-secure-32bytes
|
||||||
- CORS_ORIGIN=http://localhost:9771
|
- CORS_ORIGIN=http://localhost:9771
|
||||||
- CORS_CREDENTIALS=true
|
- CORS_CREDENTIALS=true
|
||||||
- LOG_LEVEL=debug
|
- LOG_LEVEL=debug
|
||||||
|
|
@ -26,7 +27,18 @@ services:
|
||||||
- pms-network
|
- pms-network
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
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
|
interval: 30s
|
||||||
timeout: 15s
|
timeout: 15s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import React, { useState, useEffect } from "react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|
@ -16,15 +15,8 @@ import {
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
Play,
|
|
||||||
Pause,
|
|
||||||
Edit,
|
|
||||||
Trash2,
|
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Clock,
|
Database
|
||||||
Database,
|
|
||||||
ArrowRight,
|
|
||||||
Globe
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
@ -33,6 +25,7 @@ import {
|
||||||
BatchConfig,
|
BatchConfig,
|
||||||
BatchMapping,
|
BatchMapping,
|
||||||
} from "@/lib/api/batch";
|
} from "@/lib/api/batch";
|
||||||
|
import BatchCard from "@/components/admin/BatchCard";
|
||||||
|
|
||||||
export default function BatchManagementPage() {
|
export default function BatchManagementPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -185,7 +178,7 @@ export default function BatchManagementPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-6 space-y-6">
|
<div className="container mx-auto p-4 space-y-2">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -203,7 +196,7 @@ export default function BatchManagementPage() {
|
||||||
|
|
||||||
{/* 검색 및 필터 */}
|
{/* 검색 및 필터 */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="py-2">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
||||||
|
|
@ -254,100 +247,21 @@ export default function BatchManagementPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3">
|
||||||
{batchConfigs.map((batch) => (
|
{batchConfigs.map((batch) => (
|
||||||
<div key={batch.id} className="border rounded-lg p-6 space-y-4">
|
<BatchCard
|
||||||
{/* 배치 기본 정보 */}
|
key={batch.id}
|
||||||
<div className="flex items-start justify-between">
|
batch={batch}
|
||||||
<div className="space-y-2">
|
executingBatch={executingBatch}
|
||||||
<div className="flex items-center space-x-3">
|
onExecute={executeBatch}
|
||||||
<h3 className="text-lg font-semibold">{batch.batch_name}</h3>
|
onToggleStatus={(batchId, currentStatus) => {
|
||||||
<Badge variant={batch.is_active === 'Y' ? 'default' : 'secondary'}>
|
console.log("🖱️ 비활성화/활성화 버튼 클릭:", { batchId, currentStatus });
|
||||||
{batch.is_active === 'Y' ? '활성' : '비활성'}
|
toggleBatchStatus(batchId, currentStatus);
|
||||||
</Badge>
|
}}
|
||||||
</div>
|
onEdit={(batchId) => router.push(`/admin/batchmng/edit/${batchId}`)}
|
||||||
{batch.description && (
|
onDelete={deleteBatch}
|
||||||
<p className="text-muted-foreground">{batch.description}</p>
|
getMappingSummary={getMappingSummary}
|
||||||
)}
|
/>
|
||||||
<div className="flex items-center space-x-4 text-sm text-muted-foreground">
|
|
||||||
<div className="flex items-center space-x-1">
|
|
||||||
<Clock className="h-4 w-4" />
|
|
||||||
<span>{batch.cron_schedule}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
생성일: {new Date(batch.created_date).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 액션 버튼들 */}
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => executeBatch(batch.id)}
|
|
||||||
disabled={executingBatch === batch.id}
|
|
||||||
className="flex items-center space-x-1"
|
|
||||||
>
|
|
||||||
{executingBatch === batch.id ? (
|
|
||||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Play className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
<span>실행</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
console.log("🖱️ 비활성화/활성화 버튼 클릭:", { batchId: batch.id, currentStatus: batch.is_active });
|
|
||||||
toggleBatchStatus(batch.id, batch.is_active);
|
|
||||||
}}
|
|
||||||
className="flex items-center space-x-1"
|
|
||||||
>
|
|
||||||
{batch.is_active === 'Y' ? (
|
|
||||||
<Pause className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Play className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
<span>{batch.is_active === 'Y' ? '비활성화' : '활성화'}</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => router.push(`/admin/batchmng/edit/${batch.id}`)}
|
|
||||||
className="flex items-center space-x-1"
|
|
||||||
>
|
|
||||||
<Edit className="h-4 w-4" />
|
|
||||||
<span>수정</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => deleteBatch(batch.id, batch.batch_name)}
|
|
||||||
className="flex items-center space-x-1 text-red-600 hover:text-red-700"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
<span>삭제</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 매핑 정보 */}
|
|
||||||
{batch.batch_mappings && batch.batch_mappings.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="text-sm font-medium text-muted-foreground">
|
|
||||||
매핑 정보 ({batch.batch_mappings.length}개)
|
|
||||||
</h4>
|
|
||||||
<div className="text-sm">
|
|
||||||
{getMappingSummary(batch.batch_mappings)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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 <Badge variant="outline" className="bg-blue-100 text-blue-700 border-blue-300 text-xs px-1.5 py-0.5 h-5">실행 중</Badge>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Badge variant={batch.is_active === 'Y' ? 'default' : 'secondary'} className="text-xs px-1.5 py-0.5 h-5">
|
||||||
|
{batch.is_active === 'Y' ? '활성' : '비활성'}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={`transition-all duration-200 hover:shadow-md ${getStatusColor()} h-fit`}>
|
||||||
|
<CardContent className="p-3">
|
||||||
|
{/* 헤더 섹션 */}
|
||||||
|
<div className="mb-1.5">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<div className="flex items-center space-x-1 min-w-0 flex-1">
|
||||||
|
<Settings className="h-2.5 w-2.5 text-gray-600 flex-shrink-0" />
|
||||||
|
<h3 className="text-xs font-medium text-gray-900 truncate">{batch.batch_name}</h3>
|
||||||
|
</div>
|
||||||
|
{getStatusBadge()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-gray-500 line-clamp-1 leading-tight h-3 flex items-start">
|
||||||
|
{batch.description || '\u00A0'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 정보 섹션 */}
|
||||||
|
<div className="space-y-1 mb-2">
|
||||||
|
{/* 스케줄 정보 */}
|
||||||
|
<div className="flex items-center space-x-1 text-xs">
|
||||||
|
<Clock className="h-2.5 w-2.5 text-blue-600" />
|
||||||
|
<span className="text-gray-600 truncate text-xs">{batch.cron_schedule}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 생성일 정보 */}
|
||||||
|
<div className="flex items-center space-x-1 text-xs">
|
||||||
|
<Calendar className="h-2.5 w-2.5 text-green-600" />
|
||||||
|
<span className="text-gray-600 text-xs">
|
||||||
|
{new Date(batch.created_date).toLocaleDateString('ko-KR')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 매핑 정보 섹션 */}
|
||||||
|
{batch.batch_mappings && batch.batch_mappings.length > 0 && (
|
||||||
|
<div className="mb-2 p-1.5 bg-white rounded border border-gray-100">
|
||||||
|
<div className="flex items-center space-x-1 mb-1">
|
||||||
|
<Database className="h-2.5 w-2.5 text-purple-600" />
|
||||||
|
<span className="text-xs font-medium text-gray-700">
|
||||||
|
매핑 ({batch.batch_mappings.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600 line-clamp-1">
|
||||||
|
{getMappingSummary(batch.batch_mappings)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 액션 버튼 섹션 */}
|
||||||
|
<div className="grid grid-cols-2 gap-1 pt-2 border-t border-gray-100">
|
||||||
|
{/* 실행 버튼 */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onExecute(batch.id)}
|
||||||
|
disabled={executingBatch === batch.id}
|
||||||
|
className="flex items-center justify-center space-x-1 bg-blue-50 hover:bg-blue-100 text-blue-700 border-blue-200 text-xs h-6"
|
||||||
|
>
|
||||||
|
{executingBatch === batch.id ? (
|
||||||
|
<RefreshCw className="h-2.5 w-2.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Play className="h-2.5 w-2.5" />
|
||||||
|
)}
|
||||||
|
<span>실행</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* 활성화/비활성화 버튼 */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onToggleStatus(batch.id, batch.is_active)}
|
||||||
|
className={`flex items-center justify-center space-x-1 text-xs h-6 ${
|
||||||
|
batch.is_active === 'Y'
|
||||||
|
? 'bg-orange-50 hover:bg-orange-100 text-orange-700 border-orange-200'
|
||||||
|
: 'bg-green-50 hover:bg-green-100 text-green-700 border-green-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{batch.is_active === 'Y' ? (
|
||||||
|
<Pause className="h-2.5 w-2.5" />
|
||||||
|
) : (
|
||||||
|
<Play className="h-2.5 w-2.5" />
|
||||||
|
)}
|
||||||
|
<span>{batch.is_active === 'Y' ? '비활성' : '활성'}</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* 수정 버튼 */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onEdit(batch.id)}
|
||||||
|
className="flex items-center justify-center space-x-1 bg-gray-50 hover:bg-gray-100 text-gray-700 border-gray-200 text-xs h-6"
|
||||||
|
>
|
||||||
|
<Edit className="h-2.5 w-2.5" />
|
||||||
|
<span>수정</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* 삭제 버튼 */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDelete(batch.id, batch.batch_name)}
|
||||||
|
className="flex items-center justify-center space-x-1 bg-red-50 hover:bg-red-100 text-red-700 border-red-200 text-xs h-6"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-2.5 w-2.5" />
|
||||||
|
<span>삭제</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 실행 중일 때 프로그레스 표시 */}
|
||||||
|
{executingBatch === batch.id && (
|
||||||
|
<div className="mt-2 pt-2 border-t border-blue-100">
|
||||||
|
<div className="flex items-center space-x-1 text-xs text-blue-600">
|
||||||
|
<Activity className="h-3 w-3 animate-pulse" />
|
||||||
|
<span>실행 중...</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 w-full bg-blue-100 rounded-full h-1">
|
||||||
|
<div className="bg-blue-600 h-1 rounded-full animate-pulse" style={{ width: '45%' }}></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -118,6 +118,16 @@ function FlowEditorInner() {
|
||||||
displayName: `새 ${type} 노드`,
|
displayName: `새 ${type} 노드`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// REST API 소스 노드의 경우
|
||||||
|
if (type === "restAPISource") {
|
||||||
|
defaultData.method = "GET";
|
||||||
|
defaultData.url = "";
|
||||||
|
defaultData.headers = {};
|
||||||
|
defaultData.timeout = 30000;
|
||||||
|
defaultData.responseFields = []; // 빈 배열로 초기화
|
||||||
|
defaultData.responseMapping = "";
|
||||||
|
}
|
||||||
|
|
||||||
// 액션 노드의 경우 targetType 기본값 설정
|
// 액션 노드의 경우 targetType 기본값 설정
|
||||||
if (["insertAction", "updateAction", "deleteAction", "upsertAction"].includes(type)) {
|
if (["insertAction", "updateAction", "deleteAction", "upsertAction"].includes(type)) {
|
||||||
defaultData.targetType = "internal"; // 기본값: 내부 DB
|
defaultData.targetType = "internal"; // 기본값: 내부 DB
|
||||||
|
|
|
||||||
|
|
@ -78,35 +78,33 @@ export const ConditionNode = memo(({ data, selected }: NodeProps<ConditionNodeDa
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 분기 출력 핸들 */}
|
{/* 분기 출력 핸들 */}
|
||||||
<div className="border-t">
|
<div className="relative border-t">
|
||||||
<div className="grid grid-cols-2">
|
{/* TRUE 출력 - 오른쪽 위 */}
|
||||||
{/* TRUE 출력 */}
|
<div className="relative border-b p-2">
|
||||||
<div className="relative border-r p-2">
|
<div className="flex items-center justify-end gap-1 pr-6 text-xs">
|
||||||
<div className="flex items-center justify-center gap-1 text-xs">
|
<Check className="h-3 w-3 text-green-600" />
|
||||||
<Check className="h-3 w-3 text-green-600" />
|
<span className="font-medium text-green-600">TRUE</span>
|
||||||
<span className="font-medium text-green-600">TRUE</span>
|
|
||||||
</div>
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Right}
|
|
||||||
id="true"
|
|
||||||
className="!-right-1.5 !h-3 !w-3 !border-2 !border-green-500 !bg-white"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
id="true"
|
||||||
|
className="!top-1/2 !-right-1.5 !h-3 !w-3 !-translate-y-1/2 !border-2 !border-green-500 !bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* FALSE 출력 */}
|
{/* FALSE 출력 - 오른쪽 아래 */}
|
||||||
<div className="relative p-2">
|
<div className="relative p-2">
|
||||||
<div className="flex items-center justify-center gap-1 text-xs">
|
<div className="flex items-center justify-end gap-1 pr-6 text-xs">
|
||||||
<X className="h-3 w-3 text-red-600" />
|
<X className="h-3 w-3 text-red-600" />
|
||||||
<span className="font-medium text-red-600">FALSE</span>
|
<span className="font-medium text-red-600">FALSE</span>
|
||||||
</div>
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Right}
|
|
||||||
id="false"
|
|
||||||
className="!-right-1.5 !h-3 !w-3 !border-2 !border-red-500 !bg-white"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
id="false"
|
||||||
|
className="!top-1/2 !-right-1.5 !h-3 !w-3 !-translate-y-1/2 !border-2 !border-red-500 !bg-white"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,8 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
|
|
||||||
// 소스 필드 목록 (연결된 입력 노드에서 가져오기)
|
// 소스 필드 목록 (연결된 입력 노드에서 가져오기)
|
||||||
const [sourceFields, setSourceFields] = useState<Array<{ name: string; label?: string }>>([]);
|
const [sourceFields, setSourceFields] = useState<Array<{ name: string; label?: string }>>([]);
|
||||||
|
// REST API 소스 노드 연결 여부
|
||||||
|
const [hasRestAPISource, setHasRestAPISource] = useState(false);
|
||||||
|
|
||||||
// 🔥 외부 DB 관련 상태
|
// 🔥 외부 DB 관련 상태
|
||||||
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
|
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
|
||||||
|
|
@ -135,9 +137,9 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
const getAllSourceFields = (
|
const getAllSourceFields = (
|
||||||
targetNodeId: string,
|
targetNodeId: string,
|
||||||
visitedNodes = new Set<string>(),
|
visitedNodes = new Set<string>(),
|
||||||
): Array<{ name: string; label?: string }> => {
|
): { fields: Array<{ name: string; label?: string }>; hasRestAPI: boolean } => {
|
||||||
if (visitedNodes.has(targetNodeId)) {
|
if (visitedNodes.has(targetNodeId)) {
|
||||||
return [];
|
return { fields: [], hasRestAPI: false };
|
||||||
}
|
}
|
||||||
visitedNodes.add(targetNodeId);
|
visitedNodes.add(targetNodeId);
|
||||||
|
|
||||||
|
|
@ -146,6 +148,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
const sourceNodes = nodes.filter((node) => sourceNodeIds.includes(node.id));
|
const sourceNodes = nodes.filter((node) => sourceNodeIds.includes(node.id));
|
||||||
|
|
||||||
const fields: Array<{ name: string; label?: string }> = [];
|
const fields: Array<{ name: string; label?: string }> = [];
|
||||||
|
let foundRestAPI = false;
|
||||||
|
|
||||||
sourceNodes.forEach((node) => {
|
sourceNodes.forEach((node) => {
|
||||||
console.log(`🔍 노드 ${node.id} 타입: ${node.type}`);
|
console.log(`🔍 노드 ${node.id} 타입: ${node.type}`);
|
||||||
|
|
@ -153,18 +156,20 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
|
|
||||||
// 데이터 변환 노드인 경우: 변환된 필드 + 상위 노드의 원본 필드
|
// 데이터 변환 노드인 경우: 변환된 필드 + 상위 노드의 원본 필드
|
||||||
if (node.type === "dataTransform") {
|
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}개 필드 가져옴`);
|
console.log(` 📤 상위 노드에서 ${upperFields.length}개 필드 가져옴`);
|
||||||
|
|
||||||
// 변환된 필드 추가 (in-place 변환 고려)
|
// 변환된 필드 추가 (in-place 변환 고려)
|
||||||
if (node.data.transformations && Array.isArray(node.data.transformations)) {
|
if ((node.data as any).transformations && Array.isArray((node.data as any).transformations)) {
|
||||||
console.log(` 📊 ${node.data.transformations.length}개 변환 발견`);
|
console.log(` 📊 ${(node.data as any).transformations.length}개 변환 발견`);
|
||||||
const inPlaceFields = new Set<string>(); // in-place 변환된 필드 추적
|
const inPlaceFields = new Set<string>(); // in-place 변환된 필드 추적
|
||||||
|
|
||||||
node.data.transformations.forEach((transform: any) => {
|
(node.data as any).transformations.forEach((transform: any) => {
|
||||||
const targetField = transform.targetField || transform.sourceField;
|
const targetField = transform.targetField || transform.sourceField;
|
||||||
const isInPlace = !transform.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);
|
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 {
|
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)) {
|
if (nodeFields && Array.isArray(nodeFields)) {
|
||||||
console.log(`✅ 노드 ${node.id}에서 ${nodeFields.length}개 필드 발견`);
|
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);
|
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);
|
setSourceFields(uniqueFields);
|
||||||
|
setHasRestAPISource(result.hasRestAPI);
|
||||||
console.log("✅ 최종 소스 필드 목록:", uniqueFields);
|
console.log("✅ 최종 소스 필드 목록:", uniqueFields);
|
||||||
|
console.log("✅ REST API 소스 연결:", result.hasRestAPI);
|
||||||
}, [nodeId, nodes, edges]);
|
}, [nodeId, nodes, edges]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -924,10 +953,10 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 block text-xs font-medium">
|
<Label className="mb-1.5 block text-xs font-medium">
|
||||||
요청 바디 템플릿
|
요청 바디 템플릿
|
||||||
<span className="ml-1 text-gray-500">{`{{fieldName}}`}으로 소스 필드 참조</span>
|
<span className="ml-1 text-gray-500">{"{{fieldName}}"}으로 소스 필드 참조</span>
|
||||||
</Label>
|
</Label>
|
||||||
<textarea
|
<textarea
|
||||||
placeholder={`{\n "name": "{{name}}",\n "email": "{{email}}",\n "age": "{{age}}"\n}`}
|
placeholder={'{\n "name": "{{name}}",\n "email": "{{email}}",\n "age": "{{age}}"\n}'}
|
||||||
value={apiBodyTemplate}
|
value={apiBodyTemplate}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setApiBodyTemplate(e.target.value);
|
setApiBodyTemplate(e.target.value);
|
||||||
|
|
@ -937,7 +966,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
rows={8}
|
rows={8}
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
소스 데이터의 필드명을 {`{{필드명}}`} 형태로 참조할 수 있습니다.
|
소스 데이터의 필드명을 {"{{필드명}}"} 형태로 참조할 수 있습니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1011,35 +1040,54 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* 소스 필드 드롭다운 */}
|
{/* 소스 필드 입력/선택 */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs text-gray-600">소스 필드</Label>
|
<Label className="text-xs text-gray-600">
|
||||||
<Select
|
소스 필드
|
||||||
value={mapping.sourceField || ""}
|
{hasRestAPISource && <span className="ml-1 text-teal-600">(REST API - 직접 입력)</span>}
|
||||||
onValueChange={(value) => handleMappingChange(index, "sourceField", value || null)}
|
</Label>
|
||||||
>
|
{hasRestAPISource ? (
|
||||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
// REST API 소스인 경우: 직접 입력
|
||||||
<SelectValue placeholder="소스 필드 선택" />
|
<Input
|
||||||
</SelectTrigger>
|
value={mapping.sourceField || ""}
|
||||||
<SelectContent>
|
onChange={(e) => handleMappingChange(index, "sourceField", e.target.value || null)}
|
||||||
{sourceFields.length === 0 ? (
|
placeholder="필드명 입력 (예: userId, userName)"
|
||||||
<div className="p-2 text-center text-xs text-gray-400">
|
className="mt-1 h-8 text-xs"
|
||||||
연결된 소스 노드가 없습니다
|
/>
|
||||||
</div>
|
) : (
|
||||||
) : (
|
// 일반 소스인 경우: 드롭다운 선택
|
||||||
sourceFields.map((field) => (
|
<Select
|
||||||
<SelectItem key={field.name} value={field.name} className="text-xs">
|
value={mapping.sourceField || ""}
|
||||||
<div className="flex items-center justify-between gap-2">
|
onValueChange={(value) => handleMappingChange(index, "sourceField", value || null)}
|
||||||
<span className="font-medium">{field.label || field.name}</span>
|
>
|
||||||
{field.label && field.label !== field.name && (
|
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||||
<span className="text-muted-foreground font-mono text-xs">{field.name}</span>
|
<SelectValue placeholder="소스 필드 선택" />
|
||||||
)}
|
</SelectTrigger>
|
||||||
</div>
|
<SelectContent>
|
||||||
</SelectItem>
|
{sourceFields.length === 0 ? (
|
||||||
))
|
<div className="p-2 text-center text-xs text-gray-400">
|
||||||
)}
|
연결된 소스 노드가 없습니다
|
||||||
</SelectContent>
|
</div>
|
||||||
</Select>
|
) : (
|
||||||
|
sourceFields.map((field) => (
|
||||||
|
<SelectItem key={field.name} value={field.name} className="text-xs">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="font-medium">{field.label || field.name}</span>
|
||||||
|
{field.label && field.label !== field.name && (
|
||||||
|
<span className="text-muted-foreground font-mono text-xs">
|
||||||
|
{field.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
{hasRestAPISource && (
|
||||||
|
<p className="mt-1 text-xs text-gray-500">API 응답 JSON의 필드명을 입력하세요</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-center py-1">
|
<div className="flex items-center justify-center py-1">
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ export function RestAPISourceProperties({ nodeId, data }: RestAPISourcePropertie
|
||||||
const [authToken, setAuthToken] = useState(data.authentication?.token || "");
|
const [authToken, setAuthToken] = useState(data.authentication?.token || "");
|
||||||
const [timeout, setTimeout] = useState(data.timeout?.toString() || "30000");
|
const [timeout, setTimeout] = useState(data.timeout?.toString() || "30000");
|
||||||
const [responseMapping, setResponseMapping] = useState(data.responseMapping || "");
|
const [responseMapping, setResponseMapping] = useState(data.responseMapping || "");
|
||||||
|
const [responseFields, setResponseFields] = useState(data.responseFields || []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDisplayName(data.displayName || "");
|
setDisplayName(data.displayName || "");
|
||||||
|
|
@ -48,6 +49,7 @@ export function RestAPISourceProperties({ nodeId, data }: RestAPISourcePropertie
|
||||||
setAuthToken(data.authentication?.token || "");
|
setAuthToken(data.authentication?.token || "");
|
||||||
setTimeout(data.timeout?.toString() || "30000");
|
setTimeout(data.timeout?.toString() || "30000");
|
||||||
setResponseMapping(data.responseMapping || "");
|
setResponseMapping(data.responseMapping || "");
|
||||||
|
setResponseFields(data.responseFields || []);
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
const handleApply = () => {
|
const handleApply = () => {
|
||||||
|
|
@ -59,6 +61,10 @@ export function RestAPISourceProperties({ nodeId, data }: RestAPISourcePropertie
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log("🔧 REST API 노드 업데이트 중...");
|
||||||
|
console.log("📦 responseFields:", responseFields);
|
||||||
|
console.log("📊 responseFields 개수:", responseFields.length);
|
||||||
|
|
||||||
updateNode(nodeId, {
|
updateNode(nodeId, {
|
||||||
displayName,
|
displayName,
|
||||||
url,
|
url,
|
||||||
|
|
@ -71,7 +77,10 @@ export function RestAPISourceProperties({ nodeId, data }: RestAPISourcePropertie
|
||||||
},
|
},
|
||||||
timeout: parseInt(timeout) || 30000,
|
timeout: parseInt(timeout) || 30000,
|
||||||
responseMapping,
|
responseMapping,
|
||||||
|
responseFields,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log("✅ REST API 노드 업데이트 완료");
|
||||||
};
|
};
|
||||||
|
|
||||||
const addHeader = () => {
|
const addHeader = () => {
|
||||||
|
|
@ -88,6 +97,32 @@ export function RestAPISourceProperties({ nodeId, data }: RestAPISourcePropertie
|
||||||
setHeaders(newHeaders);
|
setHeaders(newHeaders);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addResponseField = () => {
|
||||||
|
const newFields = [
|
||||||
|
...responseFields,
|
||||||
|
{
|
||||||
|
name: "",
|
||||||
|
label: "",
|
||||||
|
dataType: "TEXT",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
console.log("➕ 응답 필드 추가:", newFields);
|
||||||
|
setResponseFields(newFields);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateResponseField = (index: number, field: string, value: string) => {
|
||||||
|
const updated = [...responseFields];
|
||||||
|
updated[index] = { ...updated[index], [field]: value };
|
||||||
|
console.log(`✏️ 응답 필드 ${index} 업데이트 (${field}=${value}):`, updated);
|
||||||
|
setResponseFields(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeResponseField = (index: number) => {
|
||||||
|
const newFields = responseFields.filter((_, i) => i !== index);
|
||||||
|
console.log("🗑️ 응답 필드 삭제:", newFields);
|
||||||
|
setResponseFields(newFields);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 p-4">
|
<div className="space-y-4 p-4">
|
||||||
<div className="flex items-center gap-2 rounded-md bg-teal-50 p-2">
|
<div className="flex items-center gap-2 rounded-md bg-teal-50 p-2">
|
||||||
|
|
@ -238,6 +273,64 @@ export function RestAPISourceProperties({ nodeId, data }: RestAPISourcePropertie
|
||||||
placeholder="예: data.items"
|
placeholder="예: data.items"
|
||||||
className="mt-1 text-sm"
|
className="mt-1 text-sm"
|
||||||
/>
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">배열 데이터의 경로를 지정하세요 (예: data.items, result.users)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<Label className="text-xs">응답 필드 정의</Label>
|
||||||
|
<Button size="sm" variant="outline" onClick={addResponseField}>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
필드 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[300px] space-y-2 overflow-y-auto">
|
||||||
|
{responseFields.length === 0 ? (
|
||||||
|
<div className="rounded border border-dashed p-3 text-center text-xs text-gray-400">
|
||||||
|
응답 필드를 추가하여 데이터 구조를 정의하세요
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
responseFields.map((field, index) => (
|
||||||
|
<div key={index} className="rounded border bg-gray-50 p-2">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium text-gray-700">필드 {index + 1}</span>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => removeResponseField(index)}>
|
||||||
|
<Trash2 className="h-3 w-3 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Input
|
||||||
|
value={field.name}
|
||||||
|
onChange={(e) => updateResponseField(index, "name", e.target.value)}
|
||||||
|
placeholder="필드명 (예: userId)"
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={field.label || ""}
|
||||||
|
onChange={(e) => updateResponseField(index, "label", e.target.value)}
|
||||||
|
placeholder="표시명 (예: 사용자 ID)"
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={field.dataType || "TEXT"}
|
||||||
|
onValueChange={(value) => updateResponseField(index, "dataType", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="text-xs">
|
||||||
|
<SelectValue placeholder="데이터 타입" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="TEXT">텍스트</SelectItem>
|
||||||
|
<SelectItem value="NUMBER">숫자</SelectItem>
|
||||||
|
<SelectItem value="DATE">날짜</SelectItem>
|
||||||
|
<SelectItem value="BOOLEAN">참/거짓</SelectItem>
|
||||||
|
<SelectItem value="JSON">JSON</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button onClick={handleApply} className="w-full">
|
<Button onClick={handleApply} className="w-full">
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,8 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
||||||
|
|
||||||
// 소스 필드 목록 (연결된 입력 노드에서 가져오기)
|
// 소스 필드 목록 (연결된 입력 노드에서 가져오기)
|
||||||
const [sourceFields, setSourceFields] = useState<Array<{ name: string; label?: string }>>([]);
|
const [sourceFields, setSourceFields] = useState<Array<{ name: string; label?: string }>>([]);
|
||||||
|
// REST API 소스 노드 연결 여부
|
||||||
|
const [hasRestAPISource, setHasRestAPISource] = useState(false);
|
||||||
|
|
||||||
// 🔥 외부 DB 관련 상태
|
// 🔥 외부 DB 관련 상태
|
||||||
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
|
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
|
||||||
|
|
@ -150,9 +152,9 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
||||||
const getAllSourceFields = (
|
const getAllSourceFields = (
|
||||||
targetNodeId: string,
|
targetNodeId: string,
|
||||||
visitedNodes = new Set<string>(),
|
visitedNodes = new Set<string>(),
|
||||||
): Array<{ name: string; label?: string }> => {
|
): { fields: Array<{ name: string; label?: string }>; hasRestAPI: boolean } => {
|
||||||
if (visitedNodes.has(targetNodeId)) {
|
if (visitedNodes.has(targetNodeId)) {
|
||||||
return [];
|
return { fields: [], hasRestAPI: false };
|
||||||
}
|
}
|
||||||
visitedNodes.add(targetNodeId);
|
visitedNodes.add(targetNodeId);
|
||||||
|
|
||||||
|
|
@ -161,18 +163,21 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
||||||
const sourceNodes = nodes.filter((node) => sourceNodeIds.includes(node.id));
|
const sourceNodes = nodes.filter((node) => sourceNodeIds.includes(node.id));
|
||||||
|
|
||||||
const fields: Array<{ name: string; label?: string }> = [];
|
const fields: Array<{ name: string; label?: string }> = [];
|
||||||
|
let foundRestAPI = false;
|
||||||
|
|
||||||
sourceNodes.forEach((node) => {
|
sourceNodes.forEach((node) => {
|
||||||
// 데이터 변환 노드인 경우: 변환된 필드 + 상위 노드의 원본 필드
|
// 데이터 변환 노드인 경우: 변환된 필드 + 상위 노드의 원본 필드
|
||||||
if (node.type === "dataTransform") {
|
if (node.type === "dataTransform") {
|
||||||
// 상위 노드의 원본 필드 먼저 수집
|
// 상위 노드의 원본 필드 먼저 수집
|
||||||
const upperFields = getAllSourceFields(node.id, visitedNodes);
|
const upperResult = getAllSourceFields(node.id, visitedNodes);
|
||||||
|
const upperFields = upperResult.fields;
|
||||||
|
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
||||||
|
|
||||||
// 변환된 필드 추가 (in-place 변환 고려)
|
// 변환된 필드 추가 (in-place 변환 고려)
|
||||||
if (node.data.transformations && Array.isArray(node.data.transformations)) {
|
if ((node.data as any).transformations && Array.isArray((node.data as any).transformations)) {
|
||||||
const inPlaceFields = new Set<string>();
|
const inPlaceFields = new Set<string>();
|
||||||
|
|
||||||
node.data.transformations.forEach((transform: any) => {
|
(node.data as any).transformations.forEach((transform: any) => {
|
||||||
const targetField = transform.targetField || transform.sourceField;
|
const targetField = transform.targetField || transform.sourceField;
|
||||||
const isInPlace = !transform.targetField || transform.targetField === transform.sourceField;
|
const isInPlace = !transform.targetField || transform.targetField === transform.sourceField;
|
||||||
|
|
||||||
|
|
@ -194,16 +199,33 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
||||||
fields.push(...upperFields);
|
fields.push(...upperFields);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// REST API 소스 노드인 경우
|
||||||
|
else if (node.type === "restAPISource") {
|
||||||
|
foundRestAPI = true;
|
||||||
|
const responseFields = (node.data as any).responseFields;
|
||||||
|
if (responseFields && Array.isArray(responseFields)) {
|
||||||
|
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 if (node.type === "tableSource" && node.data.fields) {
|
else if (node.type === "tableSource" && (node.data as any).fields) {
|
||||||
node.data.fields.forEach((field: any) => {
|
(node.data as any).fields.forEach((field: any) => {
|
||||||
fields.push({
|
fields.push({
|
||||||
name: field.name,
|
name: field.name,
|
||||||
label: field.label || field.displayName,
|
label: field.label || field.displayName,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} else if (node.type === "externalDBSource" && node.data.fields) {
|
} else if (node.type === "externalDBSource" && (node.data as any).fields) {
|
||||||
node.data.fields.forEach((field: any) => {
|
(node.data as any).fields.forEach((field: any) => {
|
||||||
fields.push({
|
fields.push({
|
||||||
name: field.name,
|
name: field.name,
|
||||||
label: field.label || field.displayName,
|
label: field.label || field.displayName,
|
||||||
|
|
@ -212,15 +234,16 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return fields;
|
return { fields, hasRestAPI: foundRestAPI };
|
||||||
};
|
};
|
||||||
|
|
||||||
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);
|
setSourceFields(uniqueFields);
|
||||||
|
setHasRestAPISource(result.hasRestAPI);
|
||||||
}, [nodeId, nodes, edges]);
|
}, [nodeId, nodes, edges]);
|
||||||
|
|
||||||
const loadTables = async () => {
|
const loadTables = async () => {
|
||||||
|
|
@ -1130,35 +1153,48 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* 소스 필드 드롭다운 */}
|
{/* 소스 필드 - REST API인 경우 입력, 아니면 드롭다운 */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs text-gray-600">소스 필드</Label>
|
<Label className="text-xs text-gray-600">
|
||||||
<Select
|
소스 필드{hasRestAPISource && " (REST API - 직접 입력)"}
|
||||||
value={mapping.sourceField || ""}
|
</Label>
|
||||||
onValueChange={(value) => handleMappingChange(index, "sourceField", value || null)}
|
{hasRestAPISource ? (
|
||||||
>
|
<Input
|
||||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
value={mapping.sourceField || ""}
|
||||||
<SelectValue placeholder="소스 필드 선택" />
|
onChange={(e) => handleMappingChange(index, "sourceField", e.target.value || null)}
|
||||||
</SelectTrigger>
|
placeholder="API 응답 JSON의 필드명을 입력하세요"
|
||||||
<SelectContent>
|
className="mt-1 h-8 text-xs"
|
||||||
{sourceFields.length === 0 ? (
|
/>
|
||||||
<div className="p-2 text-center text-xs text-gray-400">
|
) : (
|
||||||
연결된 소스 노드가 없습니다
|
<Select
|
||||||
</div>
|
value={mapping.sourceField || ""}
|
||||||
) : (
|
onValueChange={(value) => handleMappingChange(index, "sourceField", value || null)}
|
||||||
sourceFields.map((field) => (
|
>
|
||||||
<SelectItem key={field.name} value={field.name} className="text-xs">
|
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<SelectValue placeholder="소스 필드 선택" />
|
||||||
<span className="font-medium">{field.label || field.name}</span>
|
</SelectTrigger>
|
||||||
{field.label && field.label !== field.name && (
|
<SelectContent>
|
||||||
<span className="text-muted-foreground font-mono text-xs">{field.name}</span>
|
{sourceFields.length === 0 ? (
|
||||||
)}
|
<div className="p-2 text-center text-xs text-gray-400">
|
||||||
</div>
|
연결된 소스 노드가 없습니다
|
||||||
</SelectItem>
|
</div>
|
||||||
))
|
) : (
|
||||||
)}
|
sourceFields.map((field) => (
|
||||||
</SelectContent>
|
<SelectItem key={field.name} value={field.name} className="text-xs">
|
||||||
</Select>
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="font-medium">{field.label || field.name}</span>
|
||||||
|
{field.label && field.label !== field.name && (
|
||||||
|
<span className="text-muted-foreground font-mono text-xs">
|
||||||
|
{field.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-center py-1">
|
<div className="flex items-center justify-center py-1">
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,8 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
||||||
|
|
||||||
// 소스 필드 목록 (연결된 입력 노드에서 가져오기)
|
// 소스 필드 목록 (연결된 입력 노드에서 가져오기)
|
||||||
const [sourceFields, setSourceFields] = useState<Array<{ name: string; label?: string }>>([]);
|
const [sourceFields, setSourceFields] = useState<Array<{ name: string; label?: string }>>([]);
|
||||||
|
// REST API 소스 노드 연결 여부
|
||||||
|
const [hasRestAPISource, setHasRestAPISource] = useState(false);
|
||||||
|
|
||||||
// 데이터 변경 시 로컬 상태 업데이트
|
// 데이터 변경 시 로컬 상태 업데이트
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -137,9 +139,9 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
||||||
const getAllSourceFields = (
|
const getAllSourceFields = (
|
||||||
targetNodeId: string,
|
targetNodeId: string,
|
||||||
visitedNodes = new Set<string>(),
|
visitedNodes = new Set<string>(),
|
||||||
): Array<{ name: string; label?: string }> => {
|
): { fields: Array<{ name: string; label?: string }>; hasRestAPI: boolean } => {
|
||||||
if (visitedNodes.has(targetNodeId)) {
|
if (visitedNodes.has(targetNodeId)) {
|
||||||
return [];
|
return { fields: [], hasRestAPI: false };
|
||||||
}
|
}
|
||||||
visitedNodes.add(targetNodeId);
|
visitedNodes.add(targetNodeId);
|
||||||
|
|
||||||
|
|
@ -148,18 +150,21 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
||||||
const sourceNodes = nodes.filter((node) => sourceNodeIds.includes(node.id));
|
const sourceNodes = nodes.filter((node) => sourceNodeIds.includes(node.id));
|
||||||
|
|
||||||
const fields: Array<{ name: string; label?: string }> = [];
|
const fields: Array<{ name: string; label?: string }> = [];
|
||||||
|
let foundRestAPI = false;
|
||||||
|
|
||||||
sourceNodes.forEach((node) => {
|
sourceNodes.forEach((node) => {
|
||||||
// 데이터 변환 노드인 경우: 변환된 필드 + 상위 노드의 원본 필드
|
// 데이터 변환 노드인 경우: 변환된 필드 + 상위 노드의 원본 필드
|
||||||
if (node.type === "dataTransform") {
|
if (node.type === "dataTransform") {
|
||||||
// 상위 노드의 원본 필드 먼저 수집
|
// 상위 노드의 원본 필드 먼저 수집
|
||||||
const upperFields = getAllSourceFields(node.id, visitedNodes);
|
const upperResult = getAllSourceFields(node.id, visitedNodes);
|
||||||
|
const upperFields = upperResult.fields;
|
||||||
|
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
||||||
|
|
||||||
// 변환된 필드 추가 (in-place 변환 고려)
|
// 변환된 필드 추가 (in-place 변환 고려)
|
||||||
if (node.data.transformations && Array.isArray(node.data.transformations)) {
|
if ((node.data as any).transformations && Array.isArray((node.data as any).transformations)) {
|
||||||
const inPlaceFields = new Set<string>();
|
const inPlaceFields = new Set<string>();
|
||||||
|
|
||||||
node.data.transformations.forEach((transform: any) => {
|
(node.data as any).transformations.forEach((transform: any) => {
|
||||||
const targetField = transform.targetField || transform.sourceField;
|
const targetField = transform.targetField || transform.sourceField;
|
||||||
const isInPlace = !transform.targetField || transform.targetField === transform.sourceField;
|
const isInPlace = !transform.targetField || transform.targetField === transform.sourceField;
|
||||||
|
|
||||||
|
|
@ -181,16 +186,33 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
||||||
fields.push(...upperFields);
|
fields.push(...upperFields);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// REST API 소스 노드인 경우
|
||||||
|
else if (node.type === "restAPISource") {
|
||||||
|
foundRestAPI = true;
|
||||||
|
const responseFields = (node.data as any).responseFields;
|
||||||
|
if (responseFields && Array.isArray(responseFields)) {
|
||||||
|
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 if (node.type === "tableSource" && node.data.fields) {
|
else if (node.type === "tableSource" && (node.data as any).fields) {
|
||||||
node.data.fields.forEach((field: any) => {
|
(node.data as any).fields.forEach((field: any) => {
|
||||||
fields.push({
|
fields.push({
|
||||||
name: field.name,
|
name: field.name,
|
||||||
label: field.label || field.displayName,
|
label: field.label || field.displayName,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} else if (node.type === "externalDBSource" && node.data.fields) {
|
} else if (node.type === "externalDBSource" && (node.data as any).fields) {
|
||||||
node.data.fields.forEach((field: any) => {
|
(node.data as any).fields.forEach((field: any) => {
|
||||||
fields.push({
|
fields.push({
|
||||||
name: field.name,
|
name: field.name,
|
||||||
label: field.label || field.displayName,
|
label: field.label || field.displayName,
|
||||||
|
|
@ -199,15 +221,16 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return fields;
|
return { fields, hasRestAPI: foundRestAPI };
|
||||||
};
|
};
|
||||||
|
|
||||||
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);
|
setSourceFields(uniqueFields);
|
||||||
|
setHasRestAPISource(result.hasRestAPI);
|
||||||
}, [nodeId, nodes, edges]);
|
}, [nodeId, nodes, edges]);
|
||||||
|
|
||||||
// 🔥 외부 커넥션 로딩 함수
|
// 🔥 외부 커넥션 로딩 함수
|
||||||
|
|
@ -986,33 +1009,46 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* 소스 필드 드롭다운 */}
|
{/* 소스 필드 - REST API인 경우 입력, 아니면 드롭다운 */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs text-gray-600">소스 필드</Label>
|
<Label className="text-xs text-gray-600">
|
||||||
<Select
|
소스 필드{hasRestAPISource && " (REST API - 직접 입력)"}
|
||||||
value={mapping.sourceField || ""}
|
</Label>
|
||||||
onValueChange={(value) => handleMappingChange(index, "sourceField", value || null)}
|
{hasRestAPISource ? (
|
||||||
>
|
<Input
|
||||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
value={mapping.sourceField || ""}
|
||||||
<SelectValue placeholder="소스 필드 선택" />
|
onChange={(e) => handleMappingChange(index, "sourceField", e.target.value || null)}
|
||||||
</SelectTrigger>
|
placeholder="API 응답 JSON의 필드명을 입력하세요"
|
||||||
<SelectContent>
|
className="mt-1 h-8 text-xs"
|
||||||
{sourceFields.length === 0 ? (
|
/>
|
||||||
<div className="p-2 text-center text-xs text-gray-400">연결된 소스 노드가 없습니다</div>
|
) : (
|
||||||
) : (
|
<Select
|
||||||
sourceFields.map((field) => (
|
value={mapping.sourceField || ""}
|
||||||
<SelectItem key={field.name} value={field.name} className="text-xs">
|
onValueChange={(value) => handleMappingChange(index, "sourceField", value || null)}
|
||||||
<div className="flex items-center justify-between gap-2">
|
>
|
||||||
<span className="font-medium">{field.label || field.name}</span>
|
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||||
{field.label && field.label !== field.name && (
|
<SelectValue placeholder="소스 필드 선택" />
|
||||||
<span className="text-muted-foreground font-mono text-xs">{field.name}</span>
|
</SelectTrigger>
|
||||||
)}
|
<SelectContent>
|
||||||
</div>
|
{sourceFields.length === 0 ? (
|
||||||
</SelectItem>
|
<div className="p-2 text-center text-xs text-gray-400">
|
||||||
))
|
연결된 소스 노드가 없습니다
|
||||||
)}
|
</div>
|
||||||
</SelectContent>
|
) : (
|
||||||
</Select>
|
sourceFields.map((field) => (
|
||||||
|
<SelectItem key={field.name} value={field.name} className="text-xs">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="font-medium">{field.label || field.name}</span>
|
||||||
|
{field.label && field.label !== field.name && (
|
||||||
|
<span className="text-muted-foreground font-mono text-xs">{field.name}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-center py-1">
|
<div className="flex items-center justify-center py-1">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue