feature/screen-management #88

Merged
kjs merged 4 commits from feature/screen-management into main 2025-10-13 12:10:34 +09:00
11 changed files with 655 additions and 199 deletions

View File

@ -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",
@ -7723,48 +7723,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",
@ -9787,9 +9791,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"

View File

@ -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",

View File

@ -61,23 +61,31 @@ export class BatchService {
// 배치 설정 조회 (매핑 포함 - 서브쿼리 사용)
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(
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<ApiResponse<BatchConfig>> {
try {
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(
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]
);

View File

@ -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<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> {
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<any> {
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<any> {
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

View File

@ -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

View File

@ -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

View File

@ -78,35 +78,33 @@ export const ConditionNode = memo(({ data, selected }: NodeProps<ConditionNodeDa
</div>
{/* 분기 출력 핸들 */}
<div className="border-t">
<div className="grid grid-cols-2">
{/* TRUE 출력 */}
<div className="relative border-r p-2">
<div className="flex items-center justify-center gap-1 text-xs">
<Check className="h-3 w-3 text-green-600" />
<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 className="relative border-t">
{/* TRUE 출력 - 오른쪽 위 */}
<div className="relative border-b p-2">
<div className="flex items-center justify-end gap-1 pr-6 text-xs">
<Check className="h-3 w-3 text-green-600" />
<span className="font-medium text-green-600">TRUE</span>
</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 출력 */}
<div className="relative p-2">
<div className="flex items-center justify-center gap-1 text-xs">
<X className="h-3 w-3 text-red-600" />
<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"
/>
{/* FALSE 출력 - 오른쪽 아래 */}
<div className="relative p-2">
<div className="flex items-center justify-end gap-1 pr-6 text-xs">
<X className="h-3 w-3 text-red-600" />
<span className="font-medium text-red-600">FALSE</span>
</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>

View File

@ -64,6 +64,8 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
// 소스 필드 목록 (연결된 입력 노드에서 가져오기)
const [sourceFields, setSourceFields] = useState<Array<{ name: string; label?: string }>>([]);
// REST API 소스 노드 연결 여부
const [hasRestAPISource, setHasRestAPISource] = useState(false);
// 🔥 외부 DB 관련 상태
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
@ -135,9 +137,9 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
const getAllSourceFields = (
targetNodeId: string,
visitedNodes = new Set<string>(),
): 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<string>(); // 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
<div>
<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>
<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}
onChange={(e) => {
setApiBodyTemplate(e.target.value);
@ -937,7 +966,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
rows={8}
/>
<p className="mt-1 text-xs text-gray-500">
{`{{필드명}}`} .
{"{{필드명}}"} .
</p>
</div>
</div>
@ -1011,35 +1040,54 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
</div>
<div className="space-y-2">
{/* 소스 필드 드롭다운 */}
{/* 소스 필드 입력/선택 */}
<div>
<Label className="text-xs text-gray-600"> </Label>
<Select
value={mapping.sourceField || ""}
onValueChange={(value) => handleMappingChange(index, "sourceField", value || null)}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue placeholder="소스 필드 선택" />
</SelectTrigger>
<SelectContent>
{sourceFields.length === 0 ? (
<div className="p-2 text-center text-xs text-gray-400">
</div>
) : (
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>
<Label className="text-xs text-gray-600">
{hasRestAPISource && <span className="ml-1 text-teal-600">(REST API - )</span>}
</Label>
{hasRestAPISource ? (
// REST API 소스인 경우: 직접 입력
<Input
value={mapping.sourceField || ""}
onChange={(e) => handleMappingChange(index, "sourceField", e.target.value || null)}
placeholder="필드명 입력 (예: userId, userName)"
className="mt-1 h-8 text-xs"
/>
) : (
// 일반 소스인 경우: 드롭다운 선택
<Select
value={mapping.sourceField || ""}
onValueChange={(value) => handleMappingChange(index, "sourceField", value || null)}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue placeholder="소스 필드 선택" />
</SelectTrigger>
<SelectContent>
{sourceFields.length === 0 ? (
<div className="p-2 text-center text-xs text-gray-400">
</div>
) : (
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 className="flex items-center justify-center py-1">

View File

@ -37,6 +37,7 @@ export function RestAPISourceProperties({ nodeId, data }: RestAPISourcePropertie
const [authToken, setAuthToken] = useState(data.authentication?.token || "");
const [timeout, setTimeout] = useState(data.timeout?.toString() || "30000");
const [responseMapping, setResponseMapping] = useState(data.responseMapping || "");
const [responseFields, setResponseFields] = useState(data.responseFields || []);
useEffect(() => {
setDisplayName(data.displayName || "");
@ -48,6 +49,7 @@ export function RestAPISourceProperties({ nodeId, data }: RestAPISourcePropertie
setAuthToken(data.authentication?.token || "");
setTimeout(data.timeout?.toString() || "30000");
setResponseMapping(data.responseMapping || "");
setResponseFields(data.responseFields || []);
}, [data]);
const handleApply = () => {
@ -59,6 +61,10 @@ export function RestAPISourceProperties({ nodeId, data }: RestAPISourcePropertie
return;
}
console.log("🔧 REST API 노드 업데이트 중...");
console.log("📦 responseFields:", responseFields);
console.log("📊 responseFields 개수:", responseFields.length);
updateNode(nodeId, {
displayName,
url,
@ -71,7 +77,10 @@ export function RestAPISourceProperties({ nodeId, data }: RestAPISourcePropertie
},
timeout: parseInt(timeout) || 30000,
responseMapping,
responseFields,
});
console.log("✅ REST API 노드 업데이트 완료");
};
const addHeader = () => {
@ -88,6 +97,32 @@ export function RestAPISourceProperties({ nodeId, data }: RestAPISourcePropertie
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 (
<div className="space-y-4 p-4">
<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"
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>
<Button onClick={handleApply} className="w-full">

View File

@ -79,6 +79,8 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
// 소스 필드 목록 (연결된 입력 노드에서 가져오기)
const [sourceFields, setSourceFields] = useState<Array<{ name: string; label?: string }>>([]);
// REST API 소스 노드 연결 여부
const [hasRestAPISource, setHasRestAPISource] = useState(false);
// 🔥 외부 DB 관련 상태
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
@ -150,9 +152,9 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
const getAllSourceFields = (
targetNodeId: string,
visitedNodes = new Set<string>(),
): 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);
@ -161,18 +163,21 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
const sourceNodes = nodes.filter((node) => sourceNodeIds.includes(node.id));
const fields: Array<{ name: string; label?: string }> = [];
let foundRestAPI = false;
sourceNodes.forEach((node) => {
// 데이터 변환 노드인 경우: 변환된 필드 + 상위 노드의 원본 필드
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 변환 고려)
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>();
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;
@ -194,16 +199,33 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
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) {
node.data.fields.forEach((field: any) => {
else if (node.type === "tableSource" && (node.data as any).fields) {
(node.data as any).fields.forEach((field: any) => {
fields.push({
name: field.name,
label: field.label || field.displayName,
});
});
} else if (node.type === "externalDBSource" && node.data.fields) {
node.data.fields.forEach((field: any) => {
} else if (node.type === "externalDBSource" && (node.data as any).fields) {
(node.data as any).fields.forEach((field: any) => {
fields.push({
name: field.name,
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);
setHasRestAPISource(result.hasRestAPI);
}, [nodeId, nodes, edges]);
const loadTables = async () => {
@ -1130,35 +1153,48 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
</div>
<div className="space-y-2">
{/* 소스 필드 드롭다운 */}
{/* 소스 필드 - REST API인 경우 입력, 아니면 드롭다운 */}
<div>
<Label className="text-xs text-gray-600"> </Label>
<Select
value={mapping.sourceField || ""}
onValueChange={(value) => handleMappingChange(index, "sourceField", value || null)}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue placeholder="소스 필드 선택" />
</SelectTrigger>
<SelectContent>
{sourceFields.length === 0 ? (
<div className="p-2 text-center text-xs text-gray-400">
</div>
) : (
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>
<Label className="text-xs text-gray-600">
{hasRestAPISource && " (REST API - 직접 입력)"}
</Label>
{hasRestAPISource ? (
<Input
value={mapping.sourceField || ""}
onChange={(e) => handleMappingChange(index, "sourceField", e.target.value || null)}
placeholder="API 응답 JSON의 필드명을 입력하세요"
className="mt-1 h-8 text-xs"
/>
) : (
<Select
value={mapping.sourceField || ""}
onValueChange={(value) => handleMappingChange(index, "sourceField", value || null)}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue placeholder="소스 필드 선택" />
</SelectTrigger>
<SelectContent>
{sourceFields.length === 0 ? (
<div className="p-2 text-center text-xs text-gray-400">
</div>
) : (
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 className="flex items-center justify-center py-1">

View File

@ -85,6 +85,8 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
// 소스 필드 목록 (연결된 입력 노드에서 가져오기)
const [sourceFields, setSourceFields] = useState<Array<{ name: string; label?: string }>>([]);
// REST API 소스 노드 연결 여부
const [hasRestAPISource, setHasRestAPISource] = useState(false);
// 데이터 변경 시 로컬 상태 업데이트
useEffect(() => {
@ -137,9 +139,9 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
const getAllSourceFields = (
targetNodeId: string,
visitedNodes = new Set<string>(),
): 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);
@ -148,18 +150,21 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
const sourceNodes = nodes.filter((node) => sourceNodeIds.includes(node.id));
const fields: Array<{ name: string; label?: string }> = [];
let foundRestAPI = false;
sourceNodes.forEach((node) => {
// 데이터 변환 노드인 경우: 변환된 필드 + 상위 노드의 원본 필드
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 변환 고려)
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>();
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;
@ -181,16 +186,33 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
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) {
node.data.fields.forEach((field: any) => {
else if (node.type === "tableSource" && (node.data as any).fields) {
(node.data as any).fields.forEach((field: any) => {
fields.push({
name: field.name,
label: field.label || field.displayName,
});
});
} else if (node.type === "externalDBSource" && node.data.fields) {
node.data.fields.forEach((field: any) => {
} else if (node.type === "externalDBSource" && (node.data as any).fields) {
(node.data as any).fields.forEach((field: any) => {
fields.push({
name: field.name,
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);
setHasRestAPISource(result.hasRestAPI);
}, [nodeId, nodes, edges]);
// 🔥 외부 커넥션 로딩 함수
@ -986,33 +1009,46 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
</div>
<div className="space-y-2">
{/* 소스 필드 드롭다운 */}
{/* 소스 필드 - REST API인 경우 입력, 아니면 드롭다운 */}
<div>
<Label className="text-xs text-gray-600"> </Label>
<Select
value={mapping.sourceField || ""}
onValueChange={(value) => handleMappingChange(index, "sourceField", value || null)}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue placeholder="소스 필드 선택" />
</SelectTrigger>
<SelectContent>
{sourceFields.length === 0 ? (
<div className="p-2 text-center text-xs text-gray-400"> </div>
) : (
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>
<Label className="text-xs text-gray-600">
{hasRestAPISource && " (REST API - 직접 입력)"}
</Label>
{hasRestAPISource ? (
<Input
value={mapping.sourceField || ""}
onChange={(e) => handleMappingChange(index, "sourceField", e.target.value || null)}
placeholder="API 응답 JSON의 필드명을 입력하세요"
className="mt-1 h-8 text-xs"
/>
) : (
<Select
value={mapping.sourceField || ""}
onValueChange={(value) => handleMappingChange(index, "sourceField", value || null)}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue placeholder="소스 필드 선택" />
</SelectTrigger>
<SelectContent>
{sourceFields.length === 0 ? (
<div className="p-2 text-center text-xs text-gray-400">
</div>
) : (
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 className="flex items-center justify-center py-1">