Merge pull request 'feature/screen-management' (#86) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/86
This commit is contained in:
commit
1eff6730b4
|
|
@ -103,15 +103,34 @@ export class OracleConnector implements DatabaseConnector {
|
|||
try {
|
||||
const startTime = Date.now();
|
||||
|
||||
// 쿼리 타입 확인 (DML인지 SELECT인지)
|
||||
// 쿼리 타입 확인
|
||||
const isDML = /^\s*(INSERT|UPDATE|DELETE|MERGE)/i.test(query);
|
||||
const isCOMMIT = /^\s*COMMIT/i.test(query);
|
||||
const isROLLBACK = /^\s*ROLLBACK/i.test(query);
|
||||
|
||||
// 🔥 COMMIT/ROLLBACK 명령은 직접 실행
|
||||
if (isCOMMIT || isROLLBACK) {
|
||||
if (isCOMMIT) {
|
||||
await this.connection!.commit();
|
||||
console.log("✅ Oracle COMMIT 실행됨");
|
||||
} else {
|
||||
await this.connection!.rollback();
|
||||
console.log("⚠️ Oracle ROLLBACK 실행됨");
|
||||
}
|
||||
return {
|
||||
rows: [],
|
||||
rowCount: 0,
|
||||
fields: [],
|
||||
affectedRows: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Oracle XE 21c 쿼리 실행 옵션
|
||||
const options: any = {
|
||||
outFormat: (oracledb as any).OUT_FORMAT_OBJECT, // OBJECT format
|
||||
maxRows: 10000, // XE 제한 고려
|
||||
fetchArraySize: 100,
|
||||
autoCommit: isDML, // ✅ DML 쿼리는 자동 커밋
|
||||
autoCommit: false, // 🔥 수동으로 COMMIT 제어하도록 변경
|
||||
};
|
||||
|
||||
console.log("Oracle 쿼리 실행:", {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import axios, { AxiosInstance, AxiosResponse } from 'axios';
|
||||
import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector';
|
||||
import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes';
|
||||
import axios, { AxiosInstance, AxiosResponse } from "axios";
|
||||
import {
|
||||
DatabaseConnector,
|
||||
ConnectionConfig,
|
||||
QueryResult,
|
||||
} from "../interfaces/DatabaseConnector";
|
||||
import { ConnectionTestResult, TableInfo } from "../types/externalDbTypes";
|
||||
|
||||
export interface RestApiConfig {
|
||||
baseUrl: string;
|
||||
|
|
@ -20,16 +24,16 @@ export class RestApiConnector implements DatabaseConnector {
|
|||
|
||||
constructor(config: RestApiConfig) {
|
||||
this.config = config;
|
||||
|
||||
|
||||
// Axios 인스턴스 생성
|
||||
this.httpClient = axios.create({
|
||||
baseURL: config.baseUrl,
|
||||
timeout: config.timeout || 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${config.apiKey}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
// 요청/응답 인터셉터 설정
|
||||
|
|
@ -40,11 +44,13 @@ export class RestApiConnector implements DatabaseConnector {
|
|||
// 요청 인터셉터
|
||||
this.httpClient.interceptors.request.use(
|
||||
(config) => {
|
||||
console.log(`[RestApiConnector] 요청: ${config.method?.toUpperCase()} ${config.url}`);
|
||||
console.log(
|
||||
`[RestApiConnector] 요청: ${config.method?.toUpperCase()} ${config.url}`
|
||||
);
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
console.error('[RestApiConnector] 요청 오류:', error);
|
||||
console.error("[RestApiConnector] 요청 오류:", error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
|
@ -52,11 +58,17 @@ export class RestApiConnector implements DatabaseConnector {
|
|||
// 응답 인터셉터
|
||||
this.httpClient.interceptors.response.use(
|
||||
(response) => {
|
||||
console.log(`[RestApiConnector] 응답: ${response.status} ${response.statusText}`);
|
||||
console.log(
|
||||
`[RestApiConnector] 응답: ${response.status} ${response.statusText}`
|
||||
);
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
console.error('[RestApiConnector] 응답 오류:', error.response?.status, error.response?.statusText);
|
||||
console.error(
|
||||
"[RestApiConnector] 응답 오류:",
|
||||
error.response?.status,
|
||||
error.response?.statusText
|
||||
);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
|
@ -65,16 +77,23 @@ export class RestApiConnector implements DatabaseConnector {
|
|||
async connect(): Promise<void> {
|
||||
try {
|
||||
// 연결 테스트 - 기본 엔드포인트 호출
|
||||
await this.httpClient.get('/health', { timeout: 5000 });
|
||||
await this.httpClient.get("/health", { timeout: 5000 });
|
||||
console.log(`[RestApiConnector] 연결 성공: ${this.config.baseUrl}`);
|
||||
} catch (error) {
|
||||
// health 엔드포인트가 없을 수 있으므로 404는 정상으로 처리
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||
console.log(`[RestApiConnector] 연결 성공 (health 엔드포인트 없음): ${this.config.baseUrl}`);
|
||||
console.log(
|
||||
`[RestApiConnector] 연결 성공 (health 엔드포인트 없음): ${this.config.baseUrl}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.error(`[RestApiConnector] 연결 실패: ${this.config.baseUrl}`, error);
|
||||
throw new Error(`REST API 연결 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`);
|
||||
console.error(
|
||||
`[RestApiConnector] 연결 실패: ${this.config.baseUrl}`,
|
||||
error
|
||||
);
|
||||
throw new Error(
|
||||
`REST API 연결 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -88,39 +107,55 @@ export class RestApiConnector implements DatabaseConnector {
|
|||
await this.connect();
|
||||
return {
|
||||
success: true,
|
||||
message: 'REST API 연결이 성공했습니다.',
|
||||
message: "REST API 연결이 성공했습니다.",
|
||||
details: {
|
||||
response_time: Date.now()
|
||||
}
|
||||
response_time: Date.now(),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'REST API 연결에 실패했습니다.',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "REST API 연결에 실패했습니다.",
|
||||
details: {
|
||||
response_time: Date.now()
|
||||
}
|
||||
response_time: Date.now(),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async executeQuery(endpoint: string, method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET', data?: any): Promise<QueryResult> {
|
||||
// 🔥 DatabaseConnector 인터페이스 호환용 executeQuery (사용하지 않음)
|
||||
async executeQuery(query: string, params?: any[]): Promise<QueryResult> {
|
||||
// REST API는 executeRequest를 사용해야 함
|
||||
throw new Error(
|
||||
"REST API Connector는 executeQuery를 지원하지 않습니다. executeRequest를 사용하세요."
|
||||
);
|
||||
}
|
||||
|
||||
// 🔥 실제 REST API 요청을 위한 메서드
|
||||
async executeRequest(
|
||||
endpoint: string,
|
||||
method: "GET" | "POST" | "PUT" | "DELETE" = "GET",
|
||||
data?: any
|
||||
): Promise<QueryResult> {
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
let response: AxiosResponse;
|
||||
|
||||
// HTTP 메서드에 따른 요청 실행
|
||||
switch (method.toUpperCase()) {
|
||||
case 'GET':
|
||||
case "GET":
|
||||
response = await this.httpClient.get(endpoint);
|
||||
break;
|
||||
case 'POST':
|
||||
case "POST":
|
||||
response = await this.httpClient.post(endpoint, data);
|
||||
break;
|
||||
case 'PUT':
|
||||
case "PUT":
|
||||
response = await this.httpClient.put(endpoint, data);
|
||||
break;
|
||||
case 'DELETE':
|
||||
case "DELETE":
|
||||
response = await this.httpClient.delete(endpoint);
|
||||
break;
|
||||
default:
|
||||
|
|
@ -133,21 +168,36 @@ export class RestApiConnector implements DatabaseConnector {
|
|||
console.log(`[RestApiConnector] 원본 응답 데이터:`, {
|
||||
type: typeof responseData,
|
||||
isArray: Array.isArray(responseData),
|
||||
keys: typeof responseData === 'object' ? Object.keys(responseData) : 'not object',
|
||||
responseData: responseData
|
||||
keys:
|
||||
typeof responseData === "object"
|
||||
? Object.keys(responseData)
|
||||
: "not object",
|
||||
responseData: responseData,
|
||||
});
|
||||
|
||||
// 응답 데이터 처리
|
||||
let rows: any[];
|
||||
if (Array.isArray(responseData)) {
|
||||
rows = responseData;
|
||||
} else if (responseData && responseData.data && Array.isArray(responseData.data)) {
|
||||
} else if (
|
||||
responseData &&
|
||||
responseData.data &&
|
||||
Array.isArray(responseData.data)
|
||||
) {
|
||||
// API 응답이 {success: true, data: [...]} 형태인 경우
|
||||
rows = responseData.data;
|
||||
} else if (responseData && responseData.data && typeof responseData.data === 'object') {
|
||||
} else if (
|
||||
responseData &&
|
||||
responseData.data &&
|
||||
typeof responseData.data === "object"
|
||||
) {
|
||||
// API 응답이 {success: true, data: {...}} 형태인 경우 (단일 객체)
|
||||
rows = [responseData.data];
|
||||
} else if (responseData && typeof responseData === 'object' && !Array.isArray(responseData)) {
|
||||
} else if (
|
||||
responseData &&
|
||||
typeof responseData === "object" &&
|
||||
!Array.isArray(responseData)
|
||||
) {
|
||||
// 단일 객체 응답인 경우
|
||||
rows = [responseData];
|
||||
} else {
|
||||
|
|
@ -156,8 +206,8 @@ export class RestApiConnector implements DatabaseConnector {
|
|||
|
||||
console.log(`[RestApiConnector] 처리된 rows:`, {
|
||||
rowsLength: rows.length,
|
||||
firstRow: rows.length > 0 ? rows[0] : 'no data',
|
||||
allRows: rows
|
||||
firstRow: rows.length > 0 ? rows[0] : "no data",
|
||||
allRows: rows,
|
||||
});
|
||||
|
||||
console.log(`[RestApiConnector] API 호출 결과:`, {
|
||||
|
|
@ -165,22 +215,32 @@ export class RestApiConnector implements DatabaseConnector {
|
|||
method,
|
||||
status: response.status,
|
||||
rowCount: rows.length,
|
||||
executionTime: `${executionTime}ms`
|
||||
executionTime: `${executionTime}ms`,
|
||||
});
|
||||
|
||||
return {
|
||||
rows: rows,
|
||||
rowCount: rows.length,
|
||||
fields: rows.length > 0 ? Object.keys(rows[0]).map(key => ({ name: key, type: 'string' })) : []
|
||||
fields:
|
||||
rows.length > 0
|
||||
? Object.keys(rows[0]).map((key) => ({ name: key, type: "string" }))
|
||||
: [],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[RestApiConnector] API 호출 오류 (${method} ${endpoint}):`, error);
|
||||
|
||||
console.error(
|
||||
`[RestApiConnector] API 호출 오류 (${method} ${endpoint}):`,
|
||||
error
|
||||
);
|
||||
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw new Error(`REST API 호출 실패: ${error.response?.status} ${error.response?.statusText}`);
|
||||
throw new Error(
|
||||
`REST API 호출 실패: ${error.response?.status} ${error.response?.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`REST API 호출 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`);
|
||||
|
||||
throw new Error(
|
||||
`REST API 호출 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -189,20 +249,20 @@ export class RestApiConnector implements DatabaseConnector {
|
|||
// 일반적인 REST API 엔드포인트들을 반환
|
||||
return [
|
||||
{
|
||||
table_name: '/api/users',
|
||||
table_name: "/api/users",
|
||||
columns: [],
|
||||
description: '사용자 정보 API'
|
||||
description: "사용자 정보 API",
|
||||
},
|
||||
{
|
||||
table_name: '/api/data',
|
||||
table_name: "/api/data",
|
||||
columns: [],
|
||||
description: '기본 데이터 API'
|
||||
description: "기본 데이터 API",
|
||||
},
|
||||
{
|
||||
table_name: '/api/custom',
|
||||
table_name: "/api/custom",
|
||||
columns: [],
|
||||
description: '사용자 정의 엔드포인트'
|
||||
}
|
||||
description: "사용자 정의 엔드포인트",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -213,22 +273,25 @@ export class RestApiConnector implements DatabaseConnector {
|
|||
async getColumns(endpoint: string): Promise<any[]> {
|
||||
try {
|
||||
// GET 요청으로 샘플 데이터를 가져와서 필드 구조 파악
|
||||
const result = await this.executeQuery(endpoint, 'GET');
|
||||
|
||||
const result = await this.executeRequest(endpoint, "GET");
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
const sampleRow = result.rows[0];
|
||||
return Object.keys(sampleRow).map(key => ({
|
||||
return Object.keys(sampleRow).map((key) => ({
|
||||
column_name: key,
|
||||
data_type: typeof sampleRow[key],
|
||||
is_nullable: 'YES',
|
||||
is_nullable: "YES",
|
||||
column_default: null,
|
||||
description: `${key} 필드`
|
||||
description: `${key} 필드`,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error(`[RestApiConnector] 컬럼 정보 조회 오류 (${endpoint}):`, error);
|
||||
console.error(
|
||||
`[RestApiConnector] 컬럼 정보 조회 오류 (${endpoint}):`,
|
||||
error
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -238,24 +301,29 @@ export class RestApiConnector implements DatabaseConnector {
|
|||
}
|
||||
|
||||
// REST API 전용 메서드들
|
||||
async getData(endpoint: string, params?: Record<string, any>): Promise<any[]> {
|
||||
const queryString = params ? '?' + new URLSearchParams(params).toString() : '';
|
||||
const result = await this.executeQuery(endpoint + queryString, 'GET');
|
||||
async getData(
|
||||
endpoint: string,
|
||||
params?: Record<string, any>
|
||||
): Promise<any[]> {
|
||||
const queryString = params
|
||||
? "?" + new URLSearchParams(params).toString()
|
||||
: "";
|
||||
const result = await this.executeRequest(endpoint + queryString, "GET");
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async postData(endpoint: string, data: any): Promise<any> {
|
||||
const result = await this.executeQuery(endpoint, 'POST', data);
|
||||
const result = await this.executeRequest(endpoint, "POST", data);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async putData(endpoint: string, data: any): Promise<any> {
|
||||
const result = await this.executeQuery(endpoint, 'PUT', data);
|
||||
const result = await this.executeRequest(endpoint, "PUT", data);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async deleteData(endpoint: string): Promise<any> {
|
||||
const result = await this.executeQuery(endpoint, 'DELETE');
|
||||
const result = await this.executeRequest(endpoint, "DELETE");
|
||||
return result.rows[0];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes';
|
||||
import { ConnectionTestResult, TableInfo } from "../types/externalDbTypes";
|
||||
|
||||
export interface ConnectionConfig {
|
||||
host: string;
|
||||
|
|
@ -15,13 +15,15 @@ export interface QueryResult {
|
|||
rows: any[];
|
||||
rowCount?: number;
|
||||
fields?: any[];
|
||||
affectedRows?: number; // MySQL/MariaDB용
|
||||
length?: number; // 배열 형태로 반환되는 경우
|
||||
}
|
||||
|
||||
export interface DatabaseConnector {
|
||||
connect(): Promise<void>;
|
||||
disconnect(): Promise<void>;
|
||||
testConnection(): Promise<ConnectionTestResult>;
|
||||
executeQuery(query: string): Promise<QueryResult>;
|
||||
executeQuery(query: string, params?: any[]): Promise<QueryResult>; // params 추가
|
||||
getTables(): Promise<TableInfo[]>;
|
||||
getColumns(tableName: string): Promise<any[]>; // 특정 테이블의 컬럼 정보 조회
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,231 @@
|
|||
import { Router, Request, Response } from "express";
|
||||
import {
|
||||
authenticateToken,
|
||||
AuthenticatedRequest,
|
||||
} from "../../middleware/authMiddleware";
|
||||
import { ExternalDbConnectionService } from "../../services/externalDbConnectionService";
|
||||
import { ExternalDbConnectionFilter } from "../../types/externalDbTypes";
|
||||
import logger from "../../utils/logger";
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/dataflow/node-external-connections/tested
|
||||
* 노드 플로우용: 테스트에 성공한 외부 DB 커넥션 목록 조회
|
||||
*/
|
||||
router.get(
|
||||
"/tested",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
logger.info("🔍 노드 플로우용 테스트 완료된 커넥션 조회 요청");
|
||||
|
||||
// 활성 상태의 외부 커넥션 조회
|
||||
const filter: ExternalDbConnectionFilter = {
|
||||
is_active: "Y",
|
||||
};
|
||||
|
||||
const externalConnections =
|
||||
await ExternalDbConnectionService.getConnections(filter);
|
||||
|
||||
if (!externalConnections.success) {
|
||||
return res.status(400).json(externalConnections);
|
||||
}
|
||||
|
||||
// 외부 커넥션들에 대해 연결 테스트 수행 (제한된 병렬 처리 + 타임아웃 관리)
|
||||
const validExternalConnections: any[] = [];
|
||||
const connections = externalConnections.data || [];
|
||||
const MAX_CONCURRENT = 3; // 최대 동시 연결 수
|
||||
const TIMEOUT_MS = 3000; // 타임아웃 3초
|
||||
|
||||
// 청크 단위로 처리 (최대 3개씩)
|
||||
for (let i = 0; i < connections.length; i += MAX_CONCURRENT) {
|
||||
const chunk = connections.slice(i, i + MAX_CONCURRENT);
|
||||
|
||||
const chunkResults = await Promise.allSettled(
|
||||
chunk.map(async (connection) => {
|
||||
let testPromise: Promise<any> | null = null;
|
||||
let timeoutId: NodeJS.Timeout | null = null;
|
||||
|
||||
try {
|
||||
// 타임아웃과 함께 테스트 실행
|
||||
testPromise = ExternalDbConnectionService.testConnectionById(
|
||||
connection.id!
|
||||
);
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(new Error("연결 테스트 타임아웃"));
|
||||
}, TIMEOUT_MS);
|
||||
});
|
||||
|
||||
const testResult = await Promise.race([
|
||||
testPromise,
|
||||
timeoutPromise,
|
||||
]);
|
||||
|
||||
// 타임아웃 정리
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
|
||||
if (testResult.success) {
|
||||
return {
|
||||
id: connection.id,
|
||||
connection_name: connection.connection_name,
|
||||
description: connection.description,
|
||||
db_type: connection.db_type,
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
database_name: connection.database_name,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
// 타임아웃 정리
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
|
||||
// 🔥 타임아웃 시 연결 강제 해제
|
||||
try {
|
||||
const { DatabaseConnectorFactory } = await import(
|
||||
"../../database/DatabaseConnectorFactory"
|
||||
);
|
||||
await DatabaseConnectorFactory.closeConnector(
|
||||
connection.id!,
|
||||
connection.db_type
|
||||
);
|
||||
logger.info(
|
||||
`🧹 타임아웃/실패로 인한 커넥션 정리 완료: ${connection.connection_name}`
|
||||
);
|
||||
} catch (cleanupError) {
|
||||
logger.warn(
|
||||
`커넥션 정리 실패 (ID: ${connection.id}):`,
|
||||
cleanupError instanceof Error
|
||||
? cleanupError.message
|
||||
: cleanupError
|
||||
);
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`커넥션 테스트 실패 (ID: ${connection.id}):`,
|
||||
error instanceof Error ? error.message : error
|
||||
);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// fulfilled 결과만 수집
|
||||
chunkResults.forEach((result) => {
|
||||
if (result.status === "fulfilled" && result.value !== null) {
|
||||
validExternalConnections.push(result.value);
|
||||
}
|
||||
});
|
||||
|
||||
// 다음 청크 처리 전 짧은 대기 (연결 풀 안정화)
|
||||
if (i + MAX_CONCURRENT < connections.length) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`✅ 테스트 성공한 커넥션: ${validExternalConnections.length}/${externalConnections.data?.length || 0}개`
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
data: validExternalConnections,
|
||||
message: `테스트에 성공한 ${validExternalConnections.length}개의 커넥션을 조회했습니다.`,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("노드 플로우용 커넥션 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "서버 내부 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/dataflow/node-external-connections/:id/tables
|
||||
* 특정 외부 DB의 테이블 목록 조회
|
||||
*/
|
||||
router.get(
|
||||
"/:id/tables",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 연결 ID입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`🔍 외부 DB 테이블 목록 조회: connectionId=${id}`);
|
||||
|
||||
const result =
|
||||
await ExternalDbConnectionService.getTablesFromConnection(id);
|
||||
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
logger.error("외부 DB 테이블 목록 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "서버 내부 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/dataflow/node-external-connections/:id/tables/:tableName/columns
|
||||
* 특정 외부 DB 테이블의 컬럼 목록 조회
|
||||
*/
|
||||
router.get(
|
||||
"/:id/tables/:tableName/columns",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
const { tableName } = req.params;
|
||||
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 연결 ID입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!tableName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "테이블명이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`🔍 외부 DB 컬럼 목록 조회: connectionId=${id}, table=${tableName}`
|
||||
);
|
||||
|
||||
const result = await ExternalDbConnectionService.getColumnsFromConnection(
|
||||
id,
|
||||
tableName
|
||||
);
|
||||
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
logger.error("외부 DB 컬럼 목록 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "서버 내부 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
/**
|
||||
* 노드 기반 데이터 플로우 API
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from "express";
|
||||
import { query, queryOne } from "../../database/db";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { NodeFlowExecutionService } from "../../services/nodeFlowExecutionService";
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* 플로우 목록 조회
|
||||
*/
|
||||
router.get("/", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const flows = await query(
|
||||
`
|
||||
SELECT
|
||||
flow_id as "flowId",
|
||||
flow_name as "flowName",
|
||||
flow_description as "flowDescription",
|
||||
created_at as "createdAt",
|
||||
updated_at as "updatedAt"
|
||||
FROM node_flows
|
||||
ORDER BY updated_at DESC
|
||||
`,
|
||||
[]
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: flows,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("플로우 목록 조회 실패:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "플로우 목록을 조회하지 못했습니다.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 플로우 상세 조회
|
||||
*/
|
||||
router.get("/:flowId", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { flowId } = req.params;
|
||||
|
||||
const flow = await queryOne(
|
||||
`
|
||||
SELECT
|
||||
flow_id as "flowId",
|
||||
flow_name as "flowName",
|
||||
flow_description as "flowDescription",
|
||||
flow_data as "flowData",
|
||||
created_at as "createdAt",
|
||||
updated_at as "updatedAt"
|
||||
FROM node_flows
|
||||
WHERE flow_id = $1
|
||||
`,
|
||||
[flowId]
|
||||
);
|
||||
|
||||
if (!flow) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "플로우를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: flow,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("플로우 조회 실패:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "플로우를 조회하지 못했습니다.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 플로우 저장 (신규)
|
||||
*/
|
||||
router.post("/", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { flowName, flowDescription, flowData } = req.body;
|
||||
|
||||
if (!flowName || !flowData) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "플로우 이름과 데이터는 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await queryOne(
|
||||
`
|
||||
INSERT INTO node_flows (flow_name, flow_description, flow_data)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING flow_id as "flowId"
|
||||
`,
|
||||
[flowName, flowDescription || "", flowData]
|
||||
);
|
||||
|
||||
logger.info(`플로우 저장 성공: ${result.flowId}`);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "플로우가 저장되었습니다.",
|
||||
data: {
|
||||
flowId: result.flowId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("플로우 저장 실패:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "플로우를 저장하지 못했습니다.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 플로우 수정
|
||||
*/
|
||||
router.put("/", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { flowId, flowName, flowDescription, flowData } = req.body;
|
||||
|
||||
if (!flowId || !flowName || !flowData) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "플로우 ID, 이름, 데이터는 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
await query(
|
||||
`
|
||||
UPDATE node_flows
|
||||
SET flow_name = $1,
|
||||
flow_description = $2,
|
||||
flow_data = $3,
|
||||
updated_at = NOW()
|
||||
WHERE flow_id = $4
|
||||
`,
|
||||
[flowName, flowDescription || "", flowData, flowId]
|
||||
);
|
||||
|
||||
logger.info(`플로우 수정 성공: ${flowId}`);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "플로우가 수정되었습니다.",
|
||||
data: {
|
||||
flowId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("플로우 수정 실패:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "플로우를 수정하지 못했습니다.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 플로우 삭제
|
||||
*/
|
||||
router.delete("/:flowId", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { flowId } = req.params;
|
||||
|
||||
await query(
|
||||
`
|
||||
DELETE FROM node_flows
|
||||
WHERE flow_id = $1
|
||||
`,
|
||||
[flowId]
|
||||
);
|
||||
|
||||
logger.info(`플로우 삭제 성공: ${flowId}`);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "플로우가 삭제되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("플로우 삭제 실패:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "플로우를 삭제하지 못했습니다.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 플로우 실행
|
||||
* POST /api/dataflow/node-flows/:flowId/execute
|
||||
*/
|
||||
router.post("/:flowId/execute", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { flowId } = req.params;
|
||||
const contextData = req.body;
|
||||
|
||||
logger.info(`🚀 플로우 실행 요청: flowId=${flowId}`, {
|
||||
contextDataKeys: Object.keys(contextData),
|
||||
});
|
||||
|
||||
// 플로우 실행
|
||||
const result = await NodeFlowExecutionService.executeFlow(
|
||||
parseInt(flowId, 10),
|
||||
contextData
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: result.success,
|
||||
message: result.message,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("플로우 실행 실패:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "플로우 실행 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -21,6 +21,8 @@ import {
|
|||
testConditionalConnection,
|
||||
executeConditionalActions,
|
||||
} from "../controllers/conditionalConnectionController";
|
||||
import nodeFlowsRouter from "./dataflow/node-flows";
|
||||
import nodeExternalConnectionsRouter from "./dataflow/node-external-connections";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
|
|
@ -146,4 +148,16 @@ router.post("/diagrams/:diagramId/test-conditions", testConditionalConnection);
|
|||
*/
|
||||
router.post("/diagrams/:diagramId/execute-actions", executeConditionalActions);
|
||||
|
||||
/**
|
||||
* 노드 기반 플로우 관리
|
||||
* /api/dataflow/node-flows/*
|
||||
*/
|
||||
router.use("/node-flows", nodeFlowsRouter);
|
||||
|
||||
/**
|
||||
* 노드 플로우용 외부 DB 커넥션 관리
|
||||
* /api/dataflow/node-external-connections/*
|
||||
*/
|
||||
router.use("/node-external-connections", nodeExternalConnectionsRouter);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -895,13 +895,18 @@ export class BatchExternalDbService {
|
|||
);
|
||||
}
|
||||
|
||||
// 데이터 조회
|
||||
const result = await connector.executeQuery(finalEndpoint, method);
|
||||
// 데이터 조회 (REST API는 executeRequest 사용)
|
||||
let result;
|
||||
if ((connector as any).executeRequest) {
|
||||
result = await (connector as any).executeRequest(finalEndpoint, method);
|
||||
} else {
|
||||
result = await connector.executeQuery(finalEndpoint);
|
||||
}
|
||||
let data = result.rows;
|
||||
|
||||
// 컬럼 필터링 (지정된 컬럼만 추출)
|
||||
if (columns && columns.length > 0) {
|
||||
data = data.map((row) => {
|
||||
data = data.map((row: any) => {
|
||||
const filteredRow: any = {};
|
||||
columns.forEach((col) => {
|
||||
if (row.hasOwnProperty(col)) {
|
||||
|
|
@ -1039,7 +1044,16 @@ export class BatchExternalDbService {
|
|||
);
|
||||
console.log(`[BatchExternalDbService] 전송할 데이터:`, requestData);
|
||||
|
||||
await connector.executeQuery(finalEndpoint, method, requestData);
|
||||
// REST API는 executeRequest 사용
|
||||
if ((connector as any).executeRequest) {
|
||||
await (connector as any).executeRequest(
|
||||
finalEndpoint,
|
||||
method,
|
||||
requestData
|
||||
);
|
||||
} else {
|
||||
await connector.executeQuery(finalEndpoint);
|
||||
}
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
console.error(`REST API 레코드 전송 실패:`, error);
|
||||
|
|
@ -1104,7 +1118,12 @@ export class BatchExternalDbService {
|
|||
);
|
||||
console.log(`[BatchExternalDbService] 전송할 데이터:`, record);
|
||||
|
||||
await connector.executeQuery(endpoint, method, record);
|
||||
// REST API는 executeRequest 사용
|
||||
if ((connector as any).executeRequest) {
|
||||
await (connector as any).executeRequest(endpoint, method, record);
|
||||
} else {
|
||||
await connector.executeQuery(endpoint);
|
||||
}
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
console.error(`REST API 레코드 전송 실패:`, error);
|
||||
|
|
|
|||
|
|
@ -1205,4 +1205,157 @@ export class ExternalDbConnectionService {
|
|||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 외부 DB 연결의 테이블 목록 조회
|
||||
*/
|
||||
static async getTablesFromConnection(
|
||||
connectionId: number
|
||||
): Promise<ApiResponse<TableInfo[]>> {
|
||||
try {
|
||||
// 연결 정보 조회
|
||||
const connection = await queryOne<any>(
|
||||
`SELECT * FROM external_db_connections WHERE id = $1`,
|
||||
[connectionId]
|
||||
);
|
||||
|
||||
if (!connection) {
|
||||
return {
|
||||
success: false,
|
||||
message: `연결 ID ${connectionId}를 찾을 수 없습니다.`,
|
||||
};
|
||||
}
|
||||
|
||||
// 비밀번호 복호화
|
||||
const password = connection.password
|
||||
? PasswordEncryption.decrypt(connection.password)
|
||||
: "";
|
||||
|
||||
// 연결 설정 준비
|
||||
const config = {
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
database: connection.database_name,
|
||||
user: connection.username,
|
||||
password: password,
|
||||
connectionTimeoutMillis:
|
||||
connection.connection_timeout != null
|
||||
? connection.connection_timeout * 1000
|
||||
: undefined,
|
||||
queryTimeoutMillis:
|
||||
connection.query_timeout != null
|
||||
? connection.query_timeout * 1000
|
||||
: undefined,
|
||||
ssl:
|
||||
connection.ssl_enabled === "Y"
|
||||
? { rejectUnauthorized: false }
|
||||
: false,
|
||||
};
|
||||
|
||||
// 커넥터 생성
|
||||
const connector = await DatabaseConnectorFactory.createConnector(
|
||||
connection.db_type,
|
||||
config,
|
||||
connectionId
|
||||
);
|
||||
|
||||
try {
|
||||
const tables = await connector.getTables();
|
||||
return {
|
||||
success: true,
|
||||
data: tables,
|
||||
message: `${tables.length}개의 테이블을 조회했습니다.`,
|
||||
};
|
||||
} finally {
|
||||
await DatabaseConnectorFactory.closeConnector(
|
||||
connectionId,
|
||||
connection.db_type
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("테이블 목록 조회 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "테이블 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 외부 DB 테이블의 컬럼 목록 조회
|
||||
*/
|
||||
static async getColumnsFromConnection(
|
||||
connectionId: number,
|
||||
tableName: string
|
||||
): Promise<ApiResponse<any[]>> {
|
||||
try {
|
||||
// 연결 정보 조회
|
||||
const connection = await queryOne<any>(
|
||||
`SELECT * FROM external_db_connections WHERE id = $1`,
|
||||
[connectionId]
|
||||
);
|
||||
|
||||
if (!connection) {
|
||||
return {
|
||||
success: false,
|
||||
message: `연결 ID ${connectionId}를 찾을 수 없습니다.`,
|
||||
};
|
||||
}
|
||||
|
||||
// 비밀번호 복호화
|
||||
const password = connection.password
|
||||
? PasswordEncryption.decrypt(connection.password)
|
||||
: "";
|
||||
|
||||
// 연결 설정 준비
|
||||
const config = {
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
database: connection.database_name,
|
||||
user: connection.username,
|
||||
password: password,
|
||||
connectionTimeoutMillis:
|
||||
connection.connection_timeout != null
|
||||
? connection.connection_timeout * 1000
|
||||
: undefined,
|
||||
queryTimeoutMillis:
|
||||
connection.query_timeout != null
|
||||
? connection.query_timeout * 1000
|
||||
: undefined,
|
||||
ssl:
|
||||
connection.ssl_enabled === "Y"
|
||||
? { rejectUnauthorized: false }
|
||||
: false,
|
||||
};
|
||||
|
||||
// 커넥터 생성
|
||||
const connector = await DatabaseConnectorFactory.createConnector(
|
||||
connection.db_type,
|
||||
config,
|
||||
connectionId
|
||||
);
|
||||
|
||||
try {
|
||||
const columns = await connector.getColumns(tableName);
|
||||
return {
|
||||
success: true,
|
||||
data: columns,
|
||||
message: `${columns.length}개의 컬럼을 조회했습니다.`,
|
||||
};
|
||||
} finally {
|
||||
await DatabaseConnectorFactory.closeConnector(
|
||||
connectionId,
|
||||
connection.db_type
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("컬럼 목록 조회 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "컬럼 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,332 @@
|
|||
# 액션 노드 타겟 선택 시스템 개선 계획
|
||||
|
||||
## 📋 현재 문제점
|
||||
|
||||
### 1. 타겟 타입 구분 부재
|
||||
- INSERT/UPDATE/DELETE/UPSERT 액션 노드가 타겟 테이블만 선택 가능
|
||||
- 내부 DB인지, 외부 DB인지, REST API인지 구분 없음
|
||||
- 실행 시 항상 내부 DB로만 동작
|
||||
|
||||
### 2. 외부 시스템 연동 불가
|
||||
- 외부 DB에 데이터 저장 불가
|
||||
- 외부 REST API 호출 불가
|
||||
- 하이브리드 플로우 구성 불가 (내부 → 외부 데이터 전송)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 개선 목표
|
||||
|
||||
액션 노드에서 다음 3가지 타겟 타입을 선택할 수 있도록 개선:
|
||||
|
||||
### 1. 내부 데이터베이스 (Internal DB)
|
||||
- 현재 시스템의 PostgreSQL
|
||||
- 기존 동작 유지
|
||||
|
||||
### 2. 외부 데이터베이스 (External DB)
|
||||
- 외부 커넥션 관리에서 설정한 DB
|
||||
- PostgreSQL, MySQL, Oracle, MSSQL, MariaDB 지원
|
||||
|
||||
### 3. REST API
|
||||
- 외부 REST API 호출
|
||||
- HTTP 메서드: POST, PUT, PATCH, DELETE
|
||||
- 인증: None, Basic, Bearer Token, API Key
|
||||
|
||||
---
|
||||
|
||||
## 📐 타입 정의 확장
|
||||
|
||||
### TargetType 추가
|
||||
```typescript
|
||||
export type TargetType = "internal" | "external" | "api";
|
||||
|
||||
export interface BaseActionNodeData {
|
||||
displayName: string;
|
||||
targetType: TargetType; // 🔥 새로 추가
|
||||
|
||||
// targetType === "internal"
|
||||
targetTable?: string;
|
||||
targetTableLabel?: string;
|
||||
|
||||
// targetType === "external"
|
||||
externalConnectionId?: number;
|
||||
externalConnectionName?: string;
|
||||
externalDbType?: string;
|
||||
externalTargetTable?: string;
|
||||
externalTargetSchema?: string;
|
||||
|
||||
// targetType === "api"
|
||||
apiEndpoint?: string;
|
||||
apiMethod?: "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
apiAuthType?: "none" | "basic" | "bearer" | "apikey";
|
||||
apiAuthConfig?: {
|
||||
username?: string;
|
||||
password?: string;
|
||||
token?: string;
|
||||
apiKey?: string;
|
||||
apiKeyHeader?: string;
|
||||
};
|
||||
apiHeaders?: Record<string, string>;
|
||||
apiBodyTemplate?: string; // JSON 템플릿
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 UI 설계
|
||||
|
||||
### 1. 타겟 타입 선택 (공통)
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 타겟 타입 │
|
||||
│ ○ 내부 데이터베이스 (기본) │
|
||||
│ ○ 외부 데이터베이스 │
|
||||
│ ○ REST API │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2-A. 내부 DB 선택 시 (기존 UI 유지)
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 테이블 선택: [검색 가능 Combobox] │
|
||||
│ 필드 매핑: │
|
||||
│ • source_field → target_field │
|
||||
│ • ... │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2-B. 외부 DB 선택 시
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 외부 커넥션: [🐘 PostgreSQL - 운영DB]│
|
||||
│ 스키마: [public ▼] │
|
||||
│ 테이블: [users ▼] │
|
||||
│ 필드 매핑: │
|
||||
│ • source_field → target_field │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2-C. REST API 선택 시
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ API 엔드포인트: │
|
||||
│ [https://api.example.com/users] │
|
||||
│ │
|
||||
│ HTTP 메서드: [POST ▼] │
|
||||
│ │
|
||||
│ 인증 타입: [Bearer Token ▼] │
|
||||
│ Token: [••••••••••••••] │
|
||||
│ │
|
||||
│ 헤더 (선택): │
|
||||
│ Content-Type: application/json │
|
||||
│ + 헤더 추가 │
|
||||
│ │
|
||||
│ 바디 템플릿: │
|
||||
│ { │
|
||||
│ "name": "{{source.name}}", │
|
||||
│ "email": "{{source.email}}" │
|
||||
│ } │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 구현 단계
|
||||
|
||||
### Phase 1: 타입 정의 및 기본 UI (1-2시간)
|
||||
- [ ] `types/node-editor.ts`에 `TargetType` 추가
|
||||
- [ ] `InsertActionNodeData` 등 인터페이스 확장
|
||||
- [ ] 속성 패널에 타겟 타입 선택 라디오 버튼 추가
|
||||
|
||||
### Phase 2: 내부 DB 타입 (기존 유지)
|
||||
- [ ] `targetType === "internal"` 처리
|
||||
- [ ] 기존 로직 그대로 유지
|
||||
- [ ] 기본값으로 설정
|
||||
|
||||
### Phase 3: 외부 DB 타입 (2-3시간)
|
||||
- [ ] 외부 커넥션 선택 UI
|
||||
- [ ] 외부 테이블/컬럼 로드 (기존 API 재사용)
|
||||
- [ ] 백엔드: `nodeFlowExecutionService.ts`에 외부 DB 실행 로직 추가
|
||||
- [ ] `DatabaseConnectorFactory` 활용
|
||||
|
||||
### Phase 4: REST API 타입 (3-4시간)
|
||||
- [ ] API 엔드포인트 설정 UI
|
||||
- [ ] HTTP 메서드 선택
|
||||
- [ ] 인증 타입별 설정 UI
|
||||
- [ ] 바디 템플릿 에디터 (변수 치환 지원)
|
||||
- [ ] 백엔드: Axios를 사용한 API 호출 로직
|
||||
- [ ] 응답 처리 및 에러 핸들링
|
||||
|
||||
### Phase 5: 노드 시각화 개선 (1시간)
|
||||
- [ ] 노드에 타겟 타입 아이콘 표시
|
||||
- 내부 DB: 💾
|
||||
- 외부 DB: 🔌 + DB 타입 아이콘
|
||||
- REST API: 🌐
|
||||
- [ ] 노드 색상 구분
|
||||
|
||||
### Phase 6: 검증 및 테스트 (2시간)
|
||||
- [ ] 타겟 타입별 필수 값 검증
|
||||
- [ ] 연결 규칙 업데이트
|
||||
- [ ] 통합 테스트
|
||||
|
||||
---
|
||||
|
||||
## 🔍 백엔드 실행 로직
|
||||
|
||||
### nodeFlowExecutionService.ts
|
||||
```typescript
|
||||
private static async executeInsertAction(
|
||||
node: FlowNode,
|
||||
inputData: any[],
|
||||
context: ExecutionContext
|
||||
): Promise<any[]> {
|
||||
const { targetType } = node.data;
|
||||
|
||||
switch (targetType) {
|
||||
case "internal":
|
||||
return this.executeInternalInsert(node, inputData);
|
||||
|
||||
case "external":
|
||||
return this.executeExternalInsert(node, inputData);
|
||||
|
||||
case "api":
|
||||
return this.executeApiInsert(node, inputData);
|
||||
|
||||
default:
|
||||
throw new Error(`지원하지 않는 타겟 타입: ${targetType}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 외부 DB INSERT
|
||||
private static async executeExternalInsert(
|
||||
node: FlowNode,
|
||||
inputData: any[]
|
||||
): Promise<any[]> {
|
||||
const { externalConnectionId, externalTargetTable, fieldMappings } = node.data;
|
||||
|
||||
const connector = await DatabaseConnectorFactory.getConnector(
|
||||
externalConnectionId!,
|
||||
node.data.externalDbType!
|
||||
);
|
||||
|
||||
const results = [];
|
||||
for (const row of inputData) {
|
||||
const values = fieldMappings.map(m => row[m.sourceField]);
|
||||
const columns = fieldMappings.map(m => m.targetField);
|
||||
|
||||
const result = await connector.executeQuery(
|
||||
`INSERT INTO ${externalTargetTable} (${columns.join(", ")}) VALUES (${...})`,
|
||||
values
|
||||
);
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
await connector.disconnect();
|
||||
return results;
|
||||
}
|
||||
|
||||
// 🔥 REST API INSERT (POST)
|
||||
private static async executeApiInsert(
|
||||
node: FlowNode,
|
||||
inputData: any[]
|
||||
): Promise<any[]> {
|
||||
const {
|
||||
apiEndpoint,
|
||||
apiMethod,
|
||||
apiAuthType,
|
||||
apiAuthConfig,
|
||||
apiHeaders,
|
||||
apiBodyTemplate
|
||||
} = node.data;
|
||||
|
||||
const axios = require("axios");
|
||||
const headers = { ...apiHeaders };
|
||||
|
||||
// 인증 헤더 추가
|
||||
if (apiAuthType === "bearer" && apiAuthConfig?.token) {
|
||||
headers["Authorization"] = `Bearer ${apiAuthConfig.token}`;
|
||||
} else if (apiAuthType === "apikey" && apiAuthConfig?.apiKey) {
|
||||
headers[apiAuthConfig.apiKeyHeader || "X-API-Key"] = apiAuthConfig.apiKey;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const row of inputData) {
|
||||
// 템플릿 변수 치환
|
||||
const body = this.replaceTemplateVariables(apiBodyTemplate, row);
|
||||
|
||||
const response = await axios({
|
||||
method: apiMethod || "POST",
|
||||
url: apiEndpoint,
|
||||
headers,
|
||||
data: JSON.parse(body),
|
||||
});
|
||||
|
||||
results.push(response.data);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 우선순위
|
||||
|
||||
### High Priority
|
||||
1. **Phase 1**: 타입 정의 및 기본 UI
|
||||
2. **Phase 2**: 내부 DB 타입 (기존 유지)
|
||||
3. **Phase 3**: 외부 DB 타입
|
||||
|
||||
### Medium Priority
|
||||
4. **Phase 4**: REST API 타입
|
||||
5. **Phase 5**: 노드 시각화
|
||||
|
||||
### Low Priority
|
||||
6. **Phase 6**: 고급 기능 (재시도, 배치 처리 등)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 예상 효과
|
||||
|
||||
### 1. 유연성 증가
|
||||
- 다양한 시스템 간 데이터 연동 가능
|
||||
- 하이브리드 플로우 구성 (내부 → 외부 → API)
|
||||
|
||||
### 2. 사용 사례 확장
|
||||
```
|
||||
[사례 1] 내부 DB → 외부 DB 동기화
|
||||
TableSource(내부)
|
||||
→ DataTransform
|
||||
→ INSERT(외부 DB)
|
||||
|
||||
[사례 2] 내부 DB → REST API 전송
|
||||
TableSource(내부)
|
||||
→ DataTransform
|
||||
→ INSERT(REST API)
|
||||
|
||||
[사례 3] 복합 플로우
|
||||
TableSource(내부)
|
||||
→ INSERT(외부 DB)
|
||||
→ INSERT(REST API - 알림)
|
||||
```
|
||||
|
||||
### 3. 기존 기능과의 호환
|
||||
- 기본값: `targetType = "internal"`
|
||||
- 기존 플로우 마이그레이션 불필요
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 주의사항
|
||||
|
||||
### 1. 보안
|
||||
- API 인증 정보 암호화 저장
|
||||
- 민감 데이터 로깅 방지
|
||||
|
||||
### 2. 에러 핸들링
|
||||
- 외부 시스템 타임아웃 처리
|
||||
- 재시도 로직 (선택적)
|
||||
- 부분 실패 처리 (이미 구현됨)
|
||||
|
||||
### 3. 성능
|
||||
- 외부 DB 연결 풀 관리 (이미 구현됨)
|
||||
- REST API Rate Limiting 고려
|
||||
|
||||
|
|
@ -0,0 +1,481 @@
|
|||
# 노드 구조 개선안 - FROM/TO 테이블 명확화
|
||||
|
||||
**작성일**: 2025-01-02
|
||||
**버전**: 1.0
|
||||
**상태**: 🤔 검토 중
|
||||
|
||||
---
|
||||
|
||||
## 📋 문제 인식
|
||||
|
||||
### 현재 설계의 한계
|
||||
|
||||
```
|
||||
현재 플로우:
|
||||
TableSource(user_info) → FieldMapping → InsertAction(targetTable: "orders")
|
||||
```
|
||||
|
||||
**문제점**:
|
||||
|
||||
1. 타겟 테이블(orders)이 노드로 표현되지 않음
|
||||
2. InsertAction의 속성으로만 존재 → 시각적으로 불명확
|
||||
3. FROM(user_info)과 TO(orders)의 관계가 직관적이지 않음
|
||||
4. 타겟 테이블의 스키마 정보를 참조하기 어려움
|
||||
|
||||
---
|
||||
|
||||
## 💡 개선 방안
|
||||
|
||||
### 옵션 1: TableTarget 노드 추가 (권장 ⭐)
|
||||
|
||||
**새로운 플로우**:
|
||||
|
||||
```
|
||||
TableSource(user_info) → FieldMapping → TableTarget(orders) → InsertAction
|
||||
```
|
||||
|
||||
**노드 추가**:
|
||||
|
||||
- `TableTarget` - 타겟 테이블을 명시적으로 표현
|
||||
|
||||
**장점**:
|
||||
|
||||
- ✅ FROM/TO가 시각적으로 명확
|
||||
- ✅ 타겟 테이블 스키마를 미리 로드 가능
|
||||
- ✅ FieldMapping에서 타겟 필드 자동 완성 가능
|
||||
- ✅ 데이터 흐름이 직관적
|
||||
|
||||
**단점**:
|
||||
|
||||
- ⚠️ 노드 개수 증가 (복잡도 증가)
|
||||
- ⚠️ 기존 설계와 호환성 문제
|
||||
|
||||
---
|
||||
|
||||
### 옵션 2: Action 노드에 Target 속성 유지 (현재 방식)
|
||||
|
||||
**현재 플로우 유지**:
|
||||
|
||||
```
|
||||
TableSource(user_info) → FieldMapping → InsertAction(targetTable: "orders")
|
||||
```
|
||||
|
||||
**개선 방법**:
|
||||
|
||||
- Action 노드에서 타겟 테이블을 더 명확히 표시
|
||||
- 노드 UI에 타겟 테이블명을 크게 표시
|
||||
- Properties Panel에서 타겟 테이블 선택 시 스키마 정보 제공
|
||||
|
||||
**장점**:
|
||||
|
||||
- ✅ 기존 설계 유지 (구현 완료된 상태)
|
||||
- ✅ 노드 개수가 적음 (간결함)
|
||||
- ✅ 빠른 플로우 구성 가능
|
||||
|
||||
**단점**:
|
||||
|
||||
- ❌ 시각적으로 FROM/TO 관계가 불명확
|
||||
- ❌ FieldMapping 단계에서 타겟 필드 정보 접근이 어려움
|
||||
|
||||
---
|
||||
|
||||
### 옵션 3: 가상 노드 자동 표시 (신규 제안 ⭐⭐)
|
||||
|
||||
**개념**:
|
||||
Action 노드에서 targetTable 속성을 설정하면, **시각적으로만** 타겟 테이블 노드를 자동 생성
|
||||
|
||||
**실제 플로우 (저장되는 구조)**:
|
||||
|
||||
```
|
||||
TableSource(user_info) → FieldMapping → InsertAction(targetTable: "orders")
|
||||
```
|
||||
|
||||
**시각적 표시 (화면에 보이는 모습)**:
|
||||
|
||||
```
|
||||
TableSource(user_info)
|
||||
→ FieldMapping
|
||||
→ InsertAction(targetTable: "orders")
|
||||
→ 👻 orders (가상 노드, 자동 생성)
|
||||
```
|
||||
|
||||
**특징**:
|
||||
|
||||
- 가상 노드는 선택/이동/삭제 불가능
|
||||
- 반투명하게 표시하여 가상임을 명확히 표시
|
||||
- Action 노드의 targetTable 속성 변경 시 자동 업데이트
|
||||
- 저장 시에는 가상 노드 제외
|
||||
|
||||
**장점**:
|
||||
|
||||
- ✅ 사용자는 기존대로 사용 (노드 추가 불필요)
|
||||
- ✅ 시각적으로 FROM/TO 관계 명확
|
||||
- ✅ 기존 설계 100% 유지
|
||||
- ✅ 구현 복잡도 낮음
|
||||
- ✅ 기존 플로우와 완벽 호환
|
||||
|
||||
**단점**:
|
||||
|
||||
- ⚠️ 가상 노드의 상호작용 제한 필요
|
||||
- ⚠️ "왜 클릭이 안 되지?" 혼란 가능성
|
||||
- ⚠️ 가상 노드 렌더링 로직 추가
|
||||
|
||||
---
|
||||
|
||||
### 옵션 4: 하이브리드 방식
|
||||
|
||||
**조건부 사용**:
|
||||
|
||||
```
|
||||
// 단순 케이스: TableTarget 생략
|
||||
TableSource → FieldMapping → InsertAction(targetTable 지정)
|
||||
|
||||
// 복잡한 케이스: TableTarget 사용
|
||||
TableSource → FieldMapping → TableTarget → InsertAction
|
||||
```
|
||||
|
||||
**장점**:
|
||||
|
||||
- ✅ 유연성 제공
|
||||
- ✅ 단순/복잡한 케이스 모두 대응
|
||||
|
||||
**단점**:
|
||||
|
||||
- ❌ 사용자 혼란 가능성
|
||||
- ❌ 검증 로직 복잡
|
||||
|
||||
---
|
||||
|
||||
## 🎯 권장 방안 비교
|
||||
|
||||
### 옵션 재평가
|
||||
|
||||
| 항목 | 옵션 1<br/>(TableTarget) | 옵션 2<br/>(현재 방식) | 옵션 3<br/>(가상 노드) ⭐ |
|
||||
| ----------------- | ------------------------ | ---------------------- | ------------------------- |
|
||||
| **시각적 명확성** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||
| **구현 복잡도** | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐ |
|
||||
| **사용자 편의성** | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||
| **기존 호환성** | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||
| **자동 완성** | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐ |
|
||||
| **유지보수성** | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ |
|
||||
| **학습 곡선** | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||
|
||||
### 최종 권장: **옵션 3 (가상 노드 자동 표시)** ⭐⭐
|
||||
|
||||
**선택 이유**:
|
||||
|
||||
1. ✅ **최고의 시각적 명확성** - FROM/TO 관계가 한눈에 보임
|
||||
2. ✅ **사용자 편의성** - 기존 방식 그대로, 노드 추가 불필요
|
||||
3. ✅ **완벽한 호환성** - 기존 플로우 수정 불필요
|
||||
4. ✅ **낮은 학습 곡선** - 새로운 노드 타입 학습 불필요
|
||||
5. ✅ **적절한 구현 복잡도** - React Flow의 커스텀 렌더링 활용
|
||||
|
||||
**구현 방식**:
|
||||
|
||||
```typescript
|
||||
// Action 노드가 있으면 자동으로 가상 타겟 노드 생성
|
||||
function generateVirtualTargetNodes(nodes: FlowNode[]): VirtualNode[] {
|
||||
return nodes
|
||||
.filter((node) => isActionNode(node.type) && node.data.targetTable)
|
||||
.map((actionNode) => ({
|
||||
id: `virtual-target-${actionNode.id}`,
|
||||
type: "virtualTarget",
|
||||
position: {
|
||||
x: actionNode.position.x,
|
||||
y: actionNode.position.y + 150,
|
||||
},
|
||||
data: {
|
||||
tableName: actionNode.data.targetTable,
|
||||
sourceActionId: actionNode.id,
|
||||
isVirtual: true,
|
||||
},
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 대안: 옵션 1 (TableTarget 추가)
|
||||
|
||||
### 새로운 노드 타입 추가
|
||||
|
||||
#### TableTarget 노드
|
||||
|
||||
**타입**: `tableTarget`
|
||||
|
||||
**데이터 구조**:
|
||||
|
||||
```typescript
|
||||
interface TableTargetNodeData {
|
||||
tableName: string; // 타겟 테이블명
|
||||
schema?: string; // 스키마 (선택)
|
||||
columns?: Array<{
|
||||
// 타겟 컬럼 정보
|
||||
name: string;
|
||||
type: string;
|
||||
nullable: boolean;
|
||||
primaryKey: boolean;
|
||||
}>;
|
||||
displayName?: string;
|
||||
}
|
||||
```
|
||||
|
||||
**특징**:
|
||||
|
||||
- 입력: FieldMapping, DataTransform 등에서 받음
|
||||
- 출력: Action 노드로 전달
|
||||
- 타겟 테이블 스키마를 미리 로드하여 검증 가능
|
||||
|
||||
**시각적 표현**:
|
||||
|
||||
```
|
||||
┌────────────────────┐
|
||||
│ 📊 Table Target │
|
||||
├────────────────────┤
|
||||
│ orders │
|
||||
│ schema: public │
|
||||
├────────────────────┤
|
||||
│ 컬럼: │
|
||||
│ • order_id (PK) │
|
||||
│ • customer_id │
|
||||
│ • order_date │
|
||||
│ • total_amount │
|
||||
└────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 개선된 연결 규칙
|
||||
|
||||
#### TableTarget 추가 시 연결 규칙
|
||||
|
||||
**허용되는 연결**:
|
||||
|
||||
```
|
||||
✅ FieldMapping → TableTarget
|
||||
✅ DataTransform → TableTarget
|
||||
✅ Condition → TableTarget
|
||||
✅ TableTarget → InsertAction
|
||||
✅ TableTarget → UpdateAction
|
||||
✅ TableTarget → UpsertAction
|
||||
```
|
||||
|
||||
**금지되는 연결**:
|
||||
|
||||
```
|
||||
❌ TableSource → TableTarget (직접 연결 불가)
|
||||
❌ TableTarget → DeleteAction (DELETE는 타겟 불필요)
|
||||
❌ TableTarget → TableTarget
|
||||
```
|
||||
|
||||
**새로운 검증 규칙**:
|
||||
|
||||
1. Action 노드는 TableTarget 또는 targetTable 속성 중 하나 필수
|
||||
2. TableTarget이 있으면 Action의 targetTable 속성 무시
|
||||
3. FieldMapping 이후에 TableTarget이 오면 자동 필드 매칭 제안
|
||||
|
||||
---
|
||||
|
||||
### 실제 사용 예시
|
||||
|
||||
#### 예시 1: 단순 데이터 복사
|
||||
|
||||
**기존 방식**:
|
||||
|
||||
```
|
||||
TableSource(user_info)
|
||||
→ FieldMapping(user_id → customer_id, user_name → name)
|
||||
→ InsertAction(targetTable: "customers")
|
||||
```
|
||||
|
||||
**개선 방식**:
|
||||
|
||||
```
|
||||
TableSource(user_info)
|
||||
→ FieldMapping(user_id → customer_id)
|
||||
→ TableTarget(customers)
|
||||
→ InsertAction
|
||||
```
|
||||
|
||||
**장점**:
|
||||
|
||||
- customers 테이블 스키마를 FieldMapping에서 참조 가능
|
||||
- 필드 자동 완성 제공
|
||||
|
||||
---
|
||||
|
||||
#### 예시 2: 조건부 데이터 처리
|
||||
|
||||
**개선 방식**:
|
||||
|
||||
```
|
||||
TableSource(user_info)
|
||||
→ Condition(age >= 18)
|
||||
├─ TRUE → TableTarget(adult_users) → InsertAction
|
||||
└─ FALSE → TableTarget(minor_users) → InsertAction
|
||||
```
|
||||
|
||||
**장점**:
|
||||
|
||||
- TRUE/FALSE 분기마다 다른 타겟 테이블 명확히 표시
|
||||
|
||||
---
|
||||
|
||||
#### 예시 3: 멀티 소스 + 단일 타겟
|
||||
|
||||
**개선 방식**:
|
||||
|
||||
```
|
||||
┌─ TableSource(users) ────┐
|
||||
│ ↓
|
||||
└─ ExternalDB(orders) ─→ FieldMapping → TableTarget(user_orders) → InsertAction
|
||||
```
|
||||
|
||||
**장점**:
|
||||
|
||||
- 여러 소스에서 데이터를 받아 하나의 타겟으로 통합
|
||||
- 타겟 테이블이 시각적으로 명확
|
||||
|
||||
---
|
||||
|
||||
## 🔧 구현 계획
|
||||
|
||||
### Phase 1: TableTarget 노드 구현
|
||||
|
||||
**작업 항목**:
|
||||
|
||||
1. ✅ `TableTargetNodeData` 인터페이스 정의
|
||||
2. ✅ `TableTargetNode.tsx` 컴포넌트 생성
|
||||
3. ✅ `TableTargetProperties.tsx` 속성 패널 생성
|
||||
4. ✅ Node Palette에 추가
|
||||
5. ✅ FlowEditor에 등록
|
||||
|
||||
**예상 시간**: 2시간
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 연결 규칙 업데이트
|
||||
|
||||
**작업 항목**:
|
||||
|
||||
1. ✅ `validateConnection`에 TableTarget 규칙 추가
|
||||
2. ✅ Action 노드가 TableTarget 입력을 받도록 수정
|
||||
3. ✅ 검증 로직 업데이트
|
||||
|
||||
**예상 시간**: 1시간
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: 자동 필드 매핑 개선
|
||||
|
||||
**작업 항목**:
|
||||
|
||||
1. ✅ TableTarget이 연결되면 타겟 스키마 자동 로드
|
||||
2. ✅ FieldMapping에서 타겟 필드 자동 완성 제공
|
||||
3. ✅ 필드 타입 호환성 검증
|
||||
|
||||
**예상 시간**: 2시간
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: 기존 플로우 마이그레이션
|
||||
|
||||
**작업 항목**:
|
||||
|
||||
1. ✅ 기존 InsertAction의 targetTable을 TableTarget으로 변환
|
||||
2. ✅ 자동 마이그레이션 스크립트 작성
|
||||
3. ✅ 호환성 유지 모드 제공
|
||||
|
||||
**예상 시간**: 2시간
|
||||
|
||||
---
|
||||
|
||||
## 🤔 고려사항
|
||||
|
||||
### 1. 기존 플로우와의 호환성
|
||||
|
||||
**문제**: 이미 저장된 플로우는 TableTarget 없이 구성됨
|
||||
|
||||
**해결 방안**:
|
||||
|
||||
- **옵션 A**: 자동 마이그레이션
|
||||
|
||||
- 플로우 로드 시 InsertAction의 targetTable을 TableTarget 노드로 변환
|
||||
- 기존 데이터는 보존
|
||||
|
||||
- **옵션 B**: 호환성 모드
|
||||
- TableTarget 없이도 동작하도록 유지
|
||||
- 새 플로우만 TableTarget 사용 권장
|
||||
|
||||
**권장**: 옵션 B (호환성 모드)
|
||||
|
||||
---
|
||||
|
||||
### 2. 사용자 경험
|
||||
|
||||
**우려**: 노드가 하나 더 추가되어 복잡해짐
|
||||
|
||||
**완화 방안**:
|
||||
|
||||
- 템플릿 제공: "TableSource → FieldMapping → TableTarget → InsertAction" 세트를 템플릿으로 제공
|
||||
- 자동 생성: InsertAction 생성 시 TableTarget 자동 생성 옵션
|
||||
- 가이드: 처음 사용자를 위한 튜토리얼
|
||||
|
||||
---
|
||||
|
||||
### 3. 성능
|
||||
|
||||
**우려**: TableTarget이 스키마를 로드하면 성능 저하 가능성
|
||||
|
||||
**완화 방안**:
|
||||
|
||||
- 캐싱: 한 번 로드한 스키마는 캐싱
|
||||
- 지연 로딩: 필요할 때만 스키마 로드
|
||||
- 백그라운드 로딩: 비동기로 스키마 로드
|
||||
|
||||
---
|
||||
|
||||
## 📊 비교 분석
|
||||
|
||||
| 항목 | 옵션 1 (TableTarget) | 옵션 2 (현재 방식) |
|
||||
| ------------------- | -------------------- | ------------------ |
|
||||
| **시각적 명확성** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
|
||||
| **구현 복잡도** | ⭐⭐⭐⭐ | ⭐⭐ |
|
||||
| **사용자 학습곡선** | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||
| **자동 완성 지원** | ⭐⭐⭐⭐⭐ | ⭐⭐ |
|
||||
| **유지보수성** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
|
||||
| **기존 호환성** | ⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 결론
|
||||
|
||||
### 권장 사항: **옵션 1 (TableTarget 추가)**
|
||||
|
||||
**이유**:
|
||||
|
||||
1. ✅ 데이터 흐름이 시각적으로 명확
|
||||
2. ✅ 스키마 기반 자동 완성 가능
|
||||
3. ✅ 향후 확장성 우수
|
||||
4. ✅ 복잡한 데이터 흐름에서 특히 유용
|
||||
|
||||
**단계적 도입**:
|
||||
|
||||
- Phase 1: TableTarget 노드 추가 (선택 사항)
|
||||
- Phase 2: 기존 방식과 공존
|
||||
- Phase 3: 사용자 피드백 수집
|
||||
- Phase 4: 장기적으로 TableTarget 방식 권장
|
||||
|
||||
---
|
||||
|
||||
## 📝 다음 단계
|
||||
|
||||
1. **의사 결정**: 옵션 1 vs 옵션 2 선택
|
||||
2. **프로토타입**: TableTarget 노드 간단히 구현
|
||||
3. **테스트**: 실제 사용 시나리오로 검증
|
||||
4. **문서화**: 사용 가이드 작성
|
||||
5. **배포**: 단계적 릴리스
|
||||
|
||||
---
|
||||
|
||||
**피드백 환영**: 이 설계에 대한 의견을 주시면 개선하겠습니다! 💬
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,939 @@
|
|||
# 노드 시스템 - 버튼 통합 호환성 분석
|
||||
|
||||
**작성일**: 2025-01-02
|
||||
**버전**: 1.0
|
||||
**상태**: 🔍 분석 완료
|
||||
|
||||
---
|
||||
|
||||
## 📋 목차
|
||||
|
||||
1. [개요](#개요)
|
||||
2. [현재 시스템 분석](#현재-시스템-분석)
|
||||
3. [호환성 분석](#호환성-분석)
|
||||
4. [통합 전략](#통합-전략)
|
||||
5. [마이그레이션 계획](#마이그레이션-계획)
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
### 목적
|
||||
|
||||
화면관리의 버튼 컴포넌트에 할당된 기존 제어 시스템을 새로운 노드 기반 제어 시스템으로 전환하기 위한 호환성 분석
|
||||
|
||||
### 비교 대상
|
||||
|
||||
- **현재**: `relationshipId` 기반 제어 시스템
|
||||
- **신규**: `flowId` 기반 노드 제어 시스템
|
||||
|
||||
---
|
||||
|
||||
## 현재 시스템 분석
|
||||
|
||||
### 1. 데이터 구조
|
||||
|
||||
#### ButtonDataflowConfig
|
||||
|
||||
```typescript
|
||||
interface ButtonDataflowConfig {
|
||||
controlMode: "relationship" | "none";
|
||||
|
||||
relationshipConfig?: {
|
||||
relationshipId: string; // 🔑 핵심: 관계 ID
|
||||
relationshipName: string;
|
||||
executionTiming: "before" | "after" | "replace";
|
||||
contextData?: Record<string, any>;
|
||||
};
|
||||
|
||||
controlDataSource?: "form" | "table-selection" | "both";
|
||||
executionOptions?: ExecutionOptions;
|
||||
}
|
||||
```
|
||||
|
||||
#### 관계 데이터 구조
|
||||
|
||||
```typescript
|
||||
{
|
||||
relationshipId: "rel-123",
|
||||
conditions: [
|
||||
{
|
||||
field: "status",
|
||||
operator: "equals",
|
||||
value: "active"
|
||||
}
|
||||
],
|
||||
actionGroups: [
|
||||
{
|
||||
name: "메인 액션",
|
||||
actions: [
|
||||
{
|
||||
type: "database",
|
||||
operation: "INSERT",
|
||||
tableName: "users",
|
||||
fields: [...]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 실행 흐름
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 1. 버튼 클릭 │
|
||||
│ OptimizedButtonComponent.tsx │
|
||||
└─────────────┬───────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ 2. executeButtonAction() │
|
||||
│ ImprovedButtonActionExecutor.ts │
|
||||
│ - executionPlan 생성 │
|
||||
│ - before/after/replace 구분 │
|
||||
└─────────────┬───────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ 3. executeControls() │
|
||||
│ - relationshipId로 관계 조회 │
|
||||
│ - 조건 검증 │
|
||||
└─────────────┬───────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ 4. evaluateConditions() │
|
||||
│ - formData 검증 │
|
||||
│ - selectedRowsData 검증 │
|
||||
└─────────────┬───────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ 5. executeDataAction() │
|
||||
│ - INSERT/UPDATE/DELETE 실행 │
|
||||
│ - 순차적 액션 실행 │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 데이터 전달 방식
|
||||
|
||||
#### 입력 데이터
|
||||
|
||||
```typescript
|
||||
{
|
||||
formData: {
|
||||
name: "김철수",
|
||||
email: "test@example.com",
|
||||
status: "active"
|
||||
},
|
||||
selectedRowsData: [
|
||||
{ id: 1, name: "이영희" },
|
||||
{ id: 2, name: "박민수" }
|
||||
],
|
||||
context: {
|
||||
buttonId: "btn-1",
|
||||
screenId: 123,
|
||||
companyCode: "COMPANY_A",
|
||||
userId: "user-1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 액션 실행 시
|
||||
|
||||
```typescript
|
||||
// 각 액션에 전체 데이터 전달
|
||||
executeDataAction(action, {
|
||||
formData,
|
||||
selectedRowsData,
|
||||
context,
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 새로운 노드 시스템 분석
|
||||
|
||||
### 1. 데이터 구조
|
||||
|
||||
#### FlowData
|
||||
|
||||
```typescript
|
||||
interface FlowData {
|
||||
flowId: number;
|
||||
flowName: string;
|
||||
flowDescription: string;
|
||||
nodes: FlowNode[]; // 🔑 핵심: 노드 배열
|
||||
edges: FlowEdge[]; // 🔑 핵심: 연결 정보
|
||||
}
|
||||
```
|
||||
|
||||
#### 노드 예시
|
||||
|
||||
```typescript
|
||||
// 소스 노드
|
||||
{
|
||||
id: "source-1",
|
||||
type: "tableSource",
|
||||
data: {
|
||||
tableName: "users",
|
||||
schema: "public",
|
||||
outputFields: [...]
|
||||
}
|
||||
}
|
||||
|
||||
// 조건 노드
|
||||
{
|
||||
id: "condition-1",
|
||||
type: "condition",
|
||||
data: {
|
||||
conditions: [{
|
||||
field: "status",
|
||||
operator: "equals",
|
||||
value: "active"
|
||||
}],
|
||||
logic: "AND"
|
||||
}
|
||||
}
|
||||
|
||||
// 액션 노드
|
||||
{
|
||||
id: "insert-1",
|
||||
type: "insertAction",
|
||||
data: {
|
||||
targetTable: "users",
|
||||
fieldMappings: [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 연결 예시
|
||||
|
||||
```typescript
|
||||
// 엣지 (노드 간 연결)
|
||||
{
|
||||
id: "edge-1",
|
||||
source: "source-1",
|
||||
target: "condition-1"
|
||||
},
|
||||
{
|
||||
id: "edge-2",
|
||||
source: "condition-1",
|
||||
target: "insert-1",
|
||||
sourceHandle: "true" // TRUE 분기
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 실행 흐름
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 1. 버튼 클릭 │
|
||||
│ FlowEditor 또는 Button Component │
|
||||
└─────────────┬───────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ 2. executeFlow() │
|
||||
│ - flowId로 플로우 조회 │
|
||||
│ - nodes + edges 로드 │
|
||||
└─────────────┬───────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ 3. topologicalSort() │
|
||||
│ - 노드 의존성 분석 │
|
||||
│ - 실행 순서 결정 │
|
||||
│ Result: [["source"], ["insert", "update"]] │
|
||||
└─────────────┬───────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ 4. executeLevel() │
|
||||
│ - 같은 레벨 노드 병렬 실행 │
|
||||
│ - Promise.allSettled 사용 │
|
||||
└─────────────┬───────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ 5. executeNode() │
|
||||
│ - 부모 노드 상태 확인 │
|
||||
│ - 실패 시 스킵 │
|
||||
└─────────────┬───────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ 6. executeActionWithTransaction() │
|
||||
│ - 독립 트랜잭션 시작 │
|
||||
│ - 액션 실행 │
|
||||
│ - 성공 시 커밋, 실패 시 롤백 │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 데이터 전달 방식
|
||||
|
||||
#### ExecutionContext
|
||||
|
||||
```typescript
|
||||
{
|
||||
sourceData: [
|
||||
{ id: 1, name: "김철수", status: "active" },
|
||||
{ id: 2, name: "이영희", status: "inactive" }
|
||||
],
|
||||
nodeResults: Map<string, NodeResult> {
|
||||
"source-1" => { status: "success", data: [...] },
|
||||
"condition-1" => { status: "success", data: true },
|
||||
"insert-1" => { status: "success", data: { insertedCount: 1 } }
|
||||
},
|
||||
executionOrder: ["source-1", "condition-1", "insert-1"]
|
||||
}
|
||||
```
|
||||
|
||||
#### 노드 실행 시
|
||||
|
||||
```typescript
|
||||
// 부모 노드 결과 전달
|
||||
const inputData = prepareInputData(node, parents, context);
|
||||
|
||||
// 부모가 하나면 부모의 결과 데이터
|
||||
// 부모가 여러 개면 모든 부모의 데이터 병합
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 호환성 분석
|
||||
|
||||
### ✅ 호환 가능한 부분
|
||||
|
||||
#### 1. 조건 검증
|
||||
|
||||
**현재**:
|
||||
|
||||
```typescript
|
||||
{
|
||||
field: "status",
|
||||
operator: "equals",
|
||||
value: "active"
|
||||
}
|
||||
```
|
||||
|
||||
**신규**:
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: "condition",
|
||||
data: {
|
||||
conditions: [
|
||||
{
|
||||
field: "status",
|
||||
operator: "equals",
|
||||
value: "active"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**결론**: ✅ **조건 구조가 거의 동일** → 마이그레이션 쉬움
|
||||
|
||||
---
|
||||
|
||||
#### 2. 액션 실행
|
||||
|
||||
**현재**:
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: "database",
|
||||
operation: "INSERT",
|
||||
tableName: "users",
|
||||
fields: [
|
||||
{ name: "name", value: "김철수" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**신규**:
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: "insertAction",
|
||||
data: {
|
||||
targetTable: "users",
|
||||
fieldMappings: [
|
||||
{ sourceField: "name", targetField: "name" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**결론**: ✅ **액션 개념이 동일** → 필드명만 변환하면 됨
|
||||
|
||||
---
|
||||
|
||||
#### 3. 데이터 소스
|
||||
|
||||
**현재**:
|
||||
|
||||
```typescript
|
||||
controlDataSource: "form" | "table-selection" | "both";
|
||||
```
|
||||
|
||||
**신규**:
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: "tableSource", // 테이블 선택 데이터
|
||||
// 또는
|
||||
type: "manualInput", // 폼 데이터
|
||||
}
|
||||
```
|
||||
|
||||
**결론**: ✅ **소스 타입 매핑 가능**
|
||||
|
||||
---
|
||||
|
||||
### ⚠️ 차이점 및 주의사항
|
||||
|
||||
#### 1. 실행 타이밍
|
||||
|
||||
**현재**:
|
||||
|
||||
```typescript
|
||||
executionTiming: "before" | "after" | "replace";
|
||||
```
|
||||
|
||||
**신규**:
|
||||
|
||||
```
|
||||
노드 그래프 자체가 실행 순서를 정의
|
||||
타이밍은 노드 연결로 표현됨
|
||||
```
|
||||
|
||||
**문제점**:
|
||||
|
||||
- `before/after` 개념이 노드에 없음
|
||||
- 버튼의 기본 액션과 제어를 어떻게 조합할지?
|
||||
|
||||
**해결 방안**:
|
||||
|
||||
```
|
||||
Option A: 버튼 액션을 노드로 표현
|
||||
Button → [Before Nodes] → [Button Action Node] → [After Nodes]
|
||||
|
||||
Option B: 실행 시점 지정
|
||||
flowConfig: {
|
||||
flowId: 123,
|
||||
timing: "before" | "after" | "replace"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 2. ActionGroups vs 병렬 실행
|
||||
|
||||
**현재**:
|
||||
|
||||
```typescript
|
||||
actionGroups: [
|
||||
{
|
||||
name: "그룹1",
|
||||
actions: [action1, action2], // 순차 실행
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
**신규**:
|
||||
|
||||
```
|
||||
소스
|
||||
↓
|
||||
├─→ INSERT (병렬)
|
||||
├─→ UPDATE (병렬)
|
||||
└─→ DELETE (병렬)
|
||||
```
|
||||
|
||||
**문제점**:
|
||||
|
||||
- 현재는 "그룹 내 순차, 그룹 간 조건부"
|
||||
- 신규는 "레벨별 병렬, 연쇄 중단"
|
||||
|
||||
**해결 방안**:
|
||||
|
||||
```
|
||||
노드 연결로 순차/병렬 표현:
|
||||
|
||||
순차: INSERT → UPDATE → DELETE
|
||||
병렬: Source → INSERT
|
||||
→ UPDATE
|
||||
→ DELETE
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 3. 데이터 전달 방식
|
||||
|
||||
**현재**:
|
||||
|
||||
```typescript
|
||||
// 모든 액션에 동일한 데이터 전달
|
||||
executeDataAction(action, {
|
||||
formData,
|
||||
selectedRowsData,
|
||||
context,
|
||||
});
|
||||
```
|
||||
|
||||
**신규**:
|
||||
|
||||
```typescript
|
||||
// 부모 노드 결과를 자식에게 전달
|
||||
const inputData = parentResult.data || sourceData;
|
||||
```
|
||||
|
||||
**문제점**:
|
||||
|
||||
- 현재는 "원본 데이터 공유"
|
||||
- 신규는 "결과 데이터 체이닝"
|
||||
|
||||
**해결 방안**:
|
||||
|
||||
```typescript
|
||||
// 버튼 실행 시 초기 데이터 설정
|
||||
context.sourceData = {
|
||||
formData,
|
||||
selectedRowsData,
|
||||
};
|
||||
|
||||
// 각 노드는 필요에 따라 선택
|
||||
- formData 사용
|
||||
- 부모 결과 사용
|
||||
- 둘 다 사용
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 4. 컨텍스트 정보
|
||||
|
||||
**현재**:
|
||||
|
||||
```typescript
|
||||
{
|
||||
buttonId: "btn-1",
|
||||
screenId: 123,
|
||||
companyCode: "COMPANY_A",
|
||||
userId: "user-1"
|
||||
}
|
||||
```
|
||||
|
||||
**신규**:
|
||||
|
||||
```typescript
|
||||
// ExecutionContext에 추가 필요
|
||||
{
|
||||
sourceData: [...],
|
||||
nodeResults: Map(),
|
||||
// 🆕 추가 필요
|
||||
buttonContext?: {
|
||||
buttonId: string,
|
||||
screenId: number,
|
||||
companyCode: string,
|
||||
userId: string
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**결론**: ✅ **컨텍스트 확장 가능**
|
||||
|
||||
---
|
||||
|
||||
## 통합 전략
|
||||
|
||||
### 전략 1: 하이브리드 방식 (권장 ⭐⭐⭐)
|
||||
|
||||
#### 개념
|
||||
|
||||
버튼 설정에서 `relationshipId` 대신 `flowId`를 저장하고, 기존 타이밍 개념 유지
|
||||
|
||||
#### 버튼 설정
|
||||
|
||||
```typescript
|
||||
interface ButtonDataflowConfig {
|
||||
controlMode: "flow"; // 🆕 신규 모드
|
||||
|
||||
flowConfig?: {
|
||||
flowId: number; // 🔑 노드 플로우 ID
|
||||
flowName: string;
|
||||
executionTiming: "before" | "after" | "replace"; // 기존 유지
|
||||
contextData?: Record<string, any>;
|
||||
};
|
||||
|
||||
controlDataSource?: "form" | "table-selection" | "both";
|
||||
}
|
||||
```
|
||||
|
||||
#### 실행 로직
|
||||
|
||||
```typescript
|
||||
async function executeButtonWithFlow(
|
||||
buttonConfig: ButtonDataflowConfig,
|
||||
formData: Record<string, any>,
|
||||
context: ButtonExecutionContext
|
||||
) {
|
||||
const { flowConfig } = buttonConfig;
|
||||
|
||||
// 1. 플로우 조회
|
||||
const flow = await getNodeFlow(flowConfig.flowId);
|
||||
|
||||
// 2. 초기 데이터 준비
|
||||
const executionContext: ExecutionContext = {
|
||||
sourceData: prepareSourceData(formData, context),
|
||||
nodeResults: new Map(),
|
||||
executionOrder: [],
|
||||
buttonContext: {
|
||||
// 🆕 버튼 컨텍스트 추가
|
||||
buttonId: context.buttonId,
|
||||
screenId: context.screenId,
|
||||
companyCode: context.companyCode,
|
||||
userId: context.userId,
|
||||
},
|
||||
};
|
||||
|
||||
// 3. 타이밍에 따라 실행
|
||||
switch (flowConfig.executionTiming) {
|
||||
case "before":
|
||||
await executeFlow(flow, executionContext);
|
||||
await executeOriginalButtonAction(buttonConfig, context);
|
||||
break;
|
||||
|
||||
case "after":
|
||||
await executeOriginalButtonAction(buttonConfig, context);
|
||||
await executeFlow(flow, executionContext);
|
||||
break;
|
||||
|
||||
case "replace":
|
||||
await executeFlow(flow, executionContext);
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 소스 데이터 준비
|
||||
|
||||
```typescript
|
||||
function prepareSourceData(
|
||||
formData: Record<string, any>,
|
||||
context: ButtonExecutionContext
|
||||
): any[] {
|
||||
const { controlDataSource, selectedRowsData } = context;
|
||||
|
||||
switch (controlDataSource) {
|
||||
case "form":
|
||||
return [formData]; // 폼 데이터를 배열로
|
||||
|
||||
case "table-selection":
|
||||
return selectedRowsData || []; // 테이블 선택 데이터
|
||||
|
||||
case "both":
|
||||
return [
|
||||
{ source: "form", data: formData },
|
||||
{ source: "table", data: selectedRowsData },
|
||||
];
|
||||
|
||||
default:
|
||||
return [formData];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 전략 2: 완전 전환 방식
|
||||
|
||||
#### 개념
|
||||
|
||||
버튼 액션 자체를 노드로 표현 (버튼 = 플로우 트리거)
|
||||
|
||||
#### 플로우 구조
|
||||
|
||||
```
|
||||
ManualInput (formData)
|
||||
↓
|
||||
Condition (status == "active")
|
||||
↓
|
||||
┌─┴─┐
|
||||
TRUE FALSE
|
||||
↓ ↓
|
||||
INSERT CANCEL
|
||||
↓
|
||||
ButtonAction (원래 버튼 액션)
|
||||
```
|
||||
|
||||
#### 장점
|
||||
|
||||
- ✅ 시스템 단순화 (노드만 존재)
|
||||
- ✅ 시각적으로 명확
|
||||
- ✅ 유연한 워크플로우
|
||||
|
||||
#### 단점
|
||||
|
||||
- ⚠️ 기존 버튼 개념 변경
|
||||
- ⚠️ 마이그레이션 복잡
|
||||
- ⚠️ UI 학습 곡선
|
||||
|
||||
---
|
||||
|
||||
## 마이그레이션 계획
|
||||
|
||||
### Phase 1: 하이브리드 지원
|
||||
|
||||
#### 목표
|
||||
|
||||
기존 `relationshipId` 방식과 새로운 `flowId` 방식 모두 지원
|
||||
|
||||
#### 작업
|
||||
|
||||
1. **ButtonDataflowConfig 확장**
|
||||
|
||||
```typescript
|
||||
interface ButtonDataflowConfig {
|
||||
controlMode: "relationship" | "flow" | "none";
|
||||
|
||||
// 기존 (하위 호환)
|
||||
relationshipConfig?: {
|
||||
relationshipId: string;
|
||||
executionTiming: "before" | "after" | "replace";
|
||||
};
|
||||
|
||||
// 🆕 신규
|
||||
flowConfig?: {
|
||||
flowId: number;
|
||||
executionTiming: "before" | "after" | "replace";
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
2. **실행 로직 분기**
|
||||
|
||||
```typescript
|
||||
if (buttonConfig.controlMode === "flow") {
|
||||
await executeButtonWithFlow(buttonConfig, formData, context);
|
||||
} else if (buttonConfig.controlMode === "relationship") {
|
||||
await executeButtonWithRelationship(buttonConfig, formData, context);
|
||||
}
|
||||
```
|
||||
|
||||
3. **UI 업데이트**
|
||||
|
||||
- 버튼 설정에 "제어 방식 선택" 추가
|
||||
- "기존 관계" vs "노드 플로우" 선택 가능
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 마이그레이션 도구
|
||||
|
||||
#### 관계 → 플로우 변환기
|
||||
|
||||
```typescript
|
||||
async function migrateRelationshipToFlow(
|
||||
relationshipId: string
|
||||
): Promise<number> {
|
||||
// 1. 기존 관계 조회
|
||||
const relationship = await getRelationship(relationshipId);
|
||||
|
||||
// 2. 노드 생성
|
||||
const nodes: FlowNode[] = [];
|
||||
const edges: FlowEdge[] = [];
|
||||
|
||||
// 소스 노드 (formData 또는 table)
|
||||
const sourceNode = {
|
||||
id: "source-1",
|
||||
type: "manualInput",
|
||||
data: { fields: extractFields(relationship) },
|
||||
};
|
||||
nodes.push(sourceNode);
|
||||
|
||||
// 조건 노드
|
||||
if (relationship.conditions.length > 0) {
|
||||
const conditionNode = {
|
||||
id: "condition-1",
|
||||
type: "condition",
|
||||
data: {
|
||||
conditions: relationship.conditions,
|
||||
logic: relationship.logic || "AND",
|
||||
},
|
||||
};
|
||||
nodes.push(conditionNode);
|
||||
edges.push({ id: "e1", source: "source-1", target: "condition-1" });
|
||||
}
|
||||
|
||||
// 액션 노드들
|
||||
let lastNodeId =
|
||||
relationship.conditions.length > 0 ? "condition-1" : "source-1";
|
||||
|
||||
relationship.actionGroups.forEach((group, groupIdx) => {
|
||||
group.actions.forEach((action, actionIdx) => {
|
||||
const actionNodeId = `action-${groupIdx}-${actionIdx}`;
|
||||
const actionNode = convertActionToNode(action, actionNodeId);
|
||||
nodes.push(actionNode);
|
||||
|
||||
edges.push({
|
||||
id: `e-${actionNodeId}`,
|
||||
source: lastNodeId,
|
||||
target: actionNodeId,
|
||||
});
|
||||
|
||||
// 순차 실행인 경우
|
||||
if (group.sequential) {
|
||||
lastNodeId = actionNodeId;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 3. 플로우 저장
|
||||
const flowData = {
|
||||
flowName: `Migrated: ${relationship.name}`,
|
||||
flowDescription: `Migrated from relationship ${relationshipId}`,
|
||||
flowData: JSON.stringify({ nodes, edges }),
|
||||
};
|
||||
|
||||
const { flowId } = await createNodeFlow(flowData);
|
||||
|
||||
// 4. 버튼 설정 업데이트
|
||||
await updateButtonConfig(relationshipId, {
|
||||
controlMode: "flow",
|
||||
flowConfig: {
|
||||
flowId,
|
||||
executionTiming: relationship.timing || "before",
|
||||
},
|
||||
});
|
||||
|
||||
return flowId;
|
||||
}
|
||||
```
|
||||
|
||||
#### 액션 변환 로직
|
||||
|
||||
```typescript
|
||||
function convertActionToNode(action: DataflowAction, nodeId: string): FlowNode {
|
||||
switch (action.operation) {
|
||||
case "INSERT":
|
||||
return {
|
||||
id: nodeId,
|
||||
type: "insertAction",
|
||||
data: {
|
||||
targetTable: action.tableName,
|
||||
fieldMappings: action.fields.map((f) => ({
|
||||
sourceField: f.name,
|
||||
targetField: f.name,
|
||||
staticValue: f.type === "static" ? f.value : undefined,
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
case "UPDATE":
|
||||
return {
|
||||
id: nodeId,
|
||||
type: "updateAction",
|
||||
data: {
|
||||
targetTable: action.tableName,
|
||||
whereConditions: action.conditions,
|
||||
fieldMappings: action.fields.map((f) => ({
|
||||
sourceField: f.name,
|
||||
targetField: f.name,
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
case "DELETE":
|
||||
return {
|
||||
id: nodeId,
|
||||
type: "deleteAction",
|
||||
data: {
|
||||
targetTable: action.tableName,
|
||||
whereConditions: action.conditions,
|
||||
},
|
||||
};
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported operation: ${action.operation}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: 완전 전환
|
||||
|
||||
#### 목표
|
||||
|
||||
모든 버튼이 노드 플로우 방식 사용
|
||||
|
||||
#### 작업
|
||||
|
||||
1. **마이그레이션 스크립트 실행**
|
||||
|
||||
```sql
|
||||
-- 모든 관계를 플로우로 변환
|
||||
SELECT migrate_all_relationships_to_flows();
|
||||
```
|
||||
|
||||
2. **UI에서 관계 모드 제거**
|
||||
|
||||
```typescript
|
||||
// controlMode에서 "relationship" 제거
|
||||
type ControlMode = "flow" | "none";
|
||||
```
|
||||
|
||||
3. **레거시 코드 정리**
|
||||
|
||||
- `executeButtonWithRelationship()` 제거
|
||||
- `RelationshipService` 제거 (또는 읽기 전용)
|
||||
|
||||
---
|
||||
|
||||
## 결론
|
||||
|
||||
### ✅ 호환 가능
|
||||
|
||||
노드 시스템과 버튼 제어 시스템은 **충분히 호환 가능**합니다!
|
||||
|
||||
### 🎯 권장 방안
|
||||
|
||||
**하이브리드 방식 (전략 1)**으로 점진적 마이그레이션
|
||||
|
||||
#### 이유
|
||||
|
||||
1. ✅ **기존 시스템 유지** - 서비스 중단 없음
|
||||
2. ✅ **점진적 전환** - 리스크 최소화
|
||||
3. ✅ **유연성** - 두 방식 모두 활용 가능
|
||||
4. ✅ **학습 곡선** - 사용자가 천천히 적응
|
||||
|
||||
### 📋 다음 단계
|
||||
|
||||
1. **Phase 1 구현** (예상: 2일)
|
||||
|
||||
- `ButtonDataflowConfig` 확장
|
||||
- `executeButtonWithFlow()` 구현
|
||||
- UI 선택 옵션 추가
|
||||
|
||||
2. **Phase 2 도구 개발** (예상: 1일)
|
||||
|
||||
- 마이그레이션 스크립트
|
||||
- 자동 변환 로직
|
||||
|
||||
3. **Phase 3 전환** (예상: 1일)
|
||||
- 데이터 마이그레이션
|
||||
- 레거시 제거
|
||||
|
||||
### 총 소요 시간
|
||||
|
||||
**약 4일**
|
||||
|
||||
---
|
||||
|
||||
**참고 문서**:
|
||||
|
||||
- [노드\_실행\_엔진\_설계.md](./노드_실행_엔진_설계.md)
|
||||
- [노드\_기반\_제어\_시스템\_개선\_계획.md](./노드_기반_제어_시스템_개선_계획.md)
|
||||
|
|
@ -0,0 +1,617 @@
|
|||
# 노드 실행 엔진 설계
|
||||
|
||||
**작성일**: 2025-01-02
|
||||
**버전**: 1.0
|
||||
**상태**: ✅ 확정
|
||||
|
||||
---
|
||||
|
||||
## 📋 목차
|
||||
|
||||
1. [개요](#개요)
|
||||
2. [실행 방식](#실행-방식)
|
||||
3. [데이터 흐름](#데이터-흐름)
|
||||
4. [오류 처리](#오류-처리)
|
||||
5. [구현 계획](#구현-계획)
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
### 목적
|
||||
|
||||
노드 기반 데이터 플로우의 실행 엔진을 설계하여:
|
||||
|
||||
- 효율적인 병렬 처리
|
||||
- 안정적인 오류 처리
|
||||
- 명확한 데이터 흐름
|
||||
|
||||
### 핵심 원칙
|
||||
|
||||
1. **독립적 트랜잭션**: 각 액션 노드는 독립적인 트랜잭션
|
||||
2. **부분 실패 허용**: 일부 실패해도 성공한 노드는 커밋
|
||||
3. **연쇄 중단**: 부모 노드 실패 시 자식 노드 스킵
|
||||
4. **병렬 실행**: 의존성 없는 노드는 병렬 실행
|
||||
|
||||
---
|
||||
|
||||
## 실행 방식
|
||||
|
||||
### 1. 기본 구조
|
||||
|
||||
```typescript
|
||||
interface ExecutionContext {
|
||||
sourceData: any[]; // 원본 데이터
|
||||
nodeResults: Map<string, NodeResult>; // 각 노드 실행 결과
|
||||
executionOrder: string[]; // 실행 순서
|
||||
}
|
||||
|
||||
interface NodeResult {
|
||||
nodeId: string;
|
||||
status: "pending" | "success" | "failed" | "skipped";
|
||||
data?: any;
|
||||
error?: Error;
|
||||
startTime: number;
|
||||
endTime?: number;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 실행 단계
|
||||
|
||||
#### Step 1: 위상 정렬 (Topological Sort)
|
||||
|
||||
노드 간 의존성을 파악하여 실행 순서 결정
|
||||
|
||||
```typescript
|
||||
function topologicalSort(nodes: FlowNode[], edges: FlowEdge[]): string[][] {
|
||||
// DAG(Directed Acyclic Graph) 순회
|
||||
// 같은 레벨의 노드들은 배열로 그룹화
|
||||
|
||||
return [
|
||||
["tableSource-1"], // Level 0: 소스
|
||||
["insert-1", "update-1", "delete-1"], // Level 1: 병렬 실행 가능
|
||||
["update-2"], // Level 2: insert-1에 의존
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 2: 레벨별 실행
|
||||
|
||||
```typescript
|
||||
async function executeFlow(
|
||||
nodes: FlowNode[],
|
||||
edges: FlowEdge[]
|
||||
): Promise<ExecutionResult> {
|
||||
const levels = topologicalSort(nodes, edges);
|
||||
const context: ExecutionContext = {
|
||||
sourceData: [],
|
||||
nodeResults: new Map(),
|
||||
executionOrder: [],
|
||||
};
|
||||
|
||||
for (const level of levels) {
|
||||
// 같은 레벨의 노드들은 병렬 실행
|
||||
await executeLevel(level, nodes, context);
|
||||
}
|
||||
|
||||
return generateExecutionReport(context);
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 3: 레벨 내 병렬 실행
|
||||
|
||||
```typescript
|
||||
async function executeLevel(
|
||||
nodeIds: string[],
|
||||
nodes: FlowNode[],
|
||||
context: ExecutionContext
|
||||
): Promise<void> {
|
||||
// Promise.allSettled로 병렬 실행
|
||||
const results = await Promise.allSettled(
|
||||
nodeIds.map((nodeId) => executeNode(nodeId, nodes, context))
|
||||
);
|
||||
|
||||
// 결과 저장
|
||||
results.forEach((result, index) => {
|
||||
const nodeId = nodeIds[index];
|
||||
if (result.status === "fulfilled") {
|
||||
context.nodeResults.set(nodeId, result.value);
|
||||
} else {
|
||||
context.nodeResults.set(nodeId, {
|
||||
nodeId,
|
||||
status: "failed",
|
||||
error: result.reason,
|
||||
startTime: Date.now(),
|
||||
endTime: Date.now(),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 데이터 흐름
|
||||
|
||||
### 1. 소스 노드 실행
|
||||
|
||||
```typescript
|
||||
async function executeSourceNode(node: TableSourceNode): Promise<any[]> {
|
||||
const { tableName, schema, whereConditions } = node.data;
|
||||
|
||||
// 데이터베이스 쿼리 실행
|
||||
const query = buildSelectQuery(tableName, schema, whereConditions);
|
||||
const data = await executeQuery(query);
|
||||
|
||||
return data;
|
||||
}
|
||||
```
|
||||
|
||||
**결과 예시**:
|
||||
|
||||
```json
|
||||
[
|
||||
{ "id": 1, "name": "김철수", "age": 30 },
|
||||
{ "id": 2, "name": "이영희", "age": 25 },
|
||||
{ "id": 3, "name": "박민수", "age": 35 }
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 액션 노드 실행
|
||||
|
||||
#### 데이터 전달 방식
|
||||
|
||||
```typescript
|
||||
async function executeNode(
|
||||
nodeId: string,
|
||||
nodes: FlowNode[],
|
||||
context: ExecutionContext
|
||||
): Promise<NodeResult> {
|
||||
const node = nodes.find((n) => n.id === nodeId);
|
||||
const parents = getParentNodes(nodeId, edges);
|
||||
|
||||
// 1️⃣ 부모 노드 상태 확인
|
||||
const parentFailed = parents.some((p) => {
|
||||
const parentResult = context.nodeResults.get(p.id);
|
||||
return parentResult?.status === "failed";
|
||||
});
|
||||
|
||||
if (parentFailed) {
|
||||
return {
|
||||
nodeId,
|
||||
status: "skipped",
|
||||
error: new Error("Parent node failed"),
|
||||
startTime: Date.now(),
|
||||
endTime: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// 2️⃣ 입력 데이터 준비
|
||||
const inputData = prepareInputData(node, parents, context);
|
||||
|
||||
// 3️⃣ 액션 실행 (독립 트랜잭션)
|
||||
return await executeActionWithTransaction(node, inputData);
|
||||
}
|
||||
```
|
||||
|
||||
#### 입력 데이터 준비
|
||||
|
||||
```typescript
|
||||
function prepareInputData(
|
||||
node: FlowNode,
|
||||
parents: FlowNode[],
|
||||
context: ExecutionContext
|
||||
): any {
|
||||
if (parents.length === 0) {
|
||||
// 소스 노드
|
||||
return null;
|
||||
} else if (parents.length === 1) {
|
||||
// 단일 부모: 부모의 결과 데이터 전달
|
||||
const parentResult = context.nodeResults.get(parents[0].id);
|
||||
return parentResult?.data || context.sourceData;
|
||||
} else {
|
||||
// 다중 부모: 모든 부모의 데이터 병합
|
||||
return parents.map((p) => {
|
||||
const result = context.nodeResults.get(p.id);
|
||||
return result?.data || context.sourceData;
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 병렬 실행 예시
|
||||
|
||||
```
|
||||
TableSource
|
||||
(100개 레코드)
|
||||
↓
|
||||
┌──────┼──────┐
|
||||
↓ ↓ ↓
|
||||
INSERT UPDATE DELETE
|
||||
(독립) (독립) (독립)
|
||||
```
|
||||
|
||||
**실행 과정**:
|
||||
|
||||
```typescript
|
||||
// 1. TableSource 실행
|
||||
const sourceData = await executeTableSource();
|
||||
// → [100개 레코드]
|
||||
|
||||
// 2. 병렬 실행 (Promise.allSettled)
|
||||
const results = await Promise.allSettled([
|
||||
executeInsertAction(insertNode, sourceData),
|
||||
executeUpdateAction(updateNode, sourceData),
|
||||
executeDeleteAction(deleteNode, sourceData),
|
||||
]);
|
||||
|
||||
// 3. 각 액션은 독립 트랜잭션
|
||||
// - INSERT 실패 → INSERT만 롤백
|
||||
// - UPDATE 성공 → UPDATE 커밋
|
||||
// - DELETE 성공 → DELETE 커밋
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. 연쇄 실행 예시
|
||||
|
||||
```
|
||||
TableSource
|
||||
↓
|
||||
INSERT
|
||||
❌ (실패)
|
||||
↓
|
||||
UPDATE-2
|
||||
⏭️ (스킵)
|
||||
```
|
||||
|
||||
**실행 과정**:
|
||||
|
||||
```typescript
|
||||
// 1. TableSource 실행
|
||||
const sourceData = await executeTableSource();
|
||||
// → 성공 ✅
|
||||
|
||||
// 2. INSERT 실행
|
||||
const insertResult = await executeInsertAction(insertNode, sourceData);
|
||||
// → 실패 ❌ (롤백됨)
|
||||
|
||||
// 3. UPDATE-2 실행 시도
|
||||
const parentFailed = insertResult.status === "failed";
|
||||
if (parentFailed) {
|
||||
return {
|
||||
status: "skipped",
|
||||
reason: "Parent INSERT failed",
|
||||
};
|
||||
// → 스킬 ⏭️
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 오류 처리
|
||||
|
||||
### 1. 독립 트랜잭션
|
||||
|
||||
각 액션 노드는 자체 트랜잭션을 가짐
|
||||
|
||||
```typescript
|
||||
async function executeActionWithTransaction(
|
||||
node: FlowNode,
|
||||
inputData: any
|
||||
): Promise<NodeResult> {
|
||||
// 트랜잭션 시작
|
||||
const transaction = await db.beginTransaction();
|
||||
|
||||
try {
|
||||
const result = await performAction(node, inputData, transaction);
|
||||
|
||||
// 성공 시 커밋
|
||||
await transaction.commit();
|
||||
|
||||
return {
|
||||
nodeId: node.id,
|
||||
status: "success",
|
||||
data: result,
|
||||
startTime: Date.now(),
|
||||
endTime: Date.now(),
|
||||
};
|
||||
} catch (error) {
|
||||
// 실패 시 롤백
|
||||
await transaction.rollback();
|
||||
|
||||
return {
|
||||
nodeId: node.id,
|
||||
status: "failed",
|
||||
error: error,
|
||||
startTime: Date.now(),
|
||||
endTime: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 부분 실패 허용
|
||||
|
||||
```typescript
|
||||
// Promise.allSettled 사용
|
||||
const results = await Promise.allSettled([action1(), action2(), action3()]);
|
||||
|
||||
// 결과 수집
|
||||
const summary = {
|
||||
total: results.length,
|
||||
success: results.filter((r) => r.status === "fulfilled").length,
|
||||
failed: results.filter((r) => r.status === "rejected").length,
|
||||
details: results,
|
||||
};
|
||||
```
|
||||
|
||||
**예시 결과**:
|
||||
|
||||
```json
|
||||
{
|
||||
"total": 3,
|
||||
"success": 2,
|
||||
"failed": 1,
|
||||
"details": [
|
||||
{ "status": "rejected", "reason": "Duplicate key error" },
|
||||
{ "status": "fulfilled", "value": { "updatedCount": 100 } },
|
||||
{ "status": "fulfilled", "value": { "deletedCount": 50 } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 연쇄 중단
|
||||
|
||||
부모 노드 실패 시 자식 노드 자동 스킵
|
||||
|
||||
```typescript
|
||||
function shouldSkipNode(node: FlowNode, context: ExecutionContext): boolean {
|
||||
const parents = getParentNodes(node.id);
|
||||
|
||||
return parents.some((parent) => {
|
||||
const parentResult = context.nodeResults.get(parent.id);
|
||||
return parentResult?.status === "failed";
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. 오류 메시지
|
||||
|
||||
```typescript
|
||||
interface ExecutionError {
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
errorType: "validation" | "execution" | "connection" | "timeout";
|
||||
message: string;
|
||||
details?: any;
|
||||
timestamp: number;
|
||||
}
|
||||
```
|
||||
|
||||
**오류 메시지 예시**:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodeId": "insert-1",
|
||||
"nodeName": "INSERT 액션",
|
||||
"errorType": "execution",
|
||||
"message": "Duplicate key error: 'email' already exists",
|
||||
"details": {
|
||||
"table": "users",
|
||||
"constraint": "users_email_unique",
|
||||
"value": "test@example.com"
|
||||
},
|
||||
"timestamp": 1704182400000
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 구현 계획
|
||||
|
||||
### Phase 1: 기본 실행 엔진 (우선순위: 높음)
|
||||
|
||||
**작업 항목**:
|
||||
|
||||
1. ✅ 위상 정렬 알고리즘 구현
|
||||
2. ✅ 레벨별 실행 로직
|
||||
3. ✅ Promise.allSettled 기반 병렬 실행
|
||||
4. ✅ 독립 트랜잭션 처리
|
||||
5. ✅ 연쇄 중단 로직
|
||||
|
||||
**예상 시간**: 1일
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 소스 노드 실행 (우선순위: 높음)
|
||||
|
||||
**작업 항목**:
|
||||
|
||||
1. ✅ TableSource 실행기
|
||||
2. ✅ ExternalDBSource 실행기
|
||||
3. ✅ RestAPISource 실행기
|
||||
4. ✅ 데이터 캐싱
|
||||
|
||||
**예상 시간**: 1일
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: 액션 노드 실행 (우선순위: 높음)
|
||||
|
||||
**작업 항목**:
|
||||
|
||||
1. ✅ INSERT 액션 실행기
|
||||
2. ✅ UPDATE 액션 실행기
|
||||
3. ✅ DELETE 액션 실행기
|
||||
4. ✅ UPSERT 액션 실행기
|
||||
5. ✅ 필드 매핑 적용
|
||||
|
||||
**예상 시간**: 2일
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: 변환 노드 실행 (우선순위: 중간)
|
||||
|
||||
**작업 항목**:
|
||||
|
||||
1. ✅ FieldMapping 실행기
|
||||
2. ✅ DataTransform 실행기
|
||||
3. ✅ Condition 분기 처리
|
||||
|
||||
**예상 시간**: 1일
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: 오류 처리 및 모니터링 (우선순위: 중간)
|
||||
|
||||
**작업 항목**:
|
||||
|
||||
1. ✅ 상세 오류 메시지
|
||||
2. ✅ 실행 결과 리포트
|
||||
3. ✅ 실행 로그 저장
|
||||
4. ✅ 실시간 진행 상태 표시
|
||||
|
||||
**예상 시간**: 1일
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: 최적화 (우선순위: 낮음)
|
||||
|
||||
**작업 항목**:
|
||||
|
||||
1. ⏳ 데이터 스트리밍 (대용량 데이터)
|
||||
2. ⏳ 배치 처리 최적화
|
||||
3. ⏳ 병렬 처리 튜닝
|
||||
4. ⏳ 캐싱 전략
|
||||
|
||||
**예상 시간**: 2일
|
||||
|
||||
---
|
||||
|
||||
## 실행 결과 예시
|
||||
|
||||
### 성공 케이스
|
||||
|
||||
```json
|
||||
{
|
||||
"flowId": "flow-123",
|
||||
"flowName": "사용자 데이터 동기화",
|
||||
"status": "completed",
|
||||
"startTime": "2025-01-02T10:00:00Z",
|
||||
"endTime": "2025-01-02T10:00:05Z",
|
||||
"duration": 5000,
|
||||
"nodes": [
|
||||
{
|
||||
"nodeId": "source-1",
|
||||
"nodeName": "TableSource",
|
||||
"status": "success",
|
||||
"recordCount": 100,
|
||||
"duration": 500
|
||||
},
|
||||
{
|
||||
"nodeId": "insert-1",
|
||||
"nodeName": "INSERT",
|
||||
"status": "success",
|
||||
"insertedCount": 100,
|
||||
"duration": 2000
|
||||
},
|
||||
{
|
||||
"nodeId": "update-1",
|
||||
"nodeName": "UPDATE",
|
||||
"status": "success",
|
||||
"updatedCount": 80,
|
||||
"duration": 1500
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"total": 3,
|
||||
"success": 3,
|
||||
"failed": 0,
|
||||
"skipped": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 부분 실패 케이스
|
||||
|
||||
```json
|
||||
{
|
||||
"flowId": "flow-124",
|
||||
"flowName": "데이터 처리",
|
||||
"status": "partial_success",
|
||||
"startTime": "2025-01-02T11:00:00Z",
|
||||
"endTime": "2025-01-02T11:00:08Z",
|
||||
"duration": 8000,
|
||||
"nodes": [
|
||||
{
|
||||
"nodeId": "source-1",
|
||||
"nodeName": "TableSource",
|
||||
"status": "success",
|
||||
"recordCount": 100
|
||||
},
|
||||
{
|
||||
"nodeId": "insert-1",
|
||||
"nodeName": "INSERT",
|
||||
"status": "failed",
|
||||
"error": "Duplicate key error",
|
||||
"details": "email 'test@example.com' already exists"
|
||||
},
|
||||
{
|
||||
"nodeId": "update-2",
|
||||
"nodeName": "UPDATE-2",
|
||||
"status": "skipped",
|
||||
"reason": "Parent INSERT failed"
|
||||
},
|
||||
{
|
||||
"nodeId": "update-1",
|
||||
"nodeName": "UPDATE",
|
||||
"status": "success",
|
||||
"updatedCount": 50
|
||||
},
|
||||
{
|
||||
"nodeId": "delete-1",
|
||||
"nodeName": "DELETE",
|
||||
"status": "success",
|
||||
"deletedCount": 20
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"total": 5,
|
||||
"success": 3,
|
||||
"failed": 1,
|
||||
"skipped": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 다음 단계
|
||||
|
||||
1. ✅ 데이터 처리 방식 확정 (완료)
|
||||
2. ⏳ 실행 엔진 구현 시작
|
||||
3. ⏳ 테스트 케이스 작성
|
||||
4. ⏳ UI에서 실행 결과 표시
|
||||
|
||||
---
|
||||
|
||||
**참고 문서**:
|
||||
|
||||
- [노드*기반*제어*시스템*개선\_계획.md](./노드_기반_제어_시스템_개선_계획.md)
|
||||
- [노드*연결*규칙\_설계.md](./노드_연결_규칙_설계.md)
|
||||
- [노드*구조*개선안.md](./노드_구조_개선안.md)
|
||||
|
|
@ -0,0 +1,431 @@
|
|||
# 노드 연결 규칙 설계
|
||||
|
||||
**작성일**: 2025-01-02
|
||||
**버전**: 1.0
|
||||
**상태**: 🔄 설계 중
|
||||
|
||||
---
|
||||
|
||||
## 📋 목차
|
||||
|
||||
1. [개요](#개요)
|
||||
2. [노드 분류](#노드-분류)
|
||||
3. [연결 규칙 매트릭스](#연결-규칙-매트릭스)
|
||||
4. [상세 연결 규칙](#상세-연결-규칙)
|
||||
5. [구현 계획](#구현-계획)
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
### 목적
|
||||
|
||||
노드 간 연결 가능 여부를 명확히 정의하여:
|
||||
|
||||
- 사용자의 실수 방지
|
||||
- 논리적으로 올바른 플로우만 생성 가능
|
||||
- 명확한 오류 메시지 제공
|
||||
|
||||
### 기본 원칙
|
||||
|
||||
1. **데이터 흐름 방향**: 소스 → 변환 → 액션
|
||||
2. **타입 안전성**: 출력과 입력 타입이 호환되어야 함
|
||||
3. **논리적 정합성**: 의미 없는 연결 방지
|
||||
|
||||
---
|
||||
|
||||
## 노드 분류
|
||||
|
||||
### 1. 데이터 소스 노드 (Source)
|
||||
|
||||
**역할**: 데이터를 생성하는 시작점
|
||||
|
||||
- `tableSource` - 내부 테이블
|
||||
- `externalDBSource` - 외부 DB
|
||||
- `restAPISource` - REST API
|
||||
|
||||
**특징**:
|
||||
|
||||
- ✅ 출력만 가능 (소스 핸들)
|
||||
- ❌ 입력 불가능
|
||||
- 플로우의 시작점
|
||||
|
||||
---
|
||||
|
||||
### 2. 변환/조건 노드 (Transform)
|
||||
|
||||
**역할**: 데이터를 가공하거나 흐름을 제어
|
||||
|
||||
#### 2.1 데이터 변환
|
||||
|
||||
- `fieldMapping` - 필드 매핑
|
||||
- `dataTransform` - 데이터 변환
|
||||
|
||||
**특징**:
|
||||
|
||||
- ✅ 입력 가능 (타겟 핸들)
|
||||
- ✅ 출력 가능 (소스 핸들)
|
||||
- 중간 파이프라인 역할
|
||||
|
||||
#### 2.2 조건 분기
|
||||
|
||||
- `condition` - 조건 분기
|
||||
|
||||
**특징**:
|
||||
|
||||
- ✅ 입력 가능 (타겟 핸들)
|
||||
- ✅ 출력 가능 (TRUE/FALSE 2개의 소스 핸들)
|
||||
- 흐름을 분기
|
||||
|
||||
---
|
||||
|
||||
### 3. 액션 노드 (Action)
|
||||
|
||||
**역할**: 실제 데이터베이스 작업 수행
|
||||
|
||||
- `insertAction` - INSERT
|
||||
- `updateAction` - UPDATE
|
||||
- `deleteAction` - DELETE
|
||||
- `upsertAction` - UPSERT
|
||||
|
||||
**특징**:
|
||||
|
||||
- ✅ 입력 가능 (타겟 핸들)
|
||||
- ⚠️ 출력 제한적 (성공/실패 결과만)
|
||||
- 플로우의 종착점 또는 중간 액션
|
||||
|
||||
---
|
||||
|
||||
### 4. 유틸리티 노드 (Utility)
|
||||
|
||||
**역할**: 보조적인 기능 제공
|
||||
|
||||
- `log` - 로그 출력
|
||||
- `comment` - 주석
|
||||
|
||||
**특징**:
|
||||
|
||||
- `log`: 입력/출력 모두 가능 (패스스루)
|
||||
- `comment`: 연결 불가능 (독립 노드)
|
||||
|
||||
---
|
||||
|
||||
## 연결 규칙 매트릭스
|
||||
|
||||
### 출력(From) → 입력(To) 연결 가능 여부
|
||||
|
||||
| From ↓ / To → | tableSource | externalDB | restAPI | condition | fieldMapping | dataTransform | insert | update | delete | upsert | log | comment |
|
||||
| ----------------- | ----------- | ---------- | ------- | --------- | ------------ | ------------- | ------ | ------ | ------ | ------ | --- | ------- |
|
||||
| **tableSource** | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| **externalDB** | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| **restAPI** | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| **condition** | ❌ | ❌ | ❌ | ⚠️ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| **fieldMapping** | ❌ | ❌ | ❌ | ✅ | ⚠️ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| **dataTransform** | ❌ | ❌ | ❌ | ✅ | ✅ | ⚠️ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| **insert** | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ✅ | ❌ |
|
||||
| **update** | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ✅ | ❌ |
|
||||
| **delete** | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ✅ | ❌ |
|
||||
| **upsert** | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ✅ | ❌ |
|
||||
| **log** | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| **comment** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
|
||||
**범례**:
|
||||
|
||||
- ✅ 허용
|
||||
- ❌ 금지
|
||||
- ⚠️ 조건부 허용 (경고 메시지와 함께)
|
||||
|
||||
---
|
||||
|
||||
## 상세 연결 규칙
|
||||
|
||||
### 규칙 1: 소스 노드는 입력을 받을 수 없음
|
||||
|
||||
**금지되는 연결**:
|
||||
|
||||
```
|
||||
❌ 어떤 노드 → tableSource
|
||||
❌ 어떤 노드 → externalDBSource
|
||||
❌ 어떤 노드 → restAPISource
|
||||
```
|
||||
|
||||
**이유**: 소스 노드는 데이터의 시작점이므로 외부 입력이 의미 없음
|
||||
|
||||
**오류 메시지**:
|
||||
|
||||
```
|
||||
"소스 노드는 입력을 받을 수 없습니다. 소스 노드는 플로우의 시작점입니다."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 규칙 2: 소스 노드끼리 연결 불가
|
||||
|
||||
**금지되는 연결**:
|
||||
|
||||
```
|
||||
❌ tableSource → externalDBSource
|
||||
❌ restAPISource → tableSource
|
||||
```
|
||||
|
||||
**이유**: 소스 노드는 독립적으로 데이터를 생성하므로 서로 연결 불필요
|
||||
|
||||
**오류 메시지**:
|
||||
|
||||
```
|
||||
"소스 노드끼리는 연결할 수 없습니다. 각 소스는 독립적으로 동작합니다."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 규칙 3: Comment 노드는 연결 불가
|
||||
|
||||
**금지되는 연결**:
|
||||
|
||||
```
|
||||
❌ 어떤 노드 → comment
|
||||
❌ comment → 어떤 노드
|
||||
```
|
||||
|
||||
**이유**: Comment는 설명 전용 노드로 데이터 흐름에 영향을 주지 않음
|
||||
|
||||
**오류 메시지**:
|
||||
|
||||
```
|
||||
"주석 노드는 연결할 수 없습니다. 주석은 플로우 설명 용도로만 사용됩니다."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 규칙 4: 동일한 타입의 변환 노드 연속 연결 경고
|
||||
|
||||
**경고가 필요한 연결**:
|
||||
|
||||
```
|
||||
⚠️ fieldMapping → fieldMapping
|
||||
⚠️ dataTransform → dataTransform
|
||||
⚠️ condition → condition
|
||||
```
|
||||
|
||||
**이유**: 논리적으로 가능하지만 비효율적일 수 있음
|
||||
|
||||
**경고 메시지**:
|
||||
|
||||
```
|
||||
"동일한 타입의 변환 노드가 연속으로 연결되었습니다. 하나의 노드로 통합하는 것이 효율적입니다."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 규칙 5: 액션 노드 연속 연결 경고
|
||||
|
||||
**경고가 필요한 연결**:
|
||||
|
||||
```
|
||||
⚠️ insertAction → updateAction
|
||||
⚠️ updateAction → deleteAction
|
||||
⚠️ deleteAction → insertAction
|
||||
```
|
||||
|
||||
**이유**: 트랜잭션 관리나 성능에 영향을 줄 수 있음
|
||||
|
||||
**경고 메시지**:
|
||||
|
||||
```
|
||||
"액션 노드가 연속으로 연결되었습니다. 트랜잭션과 성능을 고려해주세요."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 규칙 6: 자기 자신에게 연결 금지
|
||||
|
||||
**금지되는 연결**:
|
||||
|
||||
```
|
||||
❌ 모든 노드 → 자기 자신
|
||||
```
|
||||
|
||||
**이유**: 무한 루프 방지
|
||||
|
||||
**오류 메시지**:
|
||||
|
||||
```
|
||||
"노드는 자기 자신에게 연결할 수 없습니다."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 규칙 7: Log 노드는 패스스루
|
||||
|
||||
**허용되는 연결**:
|
||||
|
||||
```
|
||||
✅ 모든 노드 → log → 모든 노드 (소스 제외)
|
||||
```
|
||||
|
||||
**특징**:
|
||||
|
||||
- Log 노드는 데이터를 그대로 전달
|
||||
- 디버깅 및 모니터링 용도
|
||||
- 데이터 흐름에 영향 없음
|
||||
|
||||
---
|
||||
|
||||
## 구현 계획
|
||||
|
||||
### Phase 1: 기본 금지 규칙 (우선순위: 높음)
|
||||
|
||||
**구현 위치**: `frontend/lib/stores/flowEditorStore.ts` - `validateConnection` 함수
|
||||
|
||||
```typescript
|
||||
function validateConnection(
|
||||
connection: Connection,
|
||||
nodes: FlowNode[]
|
||||
): { valid: boolean; error?: string } {
|
||||
const sourceNode = nodes.find((n) => n.id === connection.source);
|
||||
const targetNode = nodes.find((n) => n.id === connection.target);
|
||||
|
||||
if (!sourceNode || !targetNode) {
|
||||
return { valid: false, error: "노드를 찾을 수 없습니다" };
|
||||
}
|
||||
|
||||
// 규칙 1: 소스 노드는 입력을 받을 수 없음
|
||||
if (isSourceNode(targetNode.type)) {
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
"소스 노드는 입력을 받을 수 없습니다. 소스 노드는 플로우의 시작점입니다.",
|
||||
};
|
||||
}
|
||||
|
||||
// 규칙 2: 소스 노드끼리 연결 불가
|
||||
if (isSourceNode(sourceNode.type) && isSourceNode(targetNode.type)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "소스 노드끼리는 연결할 수 없습니다.",
|
||||
};
|
||||
}
|
||||
|
||||
// 규칙 3: Comment 노드는 연결 불가
|
||||
if (sourceNode.type === "comment" || targetNode.type === "comment") {
|
||||
return {
|
||||
valid: false,
|
||||
error: "주석 노드는 연결할 수 없습니다.",
|
||||
};
|
||||
}
|
||||
|
||||
// 규칙 6: 자기 자신에게 연결 금지
|
||||
if (connection.source === connection.target) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "노드는 자기 자신에게 연결할 수 없습니다.",
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
```
|
||||
|
||||
**예상 작업 시간**: 30분
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 경고 규칙 (우선순위: 중간)
|
||||
|
||||
**구현 방법**: 연결은 허용하되 경고 표시
|
||||
|
||||
```typescript
|
||||
function getConnectionWarning(
|
||||
connection: Connection,
|
||||
nodes: FlowNode[]
|
||||
): string | null {
|
||||
const sourceNode = nodes.find((n) => n.id === connection.source);
|
||||
const targetNode = nodes.find((n) => n.id === connection.target);
|
||||
|
||||
if (!sourceNode || !targetNode) return null;
|
||||
|
||||
// 규칙 4: 동일한 타입의 변환 노드 연속 연결
|
||||
if (sourceNode.type === targetNode.type && isTransformNode(sourceNode.type)) {
|
||||
return "동일한 타입의 변환 노드가 연속으로 연결되었습니다. 하나로 통합하는 것이 효율적입니다.";
|
||||
}
|
||||
|
||||
// 규칙 5: 액션 노드 연속 연결
|
||||
if (isActionNode(sourceNode.type) && isActionNode(targetNode.type)) {
|
||||
return "액션 노드가 연속으로 연결되었습니다. 트랜잭션과 성능을 고려해주세요.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
**UI 구현**:
|
||||
|
||||
- 경고 아이콘을 연결선 위에 표시
|
||||
- 호버 시 경고 메시지 툴팁 표시
|
||||
|
||||
**예상 작업 시간**: 1시간
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: 시각적 피드백 (우선순위: 낮음)
|
||||
|
||||
**기능**:
|
||||
|
||||
1. 드래그 중 호환 가능한 노드 하이라이트
|
||||
2. 불가능한 연결 시도 시 빨간색 표시
|
||||
3. 경고가 있는 연결은 노란색 표시
|
||||
|
||||
**예상 작업 시간**: 2시간
|
||||
|
||||
---
|
||||
|
||||
## 테스트 케이스
|
||||
|
||||
### 금지 테스트
|
||||
|
||||
- [ ] tableSource → tableSource (금지)
|
||||
- [ ] fieldMapping → comment (금지)
|
||||
- [ ] 자기 자신 → 자기 자신 (금지)
|
||||
|
||||
### 경고 테스트
|
||||
|
||||
- [ ] fieldMapping → fieldMapping (경고)
|
||||
- [ ] insertAction → updateAction (경고)
|
||||
|
||||
### 정상 테스트
|
||||
|
||||
- [ ] tableSource → fieldMapping → insertAction
|
||||
- [ ] externalDBSource → condition → (TRUE) → updateAction
|
||||
- [ ] restAPISource → log → dataTransform → upsertAction
|
||||
|
||||
---
|
||||
|
||||
## 향후 확장
|
||||
|
||||
### 추가 고려사항
|
||||
|
||||
1. **핸들별 제약**:
|
||||
|
||||
- Condition 노드의 TRUE/FALSE 출력 구분
|
||||
- 특정 핸들만 특정 노드 타입과 연결 가능
|
||||
|
||||
2. **데이터 타입 검증**:
|
||||
|
||||
- 숫자 필드만 계산 노드로 연결 가능
|
||||
- 문자열 필드만 텍스트 변환 노드로 연결 가능
|
||||
|
||||
3. **순서 제약**:
|
||||
- UPDATE/DELETE 전에 반드시 SELECT 필요
|
||||
- 특정 변환 순서 강제
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 버전 | 날짜 | 변경 내용 | 작성자 |
|
||||
| ---- | ---------- | --------- | ------ |
|
||||
| 1.0 | 2025-01-02 | 초안 작성 | AI |
|
||||
|
||||
---
|
||||
|
||||
**다음 단계**: Phase 1 구현 시작
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 노드 기반 제어 시스템 페이지
|
||||
*/
|
||||
|
||||
import { FlowEditor } from "@/components/dataflow/node-editor/FlowEditor";
|
||||
|
||||
export default function NodeEditorPage() {
|
||||
return (
|
||||
<div className="h-screen bg-gray-50">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="border-b bg-white p-4">
|
||||
<div className="mx-auto">
|
||||
<h1 className="text-2xl font-bold text-gray-900">노드 기반 제어 시스템</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
드래그 앤 드롭으로 데이터 제어 플로우를 시각적으로 설계하고 관리합니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 에디터 */}
|
||||
<FlowEditor />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -24,13 +24,13 @@ export default function DataFlowPage() {
|
|||
// 단계별 제목과 설명
|
||||
const stepConfig = {
|
||||
list: {
|
||||
title: "데이터 흐름 관계도 관리",
|
||||
description: "생성된 관계도들을 확인하고 관리하세요",
|
||||
title: "데이터 흐름 제어 관리",
|
||||
description: "생성된 제어들을 확인하고 관리하세요",
|
||||
icon: "📊",
|
||||
},
|
||||
design: {
|
||||
title: "새 관계도 설계",
|
||||
description: "테이블 간 데이터 관계를 시각적으로 설계하세요",
|
||||
title: "새 제어 설계",
|
||||
description: "테이블 간 데이터 제어를 시각적으로 설계하세요",
|
||||
icon: "🎨",
|
||||
},
|
||||
};
|
||||
|
|
@ -62,7 +62,7 @@ export default function DataFlowPage() {
|
|||
};
|
||||
|
||||
const handleSave = (relationships: TableRelationship[]) => {
|
||||
console.log("저장된 관계:", relationships);
|
||||
console.log("저장된 제어:", relationships);
|
||||
// 저장 후 목록으로 돌아가기 - 다음 렌더링 사이클로 지연
|
||||
setTimeout(() => {
|
||||
goToStep("list");
|
||||
|
|
@ -71,28 +71,28 @@ export default function DataFlowPage() {
|
|||
}, 0);
|
||||
};
|
||||
|
||||
// 관계도 수정 핸들러
|
||||
// 제어 수정 핸들러
|
||||
const handleDesignDiagram = async (diagram: DataFlowDiagram | null) => {
|
||||
if (diagram) {
|
||||
// 기존 관계도 수정 - 저장된 관계 정보 로드
|
||||
// 기존 제어 수정 - 저장된 제어 정보 로드
|
||||
try {
|
||||
console.log("📖 관계도 수정 모드:", diagram);
|
||||
console.log("📖 제어 수정 모드:", diagram);
|
||||
|
||||
// 저장된 관계 정보 로드
|
||||
// 저장된 제어 정보 로드
|
||||
const relationshipData = await loadDataflowRelationship(diagram.diagramId);
|
||||
console.log("✅ 관계 정보 로드 완료:", relationshipData);
|
||||
console.log("✅ 제어 정보 로드 완료:", relationshipData);
|
||||
|
||||
setEditingDiagram(diagram);
|
||||
setLoadedRelationshipData(relationshipData);
|
||||
goToNextStep("design");
|
||||
|
||||
toast.success(`"${diagram.diagramName}" 관계를 불러왔습니다.`);
|
||||
toast.success(`"${diagram.diagramName}" 제어를 불러왔습니다.`);
|
||||
} catch (error: any) {
|
||||
console.error("❌ 관계 정보 로드 실패:", error);
|
||||
toast.error(error.message || "관계 정보를 불러오는데 실패했습니다.");
|
||||
console.error("❌ 제어 정보 로드 실패:", error);
|
||||
toast.error(error.message || "제어 정보를 불러오는데 실패했습니다.");
|
||||
}
|
||||
} else {
|
||||
// 새 관계도 생성 - 현재 페이지에서 처리
|
||||
// 새 제어 생성 - 현재 페이지에서 처리
|
||||
setEditingDiagram(null);
|
||||
setLoadedRelationshipData(null);
|
||||
goToNextStep("design");
|
||||
|
|
@ -101,21 +101,21 @@ export default function DataFlowPage() {
|
|||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="container mx-auto space-y-4 p-4">
|
||||
<div className="mx-auto space-y-4 px-5 py-4">
|
||||
{/* 페이지 제목 */}
|
||||
<div className="flex items-center justify-between rounded-lg border bg-white p-4 shadow-sm">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">관계 관리</h1>
|
||||
<p className="mt-2 text-gray-600">테이블 간 데이터 관계를 시각적으로 설계하고 관리합니다</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900">제어 관리</h1>
|
||||
<p className="mt-2 text-gray-600">테이블 간 데이터 제어를 시각적으로 설계하고 관리합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 단계별 내용 */}
|
||||
<div className="space-y-6">
|
||||
{/* 관계도 목록 단계 */}
|
||||
{/* 제어 목록 단계 */}
|
||||
{currentStep === "list" && <DataFlowList onDesignDiagram={handleDesignDiagram} />}
|
||||
|
||||
{/* 관계도 설계 단계 - 🎨 새로운 UI 사용 */}
|
||||
{/* 제어 설계 단계 - 🎨 새로운 UI 사용 */}
|
||||
{currentStep === "design" && (
|
||||
<DataConnectionDesigner
|
||||
onClose={() => {
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ export default function ScreenViewPage() {
|
|||
modalSize?: "sm" | "md" | "lg" | "xl" | "full";
|
||||
editData?: any;
|
||||
onSave?: () => void;
|
||||
modalTitle?: string;
|
||||
modalDescription?: string;
|
||||
}>({});
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -67,6 +69,8 @@ export default function ScreenViewPage() {
|
|||
modalSize: event.detail.modalSize,
|
||||
editData: event.detail.editData,
|
||||
onSave: event.detail.onSave,
|
||||
modalTitle: event.detail.modalTitle,
|
||||
modalDescription: event.detail.modalDescription,
|
||||
});
|
||||
setEditModalOpen(true);
|
||||
};
|
||||
|
|
@ -407,6 +411,8 @@ export default function ScreenViewPage() {
|
|||
modalSize={editModalConfig.modalSize}
|
||||
editData={editModalConfig.editData}
|
||||
onSave={editModalConfig.onSave}
|
||||
modalTitle={editModalConfig.modalTitle}
|
||||
modalDescription={editModalConfig.modalDescription}
|
||||
onDataChange={(changedFormData) => {
|
||||
console.log("📝 EditModal에서 데이터 변경 수신:", changedFormData);
|
||||
// 변경된 데이터를 메인 폼에 반영
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
|
|||
tables.push(relationships.toTable.tableName);
|
||||
}
|
||||
|
||||
// 관계 수 계산 (actionGroups 기준)
|
||||
// 제어 수 계산 (actionGroups 기준)
|
||||
const actionGroups = relationships.actionGroups || [];
|
||||
const relationshipCount = actionGroups.reduce((count: number, group: any) => {
|
||||
return count + (group.actions?.length || 0);
|
||||
|
|
@ -79,7 +79,7 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
|
|||
relationshipId: diagram.diagram_id, // 호환성을 위해 추가
|
||||
diagramName: diagram.diagram_name,
|
||||
connectionType: relationships.connectionType || "data_save", // 실제 연결 타입 사용
|
||||
relationshipType: "multi-relationship", // 다중 관계 타입
|
||||
relationshipType: "multi-relationship", // 다중 제어 타입
|
||||
relationshipCount: relationshipCount || 1, // 최소 1개는 있다고 가정
|
||||
tableCount: tables.length,
|
||||
tables: tables,
|
||||
|
|
@ -96,14 +96,14 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
|
|||
setTotal(response.pagination.total || 0);
|
||||
setTotalPages(Math.max(1, Math.ceil((response.pagination.total || 0) / 20)));
|
||||
} catch (error) {
|
||||
console.error("관계 목록 조회 실패", error);
|
||||
toast.error("관계 목록을 불러오는데 실패했습니다.");
|
||||
console.error("제어 목록 조회 실패", error);
|
||||
toast.error("제어 목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentPage, searchTerm, companyCode]);
|
||||
|
||||
// 관계 목록 로드
|
||||
// 제어 목록 로드
|
||||
useEffect(() => {
|
||||
loadDiagrams();
|
||||
}, [loadDiagrams]);
|
||||
|
|
@ -130,13 +130,13 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
|
|||
undefined,
|
||||
user?.userId || "SYSTEM",
|
||||
);
|
||||
toast.success(`관계가 성공적으로 복사되었습니다: ${copiedDiagram.diagram_name}`);
|
||||
toast.success(`제어가 성공적으로 복사되었습니다: ${copiedDiagram.diagram_name}`);
|
||||
|
||||
// 목록 새로고침
|
||||
await loadDiagrams();
|
||||
} catch (error) {
|
||||
console.error("관계 복사 실패:", error);
|
||||
toast.error("관계 복사에 실패했습니다.");
|
||||
console.error("제어 복사 실패:", error);
|
||||
toast.error("제어 복사에 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setShowCopyModal(false);
|
||||
|
|
@ -151,13 +151,13 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
|
|||
try {
|
||||
setLoading(true);
|
||||
await DataFlowAPI.deleteJsonDataFlowDiagram(selectedDiagramForAction.diagramId, companyCode);
|
||||
toast.success(`관계가 삭제되었습니다: ${selectedDiagramForAction.diagramName}`);
|
||||
toast.success(`제어가 삭제되었습니다: ${selectedDiagramForAction.diagramName}`);
|
||||
|
||||
// 목록 새로고침
|
||||
await loadDiagrams();
|
||||
} catch (error) {
|
||||
console.error("관계 삭제 실패:", error);
|
||||
toast.error("관계 삭제에 실패했습니다.");
|
||||
console.error("제어 삭제 실패:", error);
|
||||
toast.error("제어 삭제에 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setShowDeleteModal(false);
|
||||
|
|
@ -181,7 +181,7 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
|
|||
<div className="relative">
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
|
||||
<Input
|
||||
placeholder="관계명, 테이블명으로 검색..."
|
||||
placeholder="제어명, 테이블명으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-80 pl-10"
|
||||
|
|
@ -189,17 +189,17 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
|
|||
</div>
|
||||
</div>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700" onClick={() => onDesignDiagram(null)}>
|
||||
<Plus className="mr-2 h-4 w-4" />새 관계 생성
|
||||
<Plus className="mr-2 h-4 w-4" />새 제어 생성
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 관계 목록 테이블 */}
|
||||
{/* 제어 목록 테이블 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span className="flex items-center">
|
||||
<Network className="mr-2 h-5 w-5" />
|
||||
데이터 흐름 관계 ({total})
|
||||
데이터 흐름 제어 ({total})
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
|
@ -207,10 +207,10 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
|
|||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>관계명</TableHead>
|
||||
<TableHead>제어명</TableHead>
|
||||
<TableHead>회사 코드</TableHead>
|
||||
<TableHead>테이블 수</TableHead>
|
||||
<TableHead>관계 수</TableHead>
|
||||
<TableHead>액션 수</TableHead>
|
||||
<TableHead>최근 수정</TableHead>
|
||||
<TableHead>작업</TableHead>
|
||||
</TableRow>
|
||||
|
|
@ -244,7 +244,7 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
|
|||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center text-sm text-muted-foreground">
|
||||
<div className="text-muted-foreground flex items-center text-sm">
|
||||
<Calendar className="mr-1 h-3 w-3 text-gray-400" />
|
||||
{new Date(diagram.updatedAt).toLocaleDateString()}
|
||||
</div>
|
||||
|
|
@ -284,8 +284,8 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
|
|||
{diagrams.length === 0 && (
|
||||
<div className="py-8 text-center text-gray-500">
|
||||
<Network className="mx-auto mb-4 h-12 w-12 text-gray-300" />
|
||||
<div className="mb-2 text-lg font-medium">관계가 없습니다</div>
|
||||
<div className="text-sm">새 관계를 생성하여 테이블 간 데이터 관계를 설정해보세요.</div>
|
||||
<div className="mb-2 text-lg font-medium">제어가 없습니다</div>
|
||||
<div className="text-sm">새 제어를 생성하여 테이블 간 데이터 제어를 설정해보세요.</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
@ -302,7 +302,7 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
|
|||
>
|
||||
이전
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
|
|
@ -320,11 +320,11 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
|
|||
<Dialog open={showCopyModal} onOpenChange={setShowCopyModal}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>관계 복사</DialogTitle>
|
||||
<DialogTitle>제어 복사</DialogTitle>
|
||||
<DialogDescription>
|
||||
“{selectedDiagramForAction?.diagramName}” 관계를 복사하시겠습니까?
|
||||
“{selectedDiagramForAction?.diagramName}” 제어를 복사하시겠습니까?
|
||||
<br />
|
||||
새로운 관계는 원본 이름 뒤에 (1), (2), (3)... 형태로 생성됩니다.
|
||||
새로운 제어는 원본 이름 뒤에 (1), (2), (3)... 형태로 생성됩니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
|
|
@ -342,12 +342,12 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
|
|||
<Dialog open={showDeleteModal} onOpenChange={setShowDeleteModal}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-destructive">관계 삭제</DialogTitle>
|
||||
<DialogTitle className="text-red-600">제어 삭제</DialogTitle>
|
||||
<DialogDescription>
|
||||
“{selectedDiagramForAction?.diagramName}” 관계를 완전히 삭제하시겠습니까?
|
||||
“{selectedDiagramForAction?.diagramName}” 제어를 완전히 삭제하시겠습니까?
|
||||
<br />
|
||||
<span className="font-medium text-destructive">
|
||||
이 작업은 되돌릴 수 없으며, 모든 관계 정보가 영구적으로 삭제됩니다.
|
||||
<span className="font-medium text-red-600">
|
||||
이 작업은 되돌릴 수 없으며, 모든 제어 정보가 영구적으로 삭제됩니다.
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
|
|
|||
|
|
@ -25,11 +25,699 @@ const initialState: DataConnectionState = {
|
|||
},
|
||||
isLoading: false,
|
||||
validationErrors: [],
|
||||
};
|
||||
|
||||
export const DataConnectionDesigner: React.FC = () => {
|
||||
const [state, setState] = useState<DataConnectionState>(initialState);
|
||||
const { isMobile, isTablet } = useResponsive();
|
||||
// 컬럼 정보 초기화
|
||||
fromColumns: [],
|
||||
toColumns: [],
|
||||
...initialData,
|
||||
}));
|
||||
|
||||
// 🔧 수정 모드 감지 (initialData에 diagramId가 있으면 수정 모드)
|
||||
const diagramId = initialData?.diagramId;
|
||||
|
||||
// 🔄 초기 데이터 로드
|
||||
useEffect(() => {
|
||||
if (initialData && Object.keys(initialData).length > 1) {
|
||||
console.log("🔄 초기 데이터 로드:", initialData);
|
||||
|
||||
// 로드된 데이터로 state 업데이트
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
connectionType: initialData.connectionType || prev.connectionType,
|
||||
|
||||
// 🔧 관계 정보 로드
|
||||
relationshipName: initialData.relationshipName || prev.relationshipName,
|
||||
description: initialData.description || prev.description,
|
||||
groupsLogicalOperator: initialData.groupsLogicalOperator || prev.groupsLogicalOperator,
|
||||
|
||||
fromConnection: initialData.fromConnection || prev.fromConnection,
|
||||
toConnection: initialData.toConnection || prev.toConnection,
|
||||
fromTable: initialData.fromTable || prev.fromTable,
|
||||
toTable: initialData.toTable || prev.toTable,
|
||||
controlConditions: initialData.controlConditions || prev.controlConditions,
|
||||
fieldMappings: initialData.fieldMappings || prev.fieldMappings,
|
||||
|
||||
// 🔧 외부호출 설정 로드
|
||||
externalCallConfig: initialData.externalCallConfig || prev.externalCallConfig,
|
||||
|
||||
// 🔧 액션 그룹 데이터 로드 (기존 호환성 포함)
|
||||
actionGroups:
|
||||
initialData.actionGroups ||
|
||||
// 기존 단일 액션 데이터를 그룹으로 변환
|
||||
(initialData.actionType || initialData.actionConditions
|
||||
? [
|
||||
{
|
||||
id: "group_1",
|
||||
name: "기본 액션 그룹",
|
||||
logicalOperator: "AND" as const,
|
||||
actions: [
|
||||
{
|
||||
id: "action_1",
|
||||
name: "액션 1",
|
||||
actionType: initialData.actionType || ("insert" as const),
|
||||
conditions: initialData.actionConditions || [],
|
||||
fieldMappings: initialData.actionFieldMappings || [],
|
||||
isEnabled: true,
|
||||
},
|
||||
],
|
||||
isEnabled: true,
|
||||
},
|
||||
]
|
||||
: prev.actionGroups),
|
||||
|
||||
// 기존 호환성 필드들
|
||||
actionType: initialData.actionType || prev.actionType,
|
||||
actionConditions: initialData.actionConditions || prev.actionConditions,
|
||||
actionFieldMappings: initialData.actionFieldMappings || prev.actionFieldMappings,
|
||||
|
||||
currentStep: initialData.fromConnection && initialData.toConnection ? 4 : 1, // 연결 정보가 있으면 4단계부터 시작
|
||||
}));
|
||||
|
||||
console.log("✅ 초기 데이터 로드 완료");
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
// 🎯 액션 핸들러들
|
||||
const actions: DataConnectionActions = {
|
||||
// 연결 타입 설정
|
||||
setConnectionType: useCallback((type: "data_save" | "external_call") => {
|
||||
console.log("🔄 [DataConnectionDesigner] setConnectionType 호출됨:", type);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
connectionType: type,
|
||||
// 타입 변경 시 상태 초기화
|
||||
currentStep: 1,
|
||||
fromConnection: undefined,
|
||||
toConnection: undefined,
|
||||
fromTable: undefined,
|
||||
toTable: undefined,
|
||||
fieldMappings: [],
|
||||
validationErrors: [],
|
||||
}));
|
||||
toast.success(`연결 타입이 ${type === "data_save" ? "데이터 저장" : "외부 호출"}로 변경되었습니다.`);
|
||||
}, []),
|
||||
|
||||
// 🔧 관계 정보 설정
|
||||
setRelationshipName: useCallback((name: string) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
relationshipName: name,
|
||||
}));
|
||||
}, []),
|
||||
|
||||
setDescription: useCallback((description: string) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
description: description,
|
||||
}));
|
||||
}, []),
|
||||
|
||||
setGroupsLogicalOperator: useCallback((operator: "AND" | "OR") => {
|
||||
setState((prev) => ({ ...prev, groupsLogicalOperator: operator }));
|
||||
console.log("🔄 그룹 간 논리 연산자 변경:", operator);
|
||||
}, []),
|
||||
|
||||
// 단계 이동
|
||||
goToStep: useCallback((step: 1 | 2 | 3 | 4) => {
|
||||
setState((prev) => ({ ...prev, currentStep: step }));
|
||||
}, []),
|
||||
|
||||
// 연결 선택
|
||||
selectConnection: useCallback((type: "from" | "to", connection: Connection) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
[type === "from" ? "fromConnection" : "toConnection"]: connection,
|
||||
// 연결 변경 시 테이블과 매핑 초기화
|
||||
[type === "from" ? "fromTable" : "toTable"]: undefined,
|
||||
fieldMappings: [],
|
||||
}));
|
||||
toast.success(`${type === "from" ? "소스" : "대상"} 연결이 선택되었습니다: ${connection.name}`);
|
||||
}, []),
|
||||
|
||||
// 테이블 선택
|
||||
selectTable: useCallback((type: "from" | "to", table: TableInfo) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
[type === "from" ? "fromTable" : "toTable"]: table,
|
||||
// 테이블 변경 시 매핑과 컬럼 정보 초기화
|
||||
fieldMappings: [],
|
||||
fromColumns: type === "from" ? [] : prev.fromColumns,
|
||||
toColumns: type === "to" ? [] : prev.toColumns,
|
||||
}));
|
||||
toast.success(
|
||||
`${type === "from" ? "소스" : "대상"} 테이블이 선택되었습니다: ${table.displayName || table.tableName}`,
|
||||
);
|
||||
}, []),
|
||||
|
||||
// 컬럼 정보 로드 (중앙 관리)
|
||||
loadColumns: useCallback(async () => {
|
||||
if (!state.fromConnection || !state.toConnection || !state.fromTable || !state.toTable) {
|
||||
console.log("❌ 컬럼 로드: 필수 정보 누락");
|
||||
return;
|
||||
}
|
||||
|
||||
// 이미 로드된 경우 스킵 (배열 길이로 확인)
|
||||
if (state.fromColumns && state.toColumns && state.fromColumns.length > 0 && state.toColumns.length > 0) {
|
||||
console.log("✅ 컬럼 정보 이미 로드됨, 스킵", {
|
||||
fromColumns: state.fromColumns.length,
|
||||
toColumns: state.toColumns.length,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("🔄 중앙 컬럼 로드 시작:", {
|
||||
from: `${state.fromConnection.id}/${state.fromTable.tableName}`,
|
||||
to: `${state.toConnection.id}/${state.toTable.tableName}`,
|
||||
});
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isLoading: true,
|
||||
fromColumns: [],
|
||||
toColumns: [],
|
||||
}));
|
||||
|
||||
try {
|
||||
const [fromCols, toCols] = await Promise.all([
|
||||
getColumnsFromConnection(state.fromConnection.id, state.fromTable.tableName),
|
||||
getColumnsFromConnection(state.toConnection.id, state.toTable.tableName),
|
||||
]);
|
||||
|
||||
console.log("✅ 중앙 컬럼 로드 완료:", {
|
||||
fromColumns: fromCols.length,
|
||||
toColumns: toCols.length,
|
||||
});
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
fromColumns: Array.isArray(fromCols) ? fromCols : [],
|
||||
toColumns: Array.isArray(toCols) ? toCols : [],
|
||||
isLoading: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("❌ 중앙 컬럼 로드 실패:", error);
|
||||
setState((prev) => ({ ...prev, isLoading: false }));
|
||||
toast.error("컬럼 정보를 불러오는데 실패했습니다.");
|
||||
}
|
||||
}, [state.fromConnection, state.toConnection, state.fromTable, state.toTable, state.fromColumns, state.toColumns]),
|
||||
|
||||
// 필드 매핑 생성 (호환성용 - 실제로는 각 액션에서 직접 관리)
|
||||
createMapping: useCallback((fromField: ColumnInfo, toField: ColumnInfo) => {
|
||||
const newMapping: FieldMapping = {
|
||||
id: `${fromField.columnName}_to_${toField.columnName}_${Date.now()}`,
|
||||
fromField,
|
||||
toField,
|
||||
isValid: true,
|
||||
validationMessage: undefined,
|
||||
};
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
fieldMappings: [...prev.fieldMappings, newMapping],
|
||||
}));
|
||||
|
||||
console.log("🔗 전역 매핑 생성 (호환성):", {
|
||||
newMapping,
|
||||
fieldName: `${fromField.columnName} → ${toField.columnName}`,
|
||||
});
|
||||
|
||||
toast.success(`매핑이 생성되었습니다: ${fromField.columnName} → ${toField.columnName}`);
|
||||
}, []),
|
||||
|
||||
// 필드 매핑 업데이트
|
||||
updateMapping: useCallback((mappingId: string, updates: Partial<FieldMapping>) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
fieldMappings: prev.fieldMappings.map((mapping) =>
|
||||
mapping.id === mappingId ? { ...mapping, ...updates } : mapping,
|
||||
),
|
||||
}));
|
||||
}, []),
|
||||
|
||||
// 필드 매핑 삭제 (호환성용 - 실제로는 각 액션에서 직접 관리)
|
||||
deleteMapping: useCallback((mappingId: string) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
fieldMappings: prev.fieldMappings.filter((mapping) => mapping.id !== mappingId),
|
||||
}));
|
||||
|
||||
console.log("🗑️ 전역 매핑 삭제 (호환성):", { mappingId });
|
||||
toast.success("매핑이 삭제되었습니다.");
|
||||
}, []),
|
||||
|
||||
// 매핑 검증
|
||||
validateMappings: useCallback(async (): Promise<ValidationResult> => {
|
||||
setState((prev) => ({ ...prev, isLoading: true }));
|
||||
|
||||
try {
|
||||
// TODO: 실제 검증 로직 구현
|
||||
const result: ValidationResult = {
|
||||
isValid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
validationErrors: result.errors,
|
||||
isLoading: false,
|
||||
}));
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
setState((prev) => ({ ...prev, isLoading: false }));
|
||||
throw error;
|
||||
}
|
||||
}, []),
|
||||
|
||||
// 제어 조건 관리 (전체 실행 조건)
|
||||
addControlCondition: useCallback(() => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
controlConditions: [
|
||||
...prev.controlConditions,
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
type: "condition",
|
||||
field: "",
|
||||
operator: "=",
|
||||
value: "",
|
||||
dataType: "string",
|
||||
},
|
||||
],
|
||||
}));
|
||||
}, []),
|
||||
|
||||
updateControlCondition: useCallback((index: number, condition: any) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
controlConditions: prev.controlConditions.map((cond, i) => (i === index ? { ...cond, ...condition } : cond)),
|
||||
}));
|
||||
}, []),
|
||||
|
||||
deleteControlCondition: useCallback((index: number) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
controlConditions: prev.controlConditions.filter((_, i) => i !== index),
|
||||
}));
|
||||
toast.success("제어 조건이 삭제되었습니다.");
|
||||
}, []),
|
||||
|
||||
// 외부호출 설정 업데이트
|
||||
updateExternalCallConfig: useCallback((config: any) => {
|
||||
console.log("🔄 외부호출 설정 업데이트:", config);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
externalCallConfig: config,
|
||||
}));
|
||||
}, []),
|
||||
|
||||
// 액션 설정 관리
|
||||
setActionType: useCallback((type: "insert" | "update" | "delete" | "upsert") => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionType: type,
|
||||
// INSERT가 아닌 경우 조건 초기화
|
||||
actionConditions: type === "insert" ? [] : prev.actionConditions,
|
||||
}));
|
||||
toast.success(`액션 타입이 ${type.toUpperCase()}로 변경되었습니다.`);
|
||||
}, []),
|
||||
|
||||
addActionCondition: useCallback(() => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionConditions: [
|
||||
...prev.actionConditions,
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
type: "condition",
|
||||
field: "",
|
||||
operator: "=",
|
||||
value: "",
|
||||
dataType: "string",
|
||||
},
|
||||
],
|
||||
}));
|
||||
}, []),
|
||||
|
||||
updateActionCondition: useCallback((index: number, condition: any) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionConditions: prev.actionConditions.map((cond, i) => (i === index ? { ...cond, ...condition } : cond)),
|
||||
}));
|
||||
}, []),
|
||||
|
||||
// 🔧 액션 조건 배열 전체 업데이트 (ActionConditionBuilder용)
|
||||
setActionConditions: useCallback((conditions: any[]) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionConditions: conditions,
|
||||
}));
|
||||
}, []),
|
||||
|
||||
deleteActionCondition: useCallback((index: number) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionConditions: prev.actionConditions.filter((_, i) => i !== index),
|
||||
}));
|
||||
toast.success("조건이 삭제되었습니다.");
|
||||
}, []),
|
||||
|
||||
// 🎯 액션 그룹 관리 (멀티 액션)
|
||||
addActionGroup: useCallback(() => {
|
||||
const newGroupId = `group_${Date.now()}`;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionGroups: [
|
||||
...prev.actionGroups,
|
||||
{
|
||||
id: newGroupId,
|
||||
name: `액션 그룹 ${prev.actionGroups.length + 1}`,
|
||||
logicalOperator: "AND" as const,
|
||||
actions: [
|
||||
{
|
||||
id: `action_${Date.now()}`,
|
||||
name: "액션 1",
|
||||
actionType: "insert" as const,
|
||||
conditions: [],
|
||||
fieldMappings: [],
|
||||
isEnabled: true,
|
||||
},
|
||||
],
|
||||
isEnabled: true,
|
||||
},
|
||||
],
|
||||
}));
|
||||
toast.success("새 액션 그룹이 추가되었습니다.");
|
||||
}, []),
|
||||
|
||||
updateActionGroup: useCallback((groupId: string, updates: Partial<ActionGroup>) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionGroups: prev.actionGroups.map((group) => (group.id === groupId ? { ...group, ...updates } : group)),
|
||||
}));
|
||||
}, []),
|
||||
|
||||
deleteActionGroup: useCallback((groupId: string) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionGroups: prev.actionGroups.filter((group) => group.id !== groupId),
|
||||
}));
|
||||
toast.success("액션 그룹이 삭제되었습니다.");
|
||||
}, []),
|
||||
|
||||
addActionToGroup: useCallback((groupId: string) => {
|
||||
const newActionId = `action_${Date.now()}`;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionGroups: prev.actionGroups.map((group) =>
|
||||
group.id === groupId
|
||||
? {
|
||||
...group,
|
||||
actions: [
|
||||
...group.actions,
|
||||
{
|
||||
id: newActionId,
|
||||
name: `액션 ${group.actions.length + 1}`,
|
||||
actionType: "insert" as const,
|
||||
conditions: [],
|
||||
fieldMappings: [],
|
||||
isEnabled: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
: group,
|
||||
),
|
||||
}));
|
||||
toast.success("새 액션이 추가되었습니다.");
|
||||
}, []),
|
||||
|
||||
updateActionInGroup: useCallback((groupId: string, actionId: string, updates: Partial<SingleAction>) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionGroups: prev.actionGroups.map((group) =>
|
||||
group.id === groupId
|
||||
? {
|
||||
...group,
|
||||
actions: group.actions.map((action) => (action.id === actionId ? { ...action, ...updates } : action)),
|
||||
}
|
||||
: group,
|
||||
),
|
||||
}));
|
||||
}, []),
|
||||
|
||||
deleteActionFromGroup: useCallback((groupId: string, actionId: string) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionGroups: prev.actionGroups.map((group) =>
|
||||
group.id === groupId
|
||||
? {
|
||||
...group,
|
||||
actions: group.actions.filter((action) => action.id !== actionId),
|
||||
}
|
||||
: group,
|
||||
),
|
||||
}));
|
||||
toast.success("액션이 삭제되었습니다.");
|
||||
}, []),
|
||||
|
||||
// 매핑 저장 (직접 저장)
|
||||
saveMappings: useCallback(async () => {
|
||||
// 관계명과 설명이 없으면 저장할 수 없음
|
||||
if (!state.relationshipName?.trim()) {
|
||||
toast.error("관계 이름을 입력해주세요.");
|
||||
actions.goToStep(1); // 첫 번째 단계로 이동
|
||||
return;
|
||||
}
|
||||
|
||||
// 외부호출인 경우 API URL만 확인 (테이블 검증 제외)
|
||||
if (state.connectionType === "external_call") {
|
||||
if (!state.externalCallConfig?.restApiSettings?.apiUrl) {
|
||||
toast.error("API URL을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
// 외부호출은 테이블 정보 검증 건너뛰기
|
||||
}
|
||||
|
||||
// 중복 체크 (수정 모드가 아닌 경우에만)
|
||||
if (!diagramId) {
|
||||
try {
|
||||
const duplicateCheck = await checkRelationshipNameDuplicate(state.relationshipName, diagramId);
|
||||
if (duplicateCheck.isDuplicate) {
|
||||
toast.error(`"${state.relationshipName}" 이름이 이미 사용 중입니다. 다른 이름을 사용해주세요.`);
|
||||
actions.goToStep(1); // 첫 번째 단계로 이동
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("중복 체크 실패:", error);
|
||||
toast.error("관계명 중복 체크 중 오류가 발생했습니다.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, isLoading: true }));
|
||||
|
||||
try {
|
||||
// 실제 저장 로직 구현 - connectionType에 따라 필요한 설정만 포함
|
||||
let saveData: any = {
|
||||
relationshipName: state.relationshipName,
|
||||
description: state.description,
|
||||
connectionType: state.connectionType,
|
||||
};
|
||||
|
||||
if (state.connectionType === "external_call") {
|
||||
// 외부호출 타입인 경우: 외부호출 설정만 포함
|
||||
console.log("💾 외부호출 타입 저장 - 외부호출 설정만 포함");
|
||||
saveData = {
|
||||
...saveData,
|
||||
// 외부호출 관련 설정만 포함
|
||||
externalCallConfig: state.externalCallConfig,
|
||||
actionType: "external_call",
|
||||
// 데이터 저장 관련 설정은 제외 (null/빈 배열로 설정)
|
||||
fromConnection: null,
|
||||
toConnection: null,
|
||||
fromTable: null,
|
||||
toTable: null,
|
||||
actionGroups: [],
|
||||
controlConditions: [],
|
||||
actionConditions: [],
|
||||
fieldMappings: [],
|
||||
};
|
||||
} else if (state.connectionType === "data_save") {
|
||||
// 데이터 저장 타입인 경우: 데이터 저장 설정만 포함
|
||||
console.log("💾 데이터 저장 타입 저장 - 데이터 저장 설정만 포함");
|
||||
saveData = {
|
||||
...saveData,
|
||||
// 데이터 저장 관련 설정만 포함
|
||||
fromConnection: state.fromConnection,
|
||||
toConnection: state.toConnection,
|
||||
fromTable: state.fromTable,
|
||||
toTable: state.toTable,
|
||||
actionGroups: state.actionGroups,
|
||||
groupsLogicalOperator: state.groupsLogicalOperator,
|
||||
controlConditions: state.controlConditions,
|
||||
// 기존 호환성을 위한 필드들 (첫 번째 액션 그룹의 첫 번째 액션에서 추출)
|
||||
actionType: state.actionGroups[0]?.actions[0]?.actionType || state.actionType || "insert",
|
||||
actionConditions: state.actionGroups[0]?.actions[0]?.conditions || state.actionConditions || [],
|
||||
fieldMappings: state.actionGroups[0]?.actions[0]?.fieldMappings || state.fieldMappings || [],
|
||||
// 외부호출 관련 설정은 제외 (null로 설정)
|
||||
externalCallConfig: null,
|
||||
};
|
||||
}
|
||||
|
||||
console.log("💾 직접 저장 시작:", { saveData, diagramId, isEdit: !!diagramId });
|
||||
|
||||
// 데이터 저장 타입인 경우 기존 외부호출 설정 정리
|
||||
if (state.connectionType === "data_save" && diagramId) {
|
||||
console.log("🧹 데이터 저장 타입으로 변경 - 기존 외부호출 설정 정리");
|
||||
try {
|
||||
const { ExternalCallConfigAPI } = await import("@/lib/api/externalCallConfig");
|
||||
|
||||
// 기존 외부호출 설정이 있는지 확인하고 삭제 또는 비활성화
|
||||
const existingConfigs = await ExternalCallConfigAPI.getConfigs({
|
||||
company_code: "*",
|
||||
is_active: "Y",
|
||||
});
|
||||
|
||||
const existingConfig = existingConfigs.data?.find(
|
||||
(config: any) => config.config_name === (state.relationshipName || "외부호출 설정"),
|
||||
);
|
||||
|
||||
if (existingConfig) {
|
||||
console.log("🗑️ 기존 외부호출 설정 비활성화:", existingConfig.id);
|
||||
// 설정을 비활성화 (삭제하지 않고 is_active를 'N'으로 변경)
|
||||
await ExternalCallConfigAPI.updateConfig(existingConfig.id, {
|
||||
...existingConfig,
|
||||
is_active: "N",
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.warn("⚠️ 외부호출 설정 정리 실패 (무시하고 계속):", cleanupError);
|
||||
}
|
||||
}
|
||||
|
||||
// 외부호출인 경우에만 external-call-configs에 설정 저장
|
||||
if (state.connectionType === "external_call" && state.externalCallConfig) {
|
||||
try {
|
||||
const { ExternalCallConfigAPI } = await import("@/lib/api/externalCallConfig");
|
||||
|
||||
const configData = {
|
||||
config_name: state.relationshipName || "외부호출 설정",
|
||||
call_type: "rest-api",
|
||||
api_type: "generic",
|
||||
config_data: state.externalCallConfig.restApiSettings,
|
||||
description: state.description || "",
|
||||
company_code: "*", // 기본값
|
||||
};
|
||||
|
||||
let configResult;
|
||||
|
||||
if (diagramId) {
|
||||
// 수정 모드: 기존 설정이 있는지 확인하고 업데이트 또는 생성
|
||||
console.log("🔄 수정 모드 - 외부호출 설정 처리");
|
||||
|
||||
try {
|
||||
// 먼저 기존 설정 조회 시도
|
||||
const existingConfigs = await ExternalCallConfigAPI.getConfigs({
|
||||
company_code: "*",
|
||||
is_active: "Y",
|
||||
});
|
||||
|
||||
const existingConfig = existingConfigs.data?.find(
|
||||
(config: any) => config.config_name === (state.relationshipName || "외부호출 설정"),
|
||||
);
|
||||
|
||||
if (existingConfig) {
|
||||
// 기존 설정 업데이트
|
||||
console.log("📝 기존 외부호출 설정 업데이트:", existingConfig.id);
|
||||
configResult = await ExternalCallConfigAPI.updateConfig(existingConfig.id, configData);
|
||||
} else {
|
||||
// 기존 설정이 없으면 새로 생성
|
||||
console.log("🆕 새 외부호출 설정 생성 (수정 모드)");
|
||||
configResult = await ExternalCallConfigAPI.createConfig(configData);
|
||||
}
|
||||
} catch (updateError) {
|
||||
// 중복 생성 오류인 경우 무시하고 계속 진행
|
||||
if (updateError.message && updateError.message.includes("이미 존재합니다")) {
|
||||
console.log("⚠️ 외부호출 설정이 이미 존재함 - 기존 설정 사용");
|
||||
configResult = { success: true, message: "기존 외부호출 설정 사용" };
|
||||
} else {
|
||||
console.warn("⚠️ 외부호출 설정 처리 실패:", updateError);
|
||||
throw updateError;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 신규 생성 모드
|
||||
console.log("🆕 신규 생성 모드 - 외부호출 설정 생성");
|
||||
try {
|
||||
configResult = await ExternalCallConfigAPI.createConfig(configData);
|
||||
} catch (createError) {
|
||||
// 중복 생성 오류인 경우 무시하고 계속 진행
|
||||
if (createError.message && createError.message.includes("이미 존재합니다")) {
|
||||
console.log("⚠️ 외부호출 설정이 이미 존재함 - 기존 설정 사용");
|
||||
configResult = { success: true, message: "기존 외부호출 설정 사용" };
|
||||
} else {
|
||||
throw createError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!configResult.success) {
|
||||
throw new Error(configResult.error || "외부호출 설정 저장 실패");
|
||||
}
|
||||
|
||||
console.log("✅ 외부호출 설정 저장 완료:", configResult.data);
|
||||
} catch (configError) {
|
||||
console.error("❌ 외부호출 설정 저장 실패:", configError);
|
||||
// 외부호출 설정 저장 실패해도 관계는 저장하도록 함
|
||||
toast.error("외부호출 설정 저장에 실패했지만 관계는 저장되었습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
// 백엔드 API 호출 (수정 모드인 경우 diagramId 전달)
|
||||
const result = await saveDataflowRelationship(saveData, diagramId);
|
||||
|
||||
console.log("✅ 저장 완료:", result);
|
||||
|
||||
setState((prev) => ({ ...prev, isLoading: false }));
|
||||
toast.success(`"${state.relationshipName}" 관계가 성공적으로 저장되었습니다.`);
|
||||
|
||||
// 저장 후 닫기
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("❌ 저장 실패:", error);
|
||||
setState((prev) => ({ ...prev, isLoading: false }));
|
||||
toast.error(error.message || "저장 중 오류가 발생했습니다.");
|
||||
}
|
||||
}, [state, diagramId, onClose]),
|
||||
|
||||
// 테스트 실행
|
||||
testExecution: useCallback(async (): Promise<TestResult> => {
|
||||
setState((prev) => ({ ...prev, isLoading: true }));
|
||||
|
||||
try {
|
||||
// TODO: 실제 테스트 로직 구현
|
||||
const result: TestResult = {
|
||||
success: true,
|
||||
message: "테스트가 성공적으로 완료되었습니다.",
|
||||
affectedRows: 10,
|
||||
executionTime: 250,
|
||||
};
|
||||
|
||||
setState((prev) => ({ ...prev, isLoading: false }));
|
||||
toast.success(result.message);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
setState((prev) => ({ ...prev, isLoading: false }));
|
||||
toast.error("테스트 실행 중 오류가 발생했습니다.");
|
||||
throw error;
|
||||
}
|
||||
}, []),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-gradient-to-br from-slate-50 to-gray-100">
|
||||
|
|
@ -58,52 +746,18 @@ export const DataConnectionDesigner: React.FC = () => {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-[70%] bg-gray-50 flex flex-col">
|
||||
<StepProgress
|
||||
currentStep={state.currentStep}
|
||||
onStepChange={(step) => setState(prev => ({ ...prev, currentStep: step }))}
|
||||
/>
|
||||
|
||||
<div className="flex-1 p-6">
|
||||
{state.currentStep === 1 && (
|
||||
<ConnectionStep
|
||||
fromConnection={state.fromConnection}
|
||||
toConnection={state.toConnection}
|
||||
onFromConnectionChange={(conn) => setState(prev => ({ ...prev, fromConnection: conn }))}
|
||||
onToConnectionChange={(conn) => setState(prev => ({ ...prev, toConnection: conn }))}
|
||||
onNext={() => setState(prev => ({ ...prev, currentStep: 2 }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state.currentStep === 2 && (
|
||||
<TableStep
|
||||
fromConnection={state.fromConnection}
|
||||
toConnection={state.toConnection}
|
||||
fromTable={state.fromTable}
|
||||
toTable={state.toTable}
|
||||
onFromTableChange={(table) => setState(prev => ({ ...prev, fromTable: table }))}
|
||||
onToTableChange={(table) => setState(prev => ({ ...prev, toTable: table }))}
|
||||
onNext={() => setState(prev => ({ ...prev, currentStep: 3 }))}
|
||||
onBack={() => setState(prev => ({ ...prev, currentStep: 1 }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state.currentStep === 3 && (
|
||||
<FieldMappingStep
|
||||
fromTable={state.fromTable}
|
||||
toTable={state.toTable}
|
||||
fieldMappings={state.fieldMappings}
|
||||
onMappingsChange={(mappings) => setState(prev => ({ ...prev, fieldMappings: mappings }))}
|
||||
onBack={() => setState(prev => ({ ...prev, currentStep: 2 }))}
|
||||
onSave={() => {
|
||||
// 저장 로직
|
||||
console.log("저장:", state);
|
||||
alert("데이터 연결 설정이 저장되었습니다!");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* 메인 컨텐츠 - 좌우 분할 레이아웃 */}
|
||||
<div className="flex h-[calc(100vh-200px)] min-h-[700px] overflow-hidden">
|
||||
{/* 좌측 패널 (30%) - 항상 표시 */}
|
||||
<div className="flex w-[20%] flex-col border-r bg-white">
|
||||
<LeftPanel state={state} actions={actions} />
|
||||
</div>
|
||||
|
||||
{/* 우측 패널 (80%) */}
|
||||
<div className="flex w-[80%] flex-col bg-gray-50">
|
||||
<RightPanel key={state.connectionType} state={state} actions={actions} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ArrowLeft, Settings, CheckCircle } from "lucide-react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ArrowLeft, Settings, CheckCircle, Eye } from "lucide-react";
|
||||
|
||||
// 타입 import
|
||||
import { DataConnectionState, DataConnectionActions } from "../types/redesigned";
|
||||
|
|
@ -14,6 +15,7 @@ import { getColumnsFromConnection } from "@/lib/api/multiConnection";
|
|||
|
||||
// 컴포넌트 import
|
||||
import ActionConditionBuilder from "./ActionConfig/ActionConditionBuilder";
|
||||
import { DataflowVisualization } from "./DataflowVisualization";
|
||||
|
||||
interface ActionConfigStepProps {
|
||||
state: DataConnectionState;
|
||||
|
|
@ -78,7 +80,8 @@ const ActionConfigStep: React.FC<ActionConfigStepProps> = ({
|
|||
|
||||
const canComplete =
|
||||
actionType &&
|
||||
(actionType === "insert" || (actionConditions.length > 0 && (actionType === "delete" || fieldMappings.length > 0)));
|
||||
(actionType === "insert" ||
|
||||
((actionConditions || []).length > 0 && (actionType === "delete" || fieldMappings.length > 0)));
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -89,106 +92,137 @@ const ActionConfigStep: React.FC<ActionConfigStepProps> = ({
|
|||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="max-h-[calc(100vh-400px)] min-h-[400px] space-y-6 overflow-y-auto">
|
||||
{/* 액션 타입 선택 */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-lg font-semibold">액션 타입</h3>
|
||||
<Select value={actionType} onValueChange={actions.setActionType}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="액션 타입을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{actionTypes.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div>
|
||||
<span className="font-medium">{type.label}</span>
|
||||
<p className="text-muted-foreground text-xs">{type.description}</p>
|
||||
<CardContent className="flex h-full flex-col overflow-hidden p-0">
|
||||
<Tabs defaultValue="config" className="flex h-full flex-col">
|
||||
<div className="flex-shrink-0 border-b px-4 pt-4">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="config" className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
액션 설정
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="visualization" className="flex items-center gap-2">
|
||||
<Eye className="h-4 w-4" />
|
||||
흐름 미리보기
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* 액션 설정 탭 */}
|
||||
<TabsContent value="config" className="mt-0 flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-6">
|
||||
{/* 액션 타입 선택 */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-lg font-semibold">액션 타입</h3>
|
||||
<Select value={actionType} onValueChange={actions.setActionType}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="액션 타입을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{actionTypes.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div>
|
||||
<span className="font-medium">{type.label}</span>
|
||||
<p className="text-muted-foreground text-xs">{type.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{actionType && (
|
||||
<div className="bg-primary/5 border-primary/20 rounded-lg border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-primary">
|
||||
{actionTypes.find((t) => t.value === actionType)?.label}
|
||||
</Badge>
|
||||
<span className="text-sm">{actionTypes.find((t) => t.value === actionType)?.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{actionType && (
|
||||
<div className="bg-primary/5 border-primary/20 rounded-lg border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-primary">
|
||||
{actionTypes.find((t) => t.value === actionType)?.label}
|
||||
</Badge>
|
||||
<span className="text-sm">{actionTypes.find((t) => t.value === actionType)?.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 상세 조건 설정 */}
|
||||
{actionType && !isLoading && fromColumns.length > 0 && toColumns.length > 0 && (
|
||||
<ActionConditionBuilder
|
||||
actionType={actionType}
|
||||
fromColumns={fromColumns}
|
||||
toColumns={toColumns}
|
||||
conditions={actionConditions}
|
||||
fieldMappings={fieldMappings}
|
||||
onConditionsChange={(conditions) => {
|
||||
// 액션 조건 배열 전체 업데이트
|
||||
actions.setActionConditions(conditions);
|
||||
}}
|
||||
onFieldMappingsChange={setFieldMappings}
|
||||
/>
|
||||
)}
|
||||
{/* 상세 조건 설정 */}
|
||||
{actionType && !isLoading && fromColumns.length > 0 && toColumns.length > 0 && (
|
||||
<ActionConditionBuilder
|
||||
actionType={actionType}
|
||||
fromColumns={fromColumns}
|
||||
toColumns={toColumns}
|
||||
conditions={actionConditions || []}
|
||||
fieldMappings={fieldMappings}
|
||||
onConditionsChange={(conditions) => {
|
||||
// 액션 조건 배열 전체 업데이트
|
||||
actions.setActionConditions(conditions);
|
||||
}}
|
||||
onFieldMappingsChange={setFieldMappings}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 로딩 상태 */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-muted-foreground">컬럼 정보를 불러오는 중...</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 로딩 상태 */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-muted-foreground">컬럼 정보를 불러오는 중...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* INSERT 액션 안내 */}
|
||||
{actionType === "insert" && (
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 p-4">
|
||||
<h4 className="mb-2 text-sm font-medium text-green-800">INSERT 액션</h4>
|
||||
<p className="text-sm text-green-700">
|
||||
INSERT 액션은 별도의 실행 조건이 필요하지 않습니다. 매핑된 모든 데이터가 새로운 레코드로 삽입됩니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* INSERT 액션 안내 */}
|
||||
{actionType === "insert" && (
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 p-4">
|
||||
<h4 className="mb-2 text-sm font-medium text-green-800">INSERT 액션</h4>
|
||||
<p className="text-sm text-green-700">
|
||||
INSERT 액션은 별도의 실행 조건이 필요하지 않습니다. 매핑된 모든 데이터가 새로운 레코드로 삽입됩니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 액션 요약 */}
|
||||
{actionType && (
|
||||
<div className="bg-muted/50 rounded-lg p-4">
|
||||
<h4 className="mb-3 text-sm font-medium">설정 요약</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>액션 타입:</span>
|
||||
<Badge variant="outline">{actionType.toUpperCase()}</Badge>
|
||||
</div>
|
||||
{actionType !== "insert" && (
|
||||
<>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>실행 조건:</span>
|
||||
<span className="text-muted-foreground">
|
||||
{actionConditions.length > 0 ? `${actionConditions.length}개 조건` : "조건 없음"}
|
||||
</span>
|
||||
</div>
|
||||
{actionType !== "delete" && (
|
||||
{/* 액션 요약 */}
|
||||
{actionType && (
|
||||
<div className="bg-muted/50 rounded-lg p-4">
|
||||
<h4 className="mb-3 text-sm font-medium">설정 요약</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>필드 매핑:</span>
|
||||
<span className="text-muted-foreground">
|
||||
{fieldMappings.length > 0 ? `${fieldMappings.length}개 필드` : "필드 없음"}
|
||||
</span>
|
||||
<span>액션 타입:</span>
|
||||
<Badge variant="outline">{actionType.toUpperCase()}</Badge>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
{actionType !== "insert" && (
|
||||
<>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>실행 조건:</span>
|
||||
<span className="text-muted-foreground">
|
||||
{actionConditions.length > 0 ? `${actionConditions.length}개 조건` : "조건 없음"}
|
||||
</span>
|
||||
</div>
|
||||
{actionType !== "delete" && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>필드 매핑:</span>
|
||||
<span className="text-muted-foreground">
|
||||
{fieldMappings.length > 0 ? `${fieldMappings.length}개 필드` : "필드 없음"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* 흐름 미리보기 탭 */}
|
||||
<TabsContent value="visualization" className="mt-0 flex-1 overflow-y-auto">
|
||||
<DataflowVisualization
|
||||
state={state}
|
||||
onEdit={(step) => {
|
||||
// 편집 버튼 클릭 시 해당 단계로 이동하는 로직 추가 가능
|
||||
console.log(`편집 요청: ${step}`);
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* 하단 네비게이션 */}
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex-shrink-0 border-t bg-white p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="outline" onClick={onBack} className="flex items-center gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,321 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Database, Filter, Zap, CheckCircle, XCircle, Edit } from "lucide-react";
|
||||
import { DataConnectionState } from "../types/redesigned";
|
||||
|
||||
interface DataflowVisualizationProps {
|
||||
state: Partial<DataConnectionState> & {
|
||||
dataflowActions?: Array<{
|
||||
actionType: string;
|
||||
targetTable?: string;
|
||||
name?: string;
|
||||
fieldMappings?: any[];
|
||||
}>;
|
||||
};
|
||||
onEdit: (step: "source" | "conditions" | "actions") => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎯 Sankey 다이어그램 스타일 데이터 흐름 시각화
|
||||
*/
|
||||
export const DataflowVisualization: React.FC<DataflowVisualizationProps> = ({ state, onEdit }) => {
|
||||
const { fromTable, toTable, controlConditions = [], dataflowActions = [], fromColumns = [], toColumns = [] } = state;
|
||||
|
||||
// 상태 계산
|
||||
const hasSource = !!fromTable;
|
||||
const hasConditions = controlConditions.length > 0;
|
||||
const hasActions = dataflowActions.length > 0;
|
||||
const isComplete = hasSource && hasActions;
|
||||
|
||||
// 필드명을 라벨명으로 변환하는 함수
|
||||
const getFieldLabel = (fieldName: string) => {
|
||||
// fromColumns와 toColumns에서 해당 필드 찾기
|
||||
const allColumns = [...fromColumns, ...toColumns];
|
||||
const column = allColumns.find((col) => col.columnName === fieldName);
|
||||
return column?.displayName || column?.labelKo || fieldName;
|
||||
};
|
||||
|
||||
// 테이블명을 라벨명으로 변환하는 함수
|
||||
const getTableLabel = (tableName: string) => {
|
||||
// fromTable 또는 toTable의 라벨 반환
|
||||
if (fromTable?.tableName === tableName) {
|
||||
return fromTable?.tableLabel || fromTable?.displayName || tableName;
|
||||
}
|
||||
if (toTable?.tableName === tableName) {
|
||||
return toTable?.tableLabel || toTable?.displayName || tableName;
|
||||
}
|
||||
return tableName;
|
||||
};
|
||||
|
||||
// 액션 그룹별로 대표 액션 1개씩만 표시
|
||||
const actionGroups = dataflowActions.reduce(
|
||||
(acc, action, index) => {
|
||||
// 각 액션을 개별 그룹으로 처리 (실제로는 actionGroups에서 온 것)
|
||||
const groupKey = `group_${index}`;
|
||||
acc[groupKey] = [action];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, typeof dataflowActions>,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-600">전체 데이터 흐름을 한눈에 확인</p>
|
||||
<Badge variant={isComplete ? "default" : "secondary"} className="text-sm">
|
||||
{isComplete ? "✅ 설정 완료" : "⚠️ 설정 필요"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Sankey 다이어그램 */}
|
||||
<div className="relative flex items-center justify-center py-12">
|
||||
{/* 연결선 레이어 */}
|
||||
<svg className="absolute inset-0 h-full w-full" style={{ zIndex: 0 }}>
|
||||
{/* 소스 → 조건 선 */}
|
||||
{hasSource && <line x1="25%" y1="50%" x2="50%" y2="50%" stroke="#60a5fa" strokeWidth="3" />}
|
||||
|
||||
{/* 조건 → 액션들 선 (여러 개) */}
|
||||
{hasConditions &&
|
||||
hasActions &&
|
||||
Object.keys(actionGroups).map((groupKey, index) => {
|
||||
const totalActions = Object.keys(actionGroups).length;
|
||||
const startY = 50; // 조건 노드 중앙
|
||||
// 액션이 여러 개면 위에서 아래로 분산
|
||||
const endY =
|
||||
totalActions > 1
|
||||
? 30 + (index * 40) / (totalActions - 1) // 30%~70% 사이에 분산
|
||||
: 50; // 액션이 1개면 중앙
|
||||
|
||||
return (
|
||||
<line
|
||||
key={groupKey}
|
||||
x1="50%"
|
||||
y1={`${startY}%`}
|
||||
x2="75%"
|
||||
y2={`${endY}%`}
|
||||
stroke="#34d399"
|
||||
strokeWidth="2"
|
||||
opacity="0.7"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
<div className="relative flex w-full items-center justify-around" style={{ zIndex: 1 }}>
|
||||
{/* 1. 소스 노드 */}
|
||||
<div className="flex flex-col items-center" style={{ width: "28%" }}>
|
||||
<Card
|
||||
className={`w-full cursor-pointer border-2 transition-all hover:shadow-lg ${
|
||||
hasSource ? "border-blue-400 bg-blue-50" : "border-gray-300 bg-gray-50"
|
||||
}`}
|
||||
onClick={() => onEdit("source")}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Database className="h-5 w-5 text-blue-600" />
|
||||
<span className="text-sm font-semibold text-gray-900">데이터 소스</span>
|
||||
</div>
|
||||
{hasSource ? (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{fromTable.tableLabel || fromTable.displayName || fromTable.tableName}
|
||||
</p>
|
||||
{(fromTable.tableLabel || fromTable.displayName) && (
|
||||
<p className="text-xs text-gray-500">({fromTable.tableName})</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-500">미설정</p>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0">
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 2. 조건 노드 (중앙) */}
|
||||
<div className="flex flex-col items-center" style={{ width: "28%" }}>
|
||||
<Card
|
||||
className={`w-full cursor-pointer border-2 transition-all hover:shadow-lg ${
|
||||
hasConditions ? "border-yellow-400 bg-yellow-50" : "border-gray-300 bg-gray-50"
|
||||
}`}
|
||||
onClick={() => onEdit("conditions")}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Filter className="h-5 w-5 text-yellow-600" />
|
||||
<span className="text-sm font-semibold text-gray-900">조건 검증</span>
|
||||
</div>
|
||||
{hasConditions ? (
|
||||
<div className="space-y-2">
|
||||
{/* 실제 조건들 표시 */}
|
||||
<div className="max-h-32 space-y-1 overflow-y-auto">
|
||||
{controlConditions.slice(0, 3).map((condition, index) => (
|
||||
<div key={index} className="rounded bg-white/50 px-2 py-1">
|
||||
<p className="text-xs font-medium text-gray-800">
|
||||
{index > 0 && (
|
||||
<span className="mr-1 font-bold text-blue-600">
|
||||
{condition.logicalOperator || "AND"}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-blue-800">{getFieldLabel(condition.field)}</span>{" "}
|
||||
<span className="text-gray-600">{condition.operator}</span>{" "}
|
||||
<span className="text-green-700">{condition.value}</span>
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{controlConditions.length > 3 && (
|
||||
<p className="px-2 text-xs text-gray-500">외 {controlConditions.length - 3}개...</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between border-t pt-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<CheckCircle className="h-3 w-3 text-green-600" />
|
||||
<span className="text-xs text-gray-600">충족 → 실행</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<XCircle className="h-3 w-3 text-red-600" />
|
||||
<span className="text-xs text-gray-600">불만족 → 중단</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-500">조건 없음 (항상 실행)</p>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0">
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 3. 액션 노드들 (우측) */}
|
||||
<div className="flex flex-col items-center gap-3" style={{ width: "28%" }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onEdit("actions")}
|
||||
className="mb-2 flex items-center gap-2 self-end"
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
<span className="text-xs">전체 편집</span>
|
||||
</Button>
|
||||
|
||||
{hasActions ? (
|
||||
<div className="w-full space-y-3">
|
||||
{Object.entries(actionGroups).map(([groupKey, actions]) => (
|
||||
<ActionFlowCard
|
||||
key={groupKey}
|
||||
type={actions[0].actionType}
|
||||
actions={actions}
|
||||
getTableLabel={getTableLabel}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="w-full border-2 border-dashed border-gray-300 bg-gray-50">
|
||||
<CardContent className="p-4 text-center">
|
||||
<Zap className="mx-auto mb-2 h-6 w-6 text-gray-400" />
|
||||
<p className="text-xs text-gray-500">액션 미설정</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 조건 불만족 시 중단 표시 (하단) */}
|
||||
{hasConditions && (
|
||||
<div
|
||||
className="absolute bottom-0 flex items-center gap-2 rounded-lg border-2 border-red-300 bg-red-50 px-3 py-2"
|
||||
style={{ left: "50%", transform: "translateX(-50%)" }}
|
||||
>
|
||||
<XCircle className="h-4 w-4 text-red-600" />
|
||||
<span className="text-xs font-medium text-red-900">조건 불만족 → 실행 중단</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 통계 요약 */}
|
||||
<Card className="border-gray-200 bg-gradient-to-r from-gray-50 to-slate-50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-around text-center">
|
||||
<div>
|
||||
<p className="text-xs text-gray-600">소스</p>
|
||||
<p className="text-lg font-bold text-blue-600">{hasSource ? 1 : 0}</p>
|
||||
</div>
|
||||
<div className="h-8 w-px bg-gray-300"></div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-600">조건</p>
|
||||
<p className="text-lg font-bold text-yellow-600">{controlConditions.length}</p>
|
||||
</div>
|
||||
<div className="h-8 w-px bg-gray-300"></div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-600">액션</p>
|
||||
<p className="text-lg font-bold text-green-600">{dataflowActions.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 액션 플로우 카드 컴포넌트
|
||||
interface ActionFlowCardProps {
|
||||
type: string;
|
||||
actions: Array<{
|
||||
actionType: string;
|
||||
targetTable?: string;
|
||||
name?: string;
|
||||
fieldMappings?: any[];
|
||||
}>;
|
||||
getTableLabel: (tableName: string) => string;
|
||||
}
|
||||
|
||||
const ActionFlowCard: React.FC<ActionFlowCardProps> = ({ type, actions, getTableLabel }) => {
|
||||
const actionColors = {
|
||||
insert: { bg: "bg-blue-50", border: "border-blue-300", text: "text-blue-900", icon: "text-blue-600" },
|
||||
update: { bg: "bg-green-50", border: "border-green-300", text: "text-green-900", icon: "text-green-600" },
|
||||
delete: { bg: "bg-red-50", border: "border-red-300", text: "text-red-900", icon: "text-red-600" },
|
||||
upsert: { bg: "bg-purple-50", border: "border-purple-300", text: "text-purple-900", icon: "text-purple-600" },
|
||||
};
|
||||
|
||||
const colors = actionColors[type as keyof typeof actionColors] || actionColors.insert;
|
||||
const action = actions[0]; // 그룹당 1개만 표시
|
||||
const displayName = action.targetTable ? getTableLabel(action.targetTable) : action.name || "액션";
|
||||
const isTableLabel = action.targetTable && getTableLabel(action.targetTable) !== action.targetTable;
|
||||
|
||||
return (
|
||||
<Card className={`border-2 ${colors.border} ${colors.bg}`}>
|
||||
<CardContent className="p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Zap className={`h-4 w-4 ${colors.icon}`} />
|
||||
<span className={`text-sm font-semibold ${colors.text}`}>{type.toUpperCase()}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Database className="h-3 w-3 text-gray-500" />
|
||||
<span className="truncate font-medium text-gray-900">{displayName}</span>
|
||||
</div>
|
||||
{isTableLabel && action.targetTable && (
|
||||
<span className="ml-5 truncate text-xs text-gray-500">({action.targetTable})</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
@ -21,6 +21,7 @@ import {
|
|||
Save,
|
||||
Play,
|
||||
AlertTriangle,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
|
|
@ -28,6 +29,9 @@ import { toast } from "sonner";
|
|||
|
||||
// 타입 import
|
||||
import { ColumnInfo, Connection, TableInfo } from "@/lib/types/multiConnection";
|
||||
|
||||
// 컴포넌트 import
|
||||
import { DataflowVisualization } from "./DataflowVisualization";
|
||||
import { ActionGroup, SingleAction, FieldMapping } from "../types/redesigned";
|
||||
|
||||
// 컴포넌트 import
|
||||
|
|
@ -104,7 +108,7 @@ const MultiActionConfigStep: React.FC<MultiActionConfigStepProps> = ({
|
|||
onLoadColumns,
|
||||
}) => {
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(["group_1"])); // 첫 번째 그룹은 기본 열림
|
||||
const [activeTab, setActiveTab] = useState<"control" | "actions" | "mapping">("control"); // 현재 활성 탭
|
||||
const [activeTab, setActiveTab] = useState<"control" | "actions" | "visualization">("control"); // 현재 활성 탭
|
||||
|
||||
// 컬럼 로딩 상태 확인
|
||||
const isColumnsLoaded = fromColumns.length > 0 && toColumns.length > 0;
|
||||
|
|
@ -163,10 +167,11 @@ const MultiActionConfigStep: React.FC<MultiActionConfigStepProps> = ({
|
|||
group.actions.some((action) => action.actionType === "insert" && action.isEnabled),
|
||||
);
|
||||
|
||||
// 탭 정보 (컬럼 매핑 탭 제거)
|
||||
// 탭 정보 (흐름 미리보기 추가)
|
||||
const tabs = [
|
||||
{ id: "control" as const, label: "제어 조건", icon: "🎯", description: "전체 제어 실행 조건" },
|
||||
{ id: "actions" as const, label: "액션 설정", icon: "⚙️", description: "액션 그룹 및 실행 조건" },
|
||||
{ id: "control" as const, label: "제어 조건", description: "전체 제어 실행 조건" },
|
||||
{ id: "actions" as const, label: "액션 설정", description: "액션 그룹 및 실행 조건" },
|
||||
{ id: "visualization" as const, label: "흐름 미리보기", description: "전체 데이터 흐름을 한눈에 확인" },
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
@ -192,27 +197,16 @@ const MultiActionConfigStep: React.FC<MultiActionConfigStepProps> = ({
|
|||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<span>{tab.icon}</span>
|
||||
<span>{tab.label}</span>
|
||||
{tab.id === "actions" && (
|
||||
<Badge variant="outline" className="ml-1 text-xs">
|
||||
{actionGroups.filter((g) => g.isEnabled).length}
|
||||
</Badge>
|
||||
)}
|
||||
{tab.id === "mapping" && hasInsertActions && (
|
||||
<Badge variant="outline" className="ml-1 text-xs">
|
||||
{fieldMappings.length}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 탭 설명 */}
|
||||
<div className="bg-muted/30 mb-4 rounded-md p-3">
|
||||
<p className="text-muted-foreground text-sm">{tabs.find((tab) => tab.id === activeTab)?.description}</p>
|
||||
</div>
|
||||
|
||||
{/* 탭별 컨텐츠 */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{activeTab === "control" && (
|
||||
|
|
@ -671,6 +665,36 @@ const MultiActionConfigStep: React.FC<MultiActionConfigStepProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "visualization" && (
|
||||
<DataflowVisualization
|
||||
state={{
|
||||
fromTable,
|
||||
toTable,
|
||||
fromConnection,
|
||||
toConnection,
|
||||
fromColumns,
|
||||
toColumns,
|
||||
controlConditions,
|
||||
dataflowActions: actionGroups.flatMap((group) =>
|
||||
group.actions
|
||||
.filter((action) => action.isEnabled)
|
||||
.map((action) => ({
|
||||
...action,
|
||||
targetTable: toTable?.tableName || "",
|
||||
})),
|
||||
),
|
||||
}}
|
||||
onEdit={(step) => {
|
||||
// 편집 버튼 클릭 시 해당 탭으로 이동
|
||||
if (step === "conditions") {
|
||||
setActiveTab("control");
|
||||
} else if (step === "actions") {
|
||||
setActiveTab("actions");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 하단 네비게이션 */}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,215 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 노드 기반 플로우 에디터 메인 컴포넌트
|
||||
*/
|
||||
|
||||
import { useCallback, useRef } from "react";
|
||||
import ReactFlow, { Background, Controls, MiniMap, Panel, ReactFlowProvider, useReactFlow } from "reactflow";
|
||||
import "reactflow/dist/style.css";
|
||||
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { NodePalette } from "./sidebar/NodePalette";
|
||||
import { PropertiesPanel } from "./panels/PropertiesPanel";
|
||||
import { FlowToolbar } from "./FlowToolbar";
|
||||
import { TableSourceNode } from "./nodes/TableSourceNode";
|
||||
import { ExternalDBSourceNode } from "./nodes/ExternalDBSourceNode";
|
||||
import { ReferenceLookupNode } from "./nodes/ReferenceLookupNode";
|
||||
import { ConditionNode } from "./nodes/ConditionNode";
|
||||
import { FieldMappingNode } from "./nodes/FieldMappingNode";
|
||||
import { InsertActionNode } from "./nodes/InsertActionNode";
|
||||
import { UpdateActionNode } from "./nodes/UpdateActionNode";
|
||||
import { DeleteActionNode } from "./nodes/DeleteActionNode";
|
||||
import { UpsertActionNode } from "./nodes/UpsertActionNode";
|
||||
import { DataTransformNode } from "./nodes/DataTransformNode";
|
||||
import { RestAPISourceNode } from "./nodes/RestAPISourceNode";
|
||||
import { CommentNode } from "./nodes/CommentNode";
|
||||
import { LogNode } from "./nodes/LogNode";
|
||||
|
||||
// 노드 타입들
|
||||
const nodeTypes = {
|
||||
// 데이터 소스
|
||||
tableSource: TableSourceNode,
|
||||
externalDBSource: ExternalDBSourceNode,
|
||||
restAPISource: RestAPISourceNode,
|
||||
referenceLookup: ReferenceLookupNode,
|
||||
// 변환/조건
|
||||
condition: ConditionNode,
|
||||
fieldMapping: FieldMappingNode,
|
||||
dataTransform: DataTransformNode,
|
||||
// 액션
|
||||
insertAction: InsertActionNode,
|
||||
updateAction: UpdateActionNode,
|
||||
deleteAction: DeleteActionNode,
|
||||
upsertAction: UpsertActionNode,
|
||||
// 유틸리티
|
||||
comment: CommentNode,
|
||||
log: LogNode,
|
||||
};
|
||||
|
||||
/**
|
||||
* FlowEditor 내부 컴포넌트
|
||||
*/
|
||||
function FlowEditorInner() {
|
||||
const reactFlowWrapper = useRef<HTMLDivElement>(null);
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
|
||||
const {
|
||||
nodes,
|
||||
edges,
|
||||
onNodesChange,
|
||||
onEdgesChange,
|
||||
onConnect,
|
||||
addNode,
|
||||
showPropertiesPanel,
|
||||
selectNodes,
|
||||
selectedNodes,
|
||||
removeNodes,
|
||||
} = useFlowEditorStore();
|
||||
|
||||
/**
|
||||
* 노드 선택 변경 핸들러
|
||||
*/
|
||||
const onSelectionChange = useCallback(
|
||||
({ nodes: selectedNodes }: { nodes: any[] }) => {
|
||||
const selectedIds = selectedNodes.map((node) => node.id);
|
||||
selectNodes(selectedIds);
|
||||
console.log("🔍 선택된 노드:", selectedIds);
|
||||
},
|
||||
[selectNodes],
|
||||
);
|
||||
|
||||
/**
|
||||
* 키보드 이벤트 핸들러 (Delete/Backspace 키로 노드 삭제)
|
||||
*/
|
||||
const onKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
if ((event.key === "Delete" || event.key === "Backspace") && selectedNodes.length > 0) {
|
||||
event.preventDefault();
|
||||
console.log("🗑️ 선택된 노드 삭제:", selectedNodes);
|
||||
removeNodes(selectedNodes);
|
||||
}
|
||||
},
|
||||
[selectedNodes, removeNodes],
|
||||
);
|
||||
|
||||
/**
|
||||
* 드래그 앤 드롭 핸들러
|
||||
*/
|
||||
const onDragOver = useCallback((event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
}, []);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const type = event.dataTransfer.getData("application/reactflow");
|
||||
if (!type) return;
|
||||
|
||||
const position = screenToFlowPosition({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
|
||||
// 🔥 노드 타입별 기본 데이터 설정
|
||||
const defaultData: any = {
|
||||
displayName: `새 ${type} 노드`,
|
||||
};
|
||||
|
||||
// 액션 노드의 경우 targetType 기본값 설정
|
||||
if (["insertAction", "updateAction", "deleteAction", "upsertAction"].includes(type)) {
|
||||
defaultData.targetType = "internal"; // 기본값: 내부 DB
|
||||
defaultData.fieldMappings = [];
|
||||
defaultData.options = {};
|
||||
|
||||
if (type === "updateAction" || type === "deleteAction") {
|
||||
defaultData.whereConditions = [];
|
||||
}
|
||||
|
||||
if (type === "upsertAction") {
|
||||
defaultData.conflictKeys = [];
|
||||
}
|
||||
}
|
||||
|
||||
const newNode: any = {
|
||||
id: `node_${Date.now()}`,
|
||||
type,
|
||||
position,
|
||||
data: defaultData,
|
||||
};
|
||||
|
||||
addNode(newNode);
|
||||
},
|
||||
[screenToFlowPosition, addNode],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full">
|
||||
{/* 좌측 노드 팔레트 */}
|
||||
<div className="w-[250px] border-r bg-white">
|
||||
<NodePalette />
|
||||
</div>
|
||||
|
||||
{/* 중앙 캔버스 */}
|
||||
<div className="relative flex-1" ref={reactFlowWrapper} onKeyDown={onKeyDown} tabIndex={0}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onSelectionChange={onSelectionChange}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
className="bg-gray-50"
|
||||
deleteKeyCode={["Delete", "Backspace"]}
|
||||
>
|
||||
{/* 배경 그리드 */}
|
||||
<Background gap={16} size={1} color="#E5E7EB" />
|
||||
|
||||
{/* 컨트롤 버튼 */}
|
||||
<Controls className="bg-white shadow-md" />
|
||||
|
||||
{/* 미니맵 */}
|
||||
<MiniMap
|
||||
className="bg-white shadow-md"
|
||||
nodeColor={(node) => {
|
||||
// 노드 타입별 색상 (추후 구현)
|
||||
return "#3B82F6";
|
||||
}}
|
||||
maskColor="rgba(0, 0, 0, 0.1)"
|
||||
/>
|
||||
|
||||
{/* 상단 툴바 */}
|
||||
<Panel position="top-center" className="pointer-events-auto">
|
||||
<FlowToolbar />
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
{/* 우측 속성 패널 */}
|
||||
{showPropertiesPanel && (
|
||||
<div className="w-[350px] border-l bg-white">
|
||||
<PropertiesPanel />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* FlowEditor 메인 컴포넌트 (Provider로 감싸기)
|
||||
*/
|
||||
export function FlowEditor() {
|
||||
return (
|
||||
<div className="h-[calc(100vh-200px)] min-h-[700px] w-full">
|
||||
<ReactFlowProvider>
|
||||
<FlowEditorInner />
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 플로우 에디터 상단 툴바
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Play, Save, FileCheck, Undo2, Redo2, ZoomIn, ZoomOut, FolderOpen, Download, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { useReactFlow } from "reactflow";
|
||||
import { LoadFlowDialog } from "./dialogs/LoadFlowDialog";
|
||||
import { getNodeFlow } from "@/lib/api/nodeFlows";
|
||||
|
||||
export function FlowToolbar() {
|
||||
const { zoomIn, zoomOut, fitView } = useReactFlow();
|
||||
const {
|
||||
flowName,
|
||||
setFlowName,
|
||||
validateFlow,
|
||||
saveFlow,
|
||||
exportFlow,
|
||||
isExecuting,
|
||||
isSaving,
|
||||
selectedNodes,
|
||||
removeNodes,
|
||||
} = useFlowEditorStore();
|
||||
const [showLoadDialog, setShowLoadDialog] = useState(false);
|
||||
|
||||
const handleValidate = () => {
|
||||
const result = validateFlow();
|
||||
if (result.valid) {
|
||||
alert("✅ 검증 성공! 오류가 없습니다.");
|
||||
} else {
|
||||
alert(`❌ 검증 실패\n\n${result.errors.map((e) => `- ${e.message}`).join("\n")}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const result = await saveFlow();
|
||||
if (result.success) {
|
||||
alert(`✅ ${result.message}\nFlow ID: ${result.flowId}`);
|
||||
} else {
|
||||
alert(`❌ 저장 실패\n\n${result.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const json = exportFlow();
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${flowName || "flow"}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
alert("✅ JSON 파일로 내보내기 완료!");
|
||||
};
|
||||
|
||||
const handleLoad = async (flowId: number) => {
|
||||
try {
|
||||
const flow = await getNodeFlow(flowId);
|
||||
|
||||
// flowData가 이미 객체인지 문자열인지 확인
|
||||
const parsedData = typeof flow.flowData === "string" ? JSON.parse(flow.flowData) : flow.flowData;
|
||||
|
||||
// Zustand 스토어의 loadFlow 함수 호출
|
||||
useFlowEditorStore
|
||||
.getState()
|
||||
.loadFlow(flow.flowId, flow.flowName, flow.flowDescription, parsedData.nodes, parsedData.edges);
|
||||
alert(`✅ "${flow.flowName}" 플로우를 불러왔습니다!`);
|
||||
} catch (error) {
|
||||
console.error("플로우 불러오기 오류:", error);
|
||||
alert(error instanceof Error ? error.message : "플로우를 불러올 수 없습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecute = () => {
|
||||
// TODO: 실행 로직 구현
|
||||
alert("실행 기능 구현 예정");
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (selectedNodes.length === 0) {
|
||||
alert("삭제할 노드를 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirm(`선택된 ${selectedNodes.length}개 노드를 삭제하시겠습니까?`)) {
|
||||
removeNodes(selectedNodes);
|
||||
alert(`✅ ${selectedNodes.length}개 노드가 삭제되었습니다.`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoadFlowDialog open={showLoadDialog} onOpenChange={setShowLoadDialog} onLoad={handleLoad} />
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-white p-2 shadow-md">
|
||||
{/* 플로우 이름 */}
|
||||
<Input
|
||||
value={flowName}
|
||||
onChange={(e) => setFlowName(e.target.value)}
|
||||
className="h-8 w-[200px] text-sm"
|
||||
placeholder="플로우 이름"
|
||||
/>
|
||||
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
|
||||
{/* 실행 취소/다시 실행 */}
|
||||
<Button variant="ghost" size="sm" title="실행 취소" disabled>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" title="다시 실행" disabled>
|
||||
<Redo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
disabled={selectedNodes.length === 0}
|
||||
title={selectedNodes.length > 0 ? `${selectedNodes.length}개 노드 삭제` : "삭제할 노드를 선택하세요"}
|
||||
className="gap-1 text-red-600 hover:bg-red-50 hover:text-red-700 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{selectedNodes.length > 0 && <span className="text-xs">({selectedNodes.length})</span>}
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
|
||||
{/* 줌 컨트롤 */}
|
||||
<Button variant="ghost" size="sm" onClick={() => zoomIn()} title="확대">
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => zoomOut()} title="축소">
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => fitView()} title="전체 보기">
|
||||
<span className="text-xs">전체</span>
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
|
||||
{/* 불러오기 */}
|
||||
<Button variant="outline" size="sm" onClick={() => setShowLoadDialog(true)} className="gap-1">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<span className="text-xs">불러오기</span>
|
||||
</Button>
|
||||
|
||||
{/* 저장 */}
|
||||
<Button variant="outline" size="sm" onClick={handleSave} disabled={isSaving} className="gap-1">
|
||||
<Save className="h-4 w-4" />
|
||||
<span className="text-xs">{isSaving ? "저장 중..." : "저장"}</span>
|
||||
</Button>
|
||||
|
||||
{/* 내보내기 */}
|
||||
<Button variant="outline" size="sm" onClick={handleExport} className="gap-1">
|
||||
<Download className="h-4 w-4" />
|
||||
<span className="text-xs">JSON</span>
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
|
||||
{/* 검증 */}
|
||||
<Button variant="outline" size="sm" onClick={handleValidate} className="gap-1">
|
||||
<FileCheck className="h-4 w-4" />
|
||||
<span className="text-xs">검증</span>
|
||||
</Button>
|
||||
|
||||
{/* 테스트 실행 */}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleExecute}
|
||||
disabled={isExecuting}
|
||||
className="gap-1 bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
<span className="text-xs">{isExecuting ? "실행 중..." : "테스트 실행"}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 플로우 불러오기 다이얼로그
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Loader2, FileJson, Calendar, Trash2 } from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { getNodeFlows, deleteNodeFlow } from "@/lib/api/nodeFlows";
|
||||
|
||||
interface Flow {
|
||||
flowId: number;
|
||||
flowName: string;
|
||||
flowDescription: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface LoadFlowDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onLoad: (flowId: number) => void;
|
||||
}
|
||||
|
||||
export function LoadFlowDialog({ open, onOpenChange, onLoad }: LoadFlowDialogProps) {
|
||||
const [flows, setFlows] = useState<Flow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedFlowId, setSelectedFlowId] = useState<number | null>(null);
|
||||
const [deleting, setDeleting] = useState<number | null>(null);
|
||||
|
||||
// 플로우 목록 조회
|
||||
const fetchFlows = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const flows = await getNodeFlows();
|
||||
setFlows(flows);
|
||||
} catch (error) {
|
||||
console.error("플로우 목록 조회 오류:", error);
|
||||
alert(error instanceof Error ? error.message : "플로우 목록을 불러올 수 없습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 플로우 삭제
|
||||
const handleDelete = async (flowId: number, flowName: string) => {
|
||||
if (!confirm(`"${flowName}" 플로우를 삭제하시겠습니까?\n\n이 작업은 되돌릴 수 없습니다.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleting(flowId);
|
||||
try {
|
||||
await deleteNodeFlow(flowId);
|
||||
alert("✅ 플로우가 삭제되었습니다.");
|
||||
fetchFlows(); // 목록 새로고침
|
||||
} catch (error) {
|
||||
console.error("플로우 삭제 오류:", error);
|
||||
alert(error instanceof Error ? error.message : "플로우를 삭제할 수 없습니다.");
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 플로우 불러오기
|
||||
const handleLoad = () => {
|
||||
if (selectedFlowId === null) {
|
||||
alert("불러올 플로우를 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
onLoad(selectedFlowId);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
// 다이얼로그 열릴 때 목록 조회
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchFlows();
|
||||
setSelectedFlowId(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// 날짜 포맷팅
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat("ko-KR", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>플로우 불러오기</DialogTitle>
|
||||
<DialogDescription>저장된 플로우를 선택하여 불러옵니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : flows.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<FileJson className="mx-auto mb-4 h-12 w-12 text-gray-300" />
|
||||
<p className="text-sm text-gray-500">저장된 플로우가 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-[400px]">
|
||||
<div className="space-y-2 pr-4">
|
||||
{flows.map((flow) => (
|
||||
<div
|
||||
key={flow.flowId}
|
||||
className={`cursor-pointer rounded-lg border-2 p-4 transition-all hover:border-blue-300 hover:bg-blue-50 ${
|
||||
selectedFlowId === flow.flowId ? "border-blue-500 bg-blue-50" : "border-gray-200 bg-white"
|
||||
}`}
|
||||
onClick={() => setSelectedFlowId(flow.flowId)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-gray-900">{flow.flowName}</h3>
|
||||
<span className="text-xs text-gray-400">#{flow.flowId}</span>
|
||||
</div>
|
||||
{flow.flowDescription && <p className="mt-1 text-sm text-gray-600">{flow.flowDescription}</p>}
|
||||
<div className="mt-2 flex items-center gap-4 text-xs text-gray-500">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>수정: {formatDate(flow.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(flow.flowId, flow.flowName);
|
||||
}}
|
||||
disabled={deleting === flow.flowId}
|
||||
className="text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
>
|
||||
{deleting === flow.flowId ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleLoad} disabled={selectedFlowId === null || loading}>
|
||||
불러오기
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 주석 노드 - 플로우 설명용
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { NodeProps } from "reactflow";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import type { CommentNodeData } from "@/types/node-editor";
|
||||
|
||||
export const CommentNode = memo(({ data, selected }: NodeProps<CommentNodeData>) => {
|
||||
return (
|
||||
<div
|
||||
className={`max-w-[350px] min-w-[200px] rounded-lg border-2 border-dashed bg-yellow-50 shadow-sm transition-all ${
|
||||
selected ? "border-yellow-500 shadow-md" : "border-yellow-300"
|
||||
}`}
|
||||
>
|
||||
<div className="p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4 text-yellow-600" />
|
||||
<span className="text-xs font-semibold text-yellow-800">메모</span>
|
||||
</div>
|
||||
<div className="text-sm whitespace-pre-wrap text-gray-700">{data.content || "메모를 입력하세요..."}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
CommentNode.displayName = "CommentNode";
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 조건 분기 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Zap, Check, X } from "lucide-react";
|
||||
import type { ConditionNodeData } from "@/types/node-editor";
|
||||
|
||||
const OPERATOR_LABELS: Record<string, string> = {
|
||||
EQUALS: "=",
|
||||
NOT_EQUALS: "≠",
|
||||
GREATER_THAN: ">",
|
||||
LESS_THAN: "<",
|
||||
GREATER_THAN_OR_EQUAL: "≥",
|
||||
LESS_THAN_OR_EQUAL: "≤",
|
||||
LIKE: "포함",
|
||||
NOT_LIKE: "미포함",
|
||||
IN: "IN",
|
||||
NOT_IN: "NOT IN",
|
||||
IS_NULL: "NULL",
|
||||
IS_NOT_NULL: "NOT NULL",
|
||||
};
|
||||
|
||||
export const ConditionNode = memo(({ data, selected }: NodeProps<ConditionNodeData>) => {
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[280px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-yellow-500 shadow-lg" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* 입력 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !border-2 !border-yellow-500 !bg-white" />
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-yellow-500 px-3 py-2 text-white">
|
||||
<Zap className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">조건 검사</div>
|
||||
<div className="text-xs opacity-80">{data.displayName || "조건 분기"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
{data.conditions && data.conditions.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-gray-700">조건식: ({data.conditions.length}개)</div>
|
||||
<div className="max-h-[150px] space-y-1.5 overflow-y-auto">
|
||||
{data.conditions.slice(0, 4).map((condition, idx) => (
|
||||
<div key={idx} className="rounded bg-yellow-50 px-2 py-1.5 text-xs">
|
||||
{idx > 0 && (
|
||||
<div className="mb-1 text-center text-xs font-semibold text-yellow-600">{data.logic}</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-mono text-gray-700">{condition.field}</span>
|
||||
<span className="rounded bg-yellow-200 px-1 py-0.5 text-yellow-800">
|
||||
{OPERATOR_LABELS[condition.operator] || condition.operator}
|
||||
</span>
|
||||
{condition.value !== null && condition.value !== undefined && (
|
||||
<span className="text-gray-600">
|
||||
{typeof condition.value === "string" ? `"${condition.value}"` : String(condition.value)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{data.conditions.length > 4 && (
|
||||
<div className="text-xs text-gray-400">... 외 {data.conditions.length - 4}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-xs text-gray-400">조건 없음</div>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ConditionNode.displayName = "ConditionNode";
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 데이터 변환 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Wand2, ArrowRight } from "lucide-react";
|
||||
import type { DataTransformNodeData } from "@/types/node-editor";
|
||||
|
||||
export const DataTransformNode = memo(({ data, selected }: NodeProps<DataTransformNodeData>) => {
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-orange-500 shadow-lg" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-indigo-600 px-3 py-2 text-white">
|
||||
<Wand2 className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || "데이터 변환"}</div>
|
||||
<div className="text-xs opacity-80">{data.transformations?.length || 0}개 변환</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
{data.transformations && data.transformations.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{data.transformations.slice(0, 3).map((transform, idx) => {
|
||||
const sourceLabel = transform.sourceFieldLabel || transform.sourceField || "소스";
|
||||
const targetField = transform.targetField || transform.sourceField;
|
||||
const targetLabel = transform.targetFieldLabel || targetField;
|
||||
const isInPlace = !transform.targetField || transform.targetField === transform.sourceField;
|
||||
|
||||
return (
|
||||
<div key={idx} className="rounded bg-indigo-50 p-2">
|
||||
<div className="mb-1 flex items-center gap-2 text-xs">
|
||||
<span className="font-medium text-indigo-700">{transform.type}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">
|
||||
{sourceLabel}
|
||||
<span className="mx-1 text-gray-400">→</span>
|
||||
{isInPlace ? (
|
||||
<span className="font-medium text-indigo-600">(자기자신)</span>
|
||||
) : (
|
||||
<span>{targetLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 타입별 추가 정보 */}
|
||||
{transform.type === "EXPLODE" && transform.delimiter && (
|
||||
<div className="mt-1 text-xs text-gray-500">구분자: {transform.delimiter}</div>
|
||||
)}
|
||||
{transform.type === "CONCAT" && transform.separator && (
|
||||
<div className="mt-1 text-xs text-gray-500">구분자: {transform.separator}</div>
|
||||
)}
|
||||
{transform.type === "REPLACE" && (
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
"{transform.searchValue}" → "{transform.replaceValue}"
|
||||
</div>
|
||||
)}
|
||||
{transform.expression && (
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
<code className="rounded bg-white px-1 py-0.5">{transform.expression}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{data.transformations.length > 3 && (
|
||||
<div className="text-xs text-gray-400">... 외 {data.transformations.length - 3}개</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4 text-center text-xs text-gray-400">변환 규칙 없음</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !bg-indigo-500" />
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-indigo-500" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
DataTransformNode.displayName = "DataTransformNode";
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* DELETE 액션 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Trash2, AlertTriangle } from "lucide-react";
|
||||
import type { DeleteActionNodeData } from "@/types/node-editor";
|
||||
|
||||
export const DeleteActionNode = memo(({ data, selected }: NodeProps<DeleteActionNodeData>) => {
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-red-500 shadow-lg" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* 입력 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !border-2 !border-red-500 !bg-white" />
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-red-500 px-3 py-2 text-white">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">DELETE</div>
|
||||
<div className="text-xs opacity-80">{data.displayName || data.targetTable}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
<div className="mb-2 text-xs font-medium text-gray-500">타겟: {data.targetTable}</div>
|
||||
|
||||
{/* WHERE 조건 */}
|
||||
{data.whereConditions && data.whereConditions.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-gray-700">WHERE 조건:</div>
|
||||
<div className="max-h-[120px] space-y-1 overflow-y-auto">
|
||||
{data.whereConditions.map((condition, idx) => (
|
||||
<div key={idx} className="rounded bg-red-50 px-2 py-1 text-xs">
|
||||
<span className="font-mono text-gray-700">{condition.field}</span>
|
||||
<span className="mx-1 text-red-600">{condition.operator}</span>
|
||||
<span className="text-gray-600">{condition.sourceField || condition.staticValue || "?"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded bg-yellow-50 p-2 text-xs text-yellow-700">⚠️ 조건 없음 - 모든 데이터 삭제 주의!</div>
|
||||
)}
|
||||
|
||||
{/* 경고 메시지 */}
|
||||
<div className="mt-3 flex items-start gap-2 rounded border border-red-200 bg-red-50 p-2">
|
||||
<AlertTriangle className="h-3 w-3 flex-shrink-0 text-red-600" />
|
||||
<div className="text-xs text-red-700">
|
||||
<div className="font-medium">주의</div>
|
||||
<div className="mt-0.5">삭제된 데이터는 복구할 수 없습니다</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 옵션 */}
|
||||
{data.options?.requireConfirmation && (
|
||||
<div className="mt-2">
|
||||
<span className="rounded bg-red-100 px-1.5 py-0.5 text-xs text-red-700">실행 전 확인 필요</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 출력 핸들 */}
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !border-2 !border-red-500 !bg-white" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
DeleteActionNode.displayName = "DeleteActionNode";
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 외부 DB 소스 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Plug } from "lucide-react";
|
||||
import type { ExternalDBSourceNodeData } from "@/types/node-editor";
|
||||
|
||||
const DB_TYPE_COLORS: Record<string, string> = {
|
||||
PostgreSQL: "#336791",
|
||||
MySQL: "#4479A1",
|
||||
Oracle: "#F80000",
|
||||
MSSQL: "#CC2927",
|
||||
MariaDB: "#003545",
|
||||
};
|
||||
|
||||
const DB_TYPE_ICONS: Record<string, string> = {
|
||||
PostgreSQL: "🐘",
|
||||
MySQL: "🐬",
|
||||
Oracle: "🔴",
|
||||
MSSQL: "🟦",
|
||||
MariaDB: "🦭",
|
||||
};
|
||||
|
||||
export const ExternalDBSourceNode = memo(({ data, selected }: NodeProps<ExternalDBSourceNodeData>) => {
|
||||
const dbColor = (data.dbType && DB_TYPE_COLORS[data.dbType]) || "#F59E0B";
|
||||
const dbIcon = (data.dbType && DB_TYPE_ICONS[data.dbType]) || "🔌";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-orange-500 shadow-lg" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg px-3 py-2 text-white" style={{ backgroundColor: dbColor }}>
|
||||
<Plug className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || data.connectionName}</div>
|
||||
<div className="text-xs opacity-80">{data.tableName}</div>
|
||||
</div>
|
||||
<span className="text-lg">{dbIcon}</span>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
<div className="mb-2 flex items-center gap-1 text-xs">
|
||||
<div className="rounded bg-orange-100 px-2 py-0.5 font-medium text-orange-700">{data.dbType || "DB"}</div>
|
||||
<div className="flex-1 text-gray-500">외부 DB</div>
|
||||
</div>
|
||||
|
||||
{/* 필드 목록 */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-gray-700">출력 필드:</div>
|
||||
<div className="max-h-[150px] overflow-y-auto">
|
||||
{data.fields && data.fields.length > 0 ? (
|
||||
data.fields.slice(0, 5).map((field) => (
|
||||
<div key={field.name} className="flex items-center gap-2 text-xs text-gray-600">
|
||||
<div className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: dbColor }} />
|
||||
<span className="font-mono">{field.name}</span>
|
||||
<span className="text-gray-400">({field.type})</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-xs text-gray-400">필드 없음</div>
|
||||
)}
|
||||
{data.fields && data.fields.length > 5 && (
|
||||
<div className="text-xs text-gray-400">... 외 {data.fields.length - 5}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 출력 핸들 */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!h-3 !w-3 !border-2 !bg-white"
|
||||
style={{ borderColor: dbColor }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ExternalDBSourceNode.displayName = "ExternalDBSourceNode";
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 필드 매핑 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { ArrowLeftRight } from "lucide-react";
|
||||
import type { FieldMappingNodeData } from "@/types/node-editor";
|
||||
|
||||
export const FieldMappingNode = memo(({ data, selected }: NodeProps<FieldMappingNodeData>) => {
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-purple-500 shadow-lg" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* 입력 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !border-2 !border-purple-500 !bg-white" />
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-purple-500 px-3 py-2 text-white">
|
||||
<ArrowLeftRight className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">필드 매핑</div>
|
||||
<div className="text-xs opacity-80">{data.displayName || "데이터 매핑"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
{data.mappings && data.mappings.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-gray-700">매핑 규칙: ({data.mappings.length}개)</div>
|
||||
<div className="max-h-[150px] space-y-1 overflow-y-auto">
|
||||
{data.mappings.slice(0, 5).map((mapping) => (
|
||||
<div key={mapping.id} className="rounded bg-gray-50 px-2 py-1 text-xs">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-gray-600">{mapping.sourceField || "정적값"}</span>
|
||||
<span className="text-purple-500">→</span>
|
||||
<span className="font-mono text-gray-700">{mapping.targetField}</span>
|
||||
</div>
|
||||
{mapping.transform && <div className="mt-0.5 text-xs text-gray-400">변환: {mapping.transform}</div>}
|
||||
{mapping.staticValue !== undefined && (
|
||||
<div className="mt-0.5 text-xs text-gray-400">값: {String(mapping.staticValue)}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{data.mappings.length > 5 && (
|
||||
<div className="text-xs text-gray-400">... 외 {data.mappings.length - 5}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-xs text-gray-400">매핑 규칙 없음</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 출력 핸들 */}
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !border-2 !border-purple-500 !bg-white" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
FieldMappingNode.displayName = "FieldMappingNode";
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* INSERT 액션 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Plus } from "lucide-react";
|
||||
import type { InsertActionNodeData } from "@/types/node-editor";
|
||||
|
||||
export const InsertActionNode = memo(({ data, selected }: NodeProps<InsertActionNodeData>) => {
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-green-500 shadow-lg" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* 입력 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !border-2 !border-green-500 !bg-white" />
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-green-500 px-3 py-2 text-white">
|
||||
<Plus className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">INSERT</div>
|
||||
<div className="text-xs opacity-80">{data.displayName || data.targetTable}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
<div className="mb-2 text-xs font-medium text-gray-500">
|
||||
타겟: {data.displayName || data.targetTable}
|
||||
{data.targetTable && data.displayName && data.displayName !== data.targetTable && (
|
||||
<span className="ml-1 font-mono text-gray-400">({data.targetTable})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 필드 매핑 */}
|
||||
{data.fieldMappings && data.fieldMappings.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-gray-700">삽입 필드:</div>
|
||||
<div className="max-h-[120px] space-y-1 overflow-y-auto">
|
||||
{data.fieldMappings.slice(0, 4).map((mapping, idx) => (
|
||||
<div key={idx} className="rounded bg-gray-50 px-2 py-1 text-xs">
|
||||
<span className="text-gray-600">
|
||||
{mapping.sourceFieldLabel || mapping.sourceField || mapping.staticValue || "?"}
|
||||
</span>
|
||||
<span className="mx-1 text-gray-400">→</span>
|
||||
<span className="font-mono text-gray-700">{mapping.targetFieldLabel || mapping.targetField}</span>
|
||||
</div>
|
||||
))}
|
||||
{data.fieldMappings.length > 4 && (
|
||||
<div className="text-xs text-gray-400">... 외 {data.fieldMappings.length - 4}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 옵션 */}
|
||||
{data.options && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{data.options.ignoreDuplicates && (
|
||||
<span className="rounded bg-green-100 px-1.5 py-0.5 text-xs text-green-700">중복 무시</span>
|
||||
)}
|
||||
{data.options.batchSize && (
|
||||
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700">
|
||||
배치 {data.options.batchSize}건
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 출력 핸들 */}
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !border-2 !border-green-500 !bg-white" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
InsertActionNode.displayName = "InsertActionNode";
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 로그 노드 - 디버깅 및 모니터링용
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { FileText, AlertCircle, Info, AlertTriangle } from "lucide-react";
|
||||
import type { LogNodeData } from "@/types/node-editor";
|
||||
|
||||
const LOG_LEVEL_CONFIG = {
|
||||
debug: { icon: Info, color: "text-blue-600", bg: "bg-blue-50", border: "border-blue-200" },
|
||||
info: { icon: Info, color: "text-green-600", bg: "bg-green-50", border: "border-green-200" },
|
||||
warn: { icon: AlertTriangle, color: "text-yellow-600", bg: "bg-yellow-50", border: "border-yellow-200" },
|
||||
error: { icon: AlertCircle, color: "text-red-600", bg: "bg-red-50", border: "border-red-200" },
|
||||
};
|
||||
|
||||
export const LogNode = memo(({ data, selected }: NodeProps<LogNodeData>) => {
|
||||
const config = LOG_LEVEL_CONFIG[data.level] || LOG_LEVEL_CONFIG.info;
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[200px] rounded-lg border-2 bg-white shadow-sm transition-all ${
|
||||
selected ? `${config.border} shadow-md` : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className={`flex items-center gap-2 rounded-t-lg ${config.bg} px-3 py-2`}>
|
||||
<FileText className={`h-4 w-4 ${config.color}`} />
|
||||
<div className="flex-1">
|
||||
<div className={`text-sm font-semibold ${config.color}`}>로그</div>
|
||||
<div className="text-xs text-gray-600">{data.level.toUpperCase()}</div>
|
||||
</div>
|
||||
<Icon className={`h-4 w-4 ${config.color}`} />
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
{data.message ? (
|
||||
<div className="text-sm text-gray-700">{data.message}</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-400">로그 메시지 없음</div>
|
||||
)}
|
||||
|
||||
{data.includeData && (
|
||||
<div className="mt-2 rounded bg-gray-50 px-2 py-1 text-xs text-gray-600">✓ 데이터 포함</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !bg-gray-400" />
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-gray-400" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
LogNode.displayName = "LogNode";
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 참조 테이블 조회 노드 (내부 DB 전용)
|
||||
* 다른 테이블에서 데이터를 조회하여 조건 비교나 필드 매핑에 사용
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Link2, Database } from "lucide-react";
|
||||
import type { ReferenceLookupNodeData } from "@/types/node-editor";
|
||||
|
||||
export const ReferenceLookupNode = memo(({ data, selected }: NodeProps<ReferenceLookupNodeData>) => {
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-purple-500 shadow-lg" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-purple-500 px-3 py-2 text-white">
|
||||
<Link2 className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || "참조 조회"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
<div className="mb-2 flex items-center gap-1 text-xs font-medium text-gray-500">
|
||||
<Database className="h-3 w-3" />
|
||||
내부 DB 참조
|
||||
</div>
|
||||
|
||||
{/* 참조 테이블 */}
|
||||
{data.referenceTable && (
|
||||
<div className="mb-3 rounded bg-purple-50 p-2">
|
||||
<div className="text-xs font-medium text-purple-700">📋 참조 테이블</div>
|
||||
<div className="mt-1 font-mono text-xs text-purple-900">
|
||||
{data.referenceTableLabel || data.referenceTable}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 조인 조건 */}
|
||||
{data.joinConditions && data.joinConditions.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="text-xs font-medium text-gray-700">🔗 조인 조건:</div>
|
||||
<div className="mt-1 space-y-1">
|
||||
{data.joinConditions.map((join, idx) => (
|
||||
<div key={idx} className="text-xs text-gray-600">
|
||||
<span className="font-medium">{join.sourceFieldLabel || join.sourceField}</span>
|
||||
<span className="mx-1 text-purple-500">→</span>
|
||||
<span className="font-medium">{join.referenceFieldLabel || join.referenceField}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* WHERE 조건 */}
|
||||
{data.whereConditions && data.whereConditions.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="text-xs font-medium text-gray-700">⚡ WHERE 조건:</div>
|
||||
<div className="mt-1 text-xs text-gray-600">{data.whereConditions.length}개 조건</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 출력 필드 */}
|
||||
{data.outputFields && data.outputFields.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-700">📤 출력 필드:</div>
|
||||
<div className="mt-1 max-h-[100px] space-y-1 overflow-y-auto">
|
||||
{data.outputFields.slice(0, 3).map((field, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 text-xs text-gray-600">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-purple-400" />
|
||||
<span className="font-medium">{field.alias}</span>
|
||||
<span className="text-gray-400">← {field.fieldLabel || field.fieldName}</span>
|
||||
</div>
|
||||
))}
|
||||
{data.outputFields.length > 3 && (
|
||||
<div className="text-xs text-gray-400">... 외 {data.outputFields.length - 3}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 입력 핸들 (왼쪽) */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!h-3 !w-3 !border-2 !border-purple-500 !bg-white"
|
||||
/>
|
||||
|
||||
{/* 출력 핸들 (오른쪽) */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!h-3 !w-3 !border-2 !border-purple-500 !bg-white"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ReferenceLookupNode.displayName = "ReferenceLookupNode";
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* REST API 소스 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Globe, Lock } from "lucide-react";
|
||||
import type { RestAPISourceNodeData } from "@/types/node-editor";
|
||||
|
||||
const METHOD_COLORS: Record<string, string> = {
|
||||
GET: "bg-green-100 text-green-700",
|
||||
POST: "bg-blue-100 text-blue-700",
|
||||
PUT: "bg-yellow-100 text-yellow-700",
|
||||
DELETE: "bg-red-100 text-red-700",
|
||||
PATCH: "bg-purple-100 text-purple-700",
|
||||
};
|
||||
|
||||
export const RestAPISourceNode = memo(({ data, selected }: NodeProps<RestAPISourceNodeData>) => {
|
||||
const methodColor = METHOD_COLORS[data.method] || "bg-gray-100 text-gray-700";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-orange-500 shadow-lg" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-teal-600 px-3 py-2 text-white">
|
||||
<Globe className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || "REST API"}</div>
|
||||
<div className="text-xs opacity-80">{data.url || "URL 미설정"}</div>
|
||||
</div>
|
||||
{data.authentication && <Lock className="h-4 w-4 opacity-70" />}
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
{/* HTTP 메서드 */}
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className={`rounded px-2 py-1 text-xs font-semibold ${methodColor}`}>{data.method}</span>
|
||||
{data.timeout && <span className="text-xs text-gray-500">{data.timeout}ms</span>}
|
||||
</div>
|
||||
|
||||
{/* 헤더 */}
|
||||
{data.headers && Object.keys(data.headers).length > 0 && (
|
||||
<div className="mb-2">
|
||||
<div className="text-xs font-medium text-gray-700">헤더:</div>
|
||||
<div className="mt-1 space-y-1">
|
||||
{Object.entries(data.headers)
|
||||
.slice(0, 2)
|
||||
.map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-2 text-xs text-gray-600">
|
||||
<span className="font-mono">{key}:</span>
|
||||
<span className="truncate text-gray-500">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
{Object.keys(data.headers).length > 2 && (
|
||||
<div className="text-xs text-gray-400">... 외 {Object.keys(data.headers).length - 2}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 응답 매핑 */}
|
||||
{data.responseMapping && (
|
||||
<div className="rounded bg-teal-50 px-2 py-1 text-xs text-teal-700">
|
||||
응답 경로: <code className="font-mono">{data.responseMapping}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 핸들 */}
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-teal-500" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
RestAPISourceNode.displayName = "RestAPISourceNode";
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 테이블 소스 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Database } from "lucide-react";
|
||||
import type { TableSourceNodeData } from "@/types/node-editor";
|
||||
|
||||
export const TableSourceNode = memo(({ data, selected }: NodeProps<TableSourceNodeData>) => {
|
||||
// 디버깅: 필드 데이터 확인
|
||||
if (data.fields && data.fields.length > 0) {
|
||||
console.log("🔍 TableSource 필드 데이터:", data.fields);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-blue-500 shadow-lg" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-blue-500 px-3 py-2 text-white">
|
||||
<Database className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || data.tableName || "테이블 소스"}</div>
|
||||
{data.tableName && data.displayName !== data.tableName && (
|
||||
<div className="text-xs opacity-80">{data.tableName}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
<div className="mb-2 text-xs font-medium text-gray-500">📍 내부 데이터베이스</div>
|
||||
|
||||
{/* 필드 목록 */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-gray-700">출력 필드:</div>
|
||||
<div className="max-h-[150px] overflow-y-auto">
|
||||
{data.fields && data.fields.length > 0 ? (
|
||||
data.fields.slice(0, 5).map((field) => (
|
||||
<div key={field.name} className="flex items-center gap-2 text-xs text-gray-600">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-blue-400" />
|
||||
<span className="font-medium">{field.label || field.displayName || field.name}</span>
|
||||
{(field.label || field.displayName) && field.label !== field.name && (
|
||||
<span className="font-mono text-gray-400">({field.name})</span>
|
||||
)}
|
||||
<span className="text-gray-400">{field.type}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-xs text-gray-400">필드 없음</div>
|
||||
)}
|
||||
{data.fields && data.fields.length > 5 && (
|
||||
<div className="text-xs text-gray-400">... 외 {data.fields.length - 5}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 출력 핸들 */}
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !border-2 !border-blue-500 !bg-white" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
TableSourceNode.displayName = "TableSourceNode";
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* UPDATE 액션 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Edit } from "lucide-react";
|
||||
import type { UpdateActionNodeData } from "@/types/node-editor";
|
||||
|
||||
export const UpdateActionNode = memo(({ data, selected }: NodeProps<UpdateActionNodeData>) => {
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-blue-500 shadow-lg" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* 입력 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !border-2 !border-blue-500 !bg-white" />
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-blue-500 px-3 py-2 text-white">
|
||||
<Edit className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">UPDATE</div>
|
||||
<div className="text-xs opacity-80">{data.displayName || data.targetTable}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
<div className="mb-2 text-xs font-medium text-gray-500">
|
||||
타겟: {data.displayName || data.targetTable}
|
||||
{data.targetTable && data.displayName && data.displayName !== data.targetTable && (
|
||||
<span className="ml-1 font-mono text-gray-400">({data.targetTable})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* WHERE 조건 */}
|
||||
{data.whereConditions && data.whereConditions.length > 0 && (
|
||||
<div className="mb-3 space-y-1">
|
||||
<div className="text-xs font-medium text-gray-700">WHERE 조건:</div>
|
||||
<div className="max-h-[80px] space-y-1 overflow-y-auto">
|
||||
{data.whereConditions.slice(0, 2).map((condition, idx) => (
|
||||
<div key={idx} className="rounded bg-blue-50 px-2 py-1 text-xs">
|
||||
<span className="font-mono text-gray-700">{condition.fieldLabel || condition.field}</span>
|
||||
<span className="mx-1 text-blue-600">{condition.operator}</span>
|
||||
<span className="text-gray-600">
|
||||
{condition.sourceFieldLabel || condition.sourceField || condition.staticValue || "?"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{data.whereConditions.length > 2 && (
|
||||
<div className="text-xs text-gray-400">... 외 {data.whereConditions.length - 2}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 필드 매핑 */}
|
||||
{data.fieldMappings && data.fieldMappings.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-gray-700">업데이트 필드:</div>
|
||||
<div className="max-h-[100px] space-y-1 overflow-y-auto">
|
||||
{data.fieldMappings.slice(0, 3).map((mapping, idx) => (
|
||||
<div key={idx} className="rounded bg-gray-50 px-2 py-1 text-xs">
|
||||
<span className="text-gray-600">
|
||||
{mapping.sourceFieldLabel || mapping.sourceField || mapping.staticValue || "?"}
|
||||
</span>
|
||||
<span className="mx-1 text-gray-400">→</span>
|
||||
<span className="font-mono text-gray-700">{mapping.targetFieldLabel || mapping.targetField}</span>
|
||||
</div>
|
||||
))}
|
||||
{data.fieldMappings.length > 3 && (
|
||||
<div className="text-xs text-gray-400">... 외 {data.fieldMappings.length - 3}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 옵션 */}
|
||||
{data.options && data.options.batchSize && (
|
||||
<div className="mt-2">
|
||||
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700">
|
||||
배치 {data.options.batchSize}건
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 출력 핸들 */}
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !border-2 !border-blue-500 !bg-white" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
UpdateActionNode.displayName = "UpdateActionNode";
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* UPSERT 액션 노드
|
||||
* INSERT와 UPDATE를 결합한 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Database, RefreshCw } from "lucide-react";
|
||||
import type { UpsertActionNodeData } from "@/types/node-editor";
|
||||
|
||||
export const UpsertActionNode = memo(({ data, selected }: NodeProps<UpsertActionNodeData>) => {
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-orange-500 shadow-lg" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-purple-600 px-3 py-2 text-white">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || "UPSERT 액션"}</div>
|
||||
<div className="text-xs opacity-80">{data.targetTable}</div>
|
||||
</div>
|
||||
<Database className="h-4 w-4 opacity-70" />
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
<div className="mb-2 text-xs font-medium text-gray-500">
|
||||
타겟: {data.displayName || data.targetTable}
|
||||
{data.targetTable && data.displayName && data.displayName !== data.targetTable && (
|
||||
<span className="ml-1 font-mono text-gray-400">({data.targetTable})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 충돌 키 */}
|
||||
{data.conflictKeys && data.conflictKeys.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<div className="text-xs font-medium text-gray-700">충돌 키:</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{data.conflictKeys.map((key, idx) => (
|
||||
<span key={idx} className="rounded bg-purple-100 px-2 py-0.5 text-xs text-purple-700">
|
||||
{data.conflictKeyLabels?.[idx] || key}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 필드 매핑 */}
|
||||
{data.fieldMappings && data.fieldMappings.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<div className="text-xs font-medium text-gray-700">필드 매핑:</div>
|
||||
<div className="mt-1 space-y-1">
|
||||
{data.fieldMappings.slice(0, 3).map((mapping, idx) => (
|
||||
<div key={idx} className="rounded bg-gray-50 px-2 py-1 text-xs">
|
||||
<span className="text-gray-600">
|
||||
{mapping.sourceFieldLabel || mapping.sourceField || mapping.staticValue || "?"}
|
||||
</span>
|
||||
<span className="mx-1 text-gray-400">→</span>
|
||||
<span className="font-mono text-gray-700">{mapping.targetFieldLabel || mapping.targetField}</span>
|
||||
</div>
|
||||
))}
|
||||
{data.fieldMappings.length > 3 && (
|
||||
<div className="text-xs text-gray-400">... 외 {data.fieldMappings.length - 3}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 옵션 */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{data.options?.updateOnConflict && (
|
||||
<span className="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700">충돌 시 업데이트</span>
|
||||
)}
|
||||
{data.options?.batchSize && (
|
||||
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
|
||||
배치: {data.options.batchSize}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !bg-purple-500" />
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-purple-500" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
UpsertActionNode.displayName = "UpsertActionNode";
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 노드 속성 편집 패널
|
||||
*/
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { TableSourceProperties } from "./properties/TableSourceProperties";
|
||||
import { ReferenceLookupProperties } from "./properties/ReferenceLookupProperties";
|
||||
import { InsertActionProperties } from "./properties/InsertActionProperties";
|
||||
import { FieldMappingProperties } from "./properties/FieldMappingProperties";
|
||||
import { ConditionProperties } from "./properties/ConditionProperties";
|
||||
import { UpdateActionProperties } from "./properties/UpdateActionProperties";
|
||||
import { DeleteActionProperties } from "./properties/DeleteActionProperties";
|
||||
import { ExternalDBSourceProperties } from "./properties/ExternalDBSourceProperties";
|
||||
import { UpsertActionProperties } from "./properties/UpsertActionProperties";
|
||||
import { DataTransformProperties } from "./properties/DataTransformProperties";
|
||||
import { RestAPISourceProperties } from "./properties/RestAPISourceProperties";
|
||||
import { CommentProperties } from "./properties/CommentProperties";
|
||||
import { LogProperties } from "./properties/LogProperties";
|
||||
import type { NodeType } from "@/types/node-editor";
|
||||
|
||||
export function PropertiesPanel() {
|
||||
const { nodes, selectedNodes, setShowPropertiesPanel } = useFlowEditorStore();
|
||||
|
||||
// 선택된 노드가 하나일 경우 해당 노드 데이터 가져오기
|
||||
const selectedNode = selectedNodes.length === 1 ? nodes.find((n) => n.id === selectedNodes[0]) : null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">속성</h3>
|
||||
{selectedNode && (
|
||||
<p className="mt-0.5 text-xs text-gray-500">{getNodeTypeLabel(selectedNode.type as NodeType)}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowPropertiesPanel(false)} className="h-6 w-6 p-0">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 내용 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{selectedNodes.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center p-4">
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
<div className="mb-2 text-2xl">📝</div>
|
||||
<p>노드를 선택하여</p>
|
||||
<p>속성을 편집하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
) : selectedNodes.length === 1 && selectedNode ? (
|
||||
<NodePropertiesRenderer node={selectedNode} />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center p-4">
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
<div className="mb-2 text-2xl">📋</div>
|
||||
<p>{selectedNodes.length}개의 노드가</p>
|
||||
<p>선택되었습니다</p>
|
||||
<p className="mt-2 text-xs">한 번에 하나의 노드만 편집할 수 있습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 노드 타입별 속성 렌더러
|
||||
*/
|
||||
function NodePropertiesRenderer({ node }: { node: any }) {
|
||||
switch (node.type) {
|
||||
case "tableSource":
|
||||
return <TableSourceProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "referenceLookup":
|
||||
return <ReferenceLookupProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "insertAction":
|
||||
return <InsertActionProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "fieldMapping":
|
||||
return <FieldMappingProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "condition":
|
||||
return <ConditionProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "updateAction":
|
||||
return <UpdateActionProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "deleteAction":
|
||||
return <DeleteActionProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "externalDBSource":
|
||||
return <ExternalDBSourceProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "upsertAction":
|
||||
return <UpsertActionProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "dataTransform":
|
||||
return <DataTransformProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "restAPISource":
|
||||
return <RestAPISourceProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "comment":
|
||||
return <CommentProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "log":
|
||||
return <LogProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="rounded border border-yellow-200 bg-yellow-50 p-4 text-sm">
|
||||
<p className="font-medium text-yellow-800">🚧 속성 편집 준비 중</p>
|
||||
<p className="mt-2 text-xs text-yellow-700">
|
||||
{getNodeTypeLabel(node.type as NodeType)} 노드의 속성 편집 UI는 곧 구현될 예정입니다.
|
||||
</p>
|
||||
<div className="mt-3 rounded bg-white p-2 text-xs">
|
||||
<p className="font-medium text-gray-700">노드 ID:</p>
|
||||
<p className="font-mono text-gray-600">{node.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 노드 타입 라벨 가져오기
|
||||
*/
|
||||
function getNodeTypeLabel(type: NodeType): string {
|
||||
const labels: Record<NodeType, string> = {
|
||||
tableSource: "테이블 소스",
|
||||
externalDBSource: "외부 DB 소스",
|
||||
restAPISource: "REST API 소스",
|
||||
condition: "조건 분기",
|
||||
fieldMapping: "필드 매핑",
|
||||
dataTransform: "데이터 변환",
|
||||
insertAction: "INSERT 액션",
|
||||
updateAction: "UPDATE 액션",
|
||||
deleteAction: "DELETE 액션",
|
||||
upsertAction: "UPSERT 액션",
|
||||
comment: "주석",
|
||||
log: "로그",
|
||||
};
|
||||
return labels[type] || type;
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { CommentNodeData } from "@/types/node-editor";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
|
||||
interface CommentPropertiesProps {
|
||||
nodeId: string;
|
||||
data: CommentNodeData;
|
||||
}
|
||||
|
||||
export function CommentProperties({ nodeId, data }: CommentPropertiesProps) {
|
||||
const { updateNode } = useFlowEditorStore();
|
||||
|
||||
const [content, setContent] = useState(data.content || "");
|
||||
|
||||
useEffect(() => {
|
||||
setContent(data.content || "");
|
||||
}, [data]);
|
||||
|
||||
const handleApply = () => {
|
||||
updateNode(nodeId, {
|
||||
content,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="flex items-center gap-2 rounded-md bg-yellow-50 p-2">
|
||||
<MessageSquare className="h-4 w-4 text-yellow-600" />
|
||||
<span className="font-semibold text-yellow-600">주석</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="content" className="text-xs">
|
||||
메모 내용
|
||||
</Label>
|
||||
<Textarea
|
||||
id="content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="플로우 설명이나 메모를 입력하세요..."
|
||||
className="mt-1 text-sm"
|
||||
rows={8}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">이 노드는 플로우 실행에 영향을 주지 않습니다.</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleApply} className="w-full">
|
||||
적용
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,396 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 조건 분기 노드 속성 편집
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import type { ConditionNodeData } from "@/types/node-editor";
|
||||
|
||||
// 필드 정의
|
||||
interface FieldDefinition {
|
||||
name: string;
|
||||
label?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
interface ConditionPropertiesProps {
|
||||
nodeId: string;
|
||||
data: ConditionNodeData;
|
||||
}
|
||||
|
||||
const OPERATORS = [
|
||||
{ value: "EQUALS", label: "같음 (=)" },
|
||||
{ value: "NOT_EQUALS", label: "같지 않음 (≠)" },
|
||||
{ value: "GREATER_THAN", label: "보다 큼 (>)" },
|
||||
{ value: "LESS_THAN", label: "보다 작음 (<)" },
|
||||
{ value: "GREATER_THAN_OR_EQUAL", label: "크거나 같음 (≥)" },
|
||||
{ value: "LESS_THAN_OR_EQUAL", label: "작거나 같음 (≤)" },
|
||||
{ value: "LIKE", label: "포함 (LIKE)" },
|
||||
{ value: "NOT_LIKE", label: "미포함 (NOT LIKE)" },
|
||||
{ value: "IN", label: "IN" },
|
||||
{ value: "NOT_IN", label: "NOT IN" },
|
||||
{ value: "IS_NULL", label: "NULL" },
|
||||
{ value: "IS_NOT_NULL", label: "NOT NULL" },
|
||||
] as const;
|
||||
|
||||
export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) {
|
||||
const { updateNode, nodes, edges } = useFlowEditorStore();
|
||||
|
||||
const [displayName, setDisplayName] = useState(data.displayName || "조건 분기");
|
||||
const [conditions, setConditions] = useState(data.conditions || []);
|
||||
const [logic, setLogic] = useState<"AND" | "OR">(data.logic || "AND");
|
||||
const [availableFields, setAvailableFields] = useState<FieldDefinition[]>([]);
|
||||
|
||||
// 데이터 변경 시 로컬 상태 업데이트
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || "조건 분기");
|
||||
setConditions(data.conditions || []);
|
||||
setLogic(data.logic || "AND");
|
||||
}, [data]);
|
||||
|
||||
// 🔥 연결된 소스 노드의 필드를 재귀적으로 수집
|
||||
useEffect(() => {
|
||||
const getAllSourceFields = (currentNodeId: string, visited: Set<string> = new Set()): FieldDefinition[] => {
|
||||
if (visited.has(currentNodeId)) return [];
|
||||
visited.add(currentNodeId);
|
||||
|
||||
const fields: FieldDefinition[] = [];
|
||||
|
||||
// 현재 노드로 들어오는 엣지 찾기
|
||||
const incomingEdges = edges.filter((e) => e.target === currentNodeId);
|
||||
|
||||
for (const edge of incomingEdges) {
|
||||
const sourceNode = nodes.find((n) => n.id === edge.source);
|
||||
if (!sourceNode) continue;
|
||||
|
||||
const sourceData = sourceNode.data as any;
|
||||
|
||||
// 소스 노드 타입별 필드 수집
|
||||
if (sourceNode.type === "tableSource") {
|
||||
// Table Source: fields 사용
|
||||
if (sourceData.fields && Array.isArray(sourceData.fields)) {
|
||||
console.log("🔍 [ConditionProperties] Table Source 필드:", sourceData.fields);
|
||||
fields.push(...sourceData.fields);
|
||||
} else {
|
||||
console.log("⚠️ [ConditionProperties] Table Source에 필드 없음:", sourceData);
|
||||
}
|
||||
} else if (sourceNode.type === "externalDBSource") {
|
||||
// External DB Source: outputFields 사용
|
||||
if (sourceData.outputFields && Array.isArray(sourceData.outputFields)) {
|
||||
console.log("🔍 [ConditionProperties] External DB 필드:", sourceData.outputFields);
|
||||
fields.push(...sourceData.outputFields);
|
||||
} else {
|
||||
console.log("⚠️ [ConditionProperties] External DB에 필드 없음:", sourceData);
|
||||
}
|
||||
} else if (sourceNode.type === "dataTransform") {
|
||||
// Data Transform: 재귀적으로 상위 노드 필드 수집
|
||||
const upperFields = getAllSourceFields(sourceNode.id, visited);
|
||||
|
||||
// Data Transform의 변환 결과 추가
|
||||
if (sourceData.transformations && Array.isArray(sourceData.transformations)) {
|
||||
const inPlaceFields = new Set<string>();
|
||||
|
||||
for (const transform of sourceData.transformations) {
|
||||
const { sourceField, targetField } = transform;
|
||||
|
||||
// In-place 변환인지 확인
|
||||
if (!targetField || targetField === sourceField) {
|
||||
inPlaceFields.add(sourceField);
|
||||
} else {
|
||||
// 새로운 필드 생성
|
||||
fields.push({ name: targetField, label: targetField });
|
||||
}
|
||||
}
|
||||
|
||||
// 원본 필드 중 in-place 변환되지 않은 것들 추가
|
||||
for (const field of upperFields) {
|
||||
if (!inPlaceFields.has(field.name)) {
|
||||
fields.push(field);
|
||||
} else {
|
||||
// In-place 변환된 필드는 원본 이름으로 유지
|
||||
fields.push(field);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fields.push(...upperFields);
|
||||
}
|
||||
} else if (
|
||||
sourceNode.type === "insertAction" ||
|
||||
sourceNode.type === "updateAction" ||
|
||||
sourceNode.type === "deleteAction" ||
|
||||
sourceNode.type === "upsertAction"
|
||||
) {
|
||||
// Action 노드: 재귀적으로 상위 노드 필드 수집
|
||||
fields.push(...getAllSourceFields(sourceNode.id, visited));
|
||||
}
|
||||
}
|
||||
|
||||
// 중복 제거
|
||||
const uniqueFields = Array.from(new Map(fields.map((f) => [f.name, f])).values());
|
||||
return uniqueFields;
|
||||
};
|
||||
|
||||
const fields = getAllSourceFields(nodeId);
|
||||
console.log("✅ [ConditionProperties] 최종 수집된 필드:", fields);
|
||||
console.log("🔍 [ConditionProperties] 현재 노드 ID:", nodeId);
|
||||
console.log(
|
||||
"🔍 [ConditionProperties] 연결된 엣지:",
|
||||
edges.filter((e) => e.target === nodeId),
|
||||
);
|
||||
setAvailableFields(fields);
|
||||
}, [nodeId, nodes, edges]);
|
||||
|
||||
const handleAddCondition = () => {
|
||||
setConditions([
|
||||
...conditions,
|
||||
{
|
||||
field: "",
|
||||
operator: "EQUALS",
|
||||
value: "",
|
||||
valueType: "static", // "static" (고정값) 또는 "field" (필드 참조)
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRemoveCondition = (index: number) => {
|
||||
setConditions(conditions.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleConditionChange = (index: number, field: string, value: any) => {
|
||||
const newConditions = [...conditions];
|
||||
newConditions[index] = { ...newConditions[index], [field]: value };
|
||||
setConditions(newConditions);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
conditions,
|
||||
logic,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="displayName" className="text-xs">
|
||||
표시 이름
|
||||
</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="노드 표시 이름"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="logic" className="text-xs">
|
||||
조건 로직
|
||||
</Label>
|
||||
<Select value={logic} onValueChange={(value: "AND" | "OR") => setLogic(value)}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="AND">AND (모두 충족)</SelectItem>
|
||||
<SelectItem value="OR">OR (하나라도 충족)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 조건식 */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">조건식</h3>
|
||||
<Button size="sm" variant="outline" onClick={handleAddCondition} className="h-7">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{conditions.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{conditions.map((condition, index) => (
|
||||
<div key={index} className="rounded border bg-yellow-50 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-yellow-700">조건 #{index + 1}</span>
|
||||
{index > 0 && (
|
||||
<span className="rounded bg-yellow-200 px-1.5 py-0.5 text-xs font-semibold text-yellow-800">
|
||||
{logic}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveCondition(index)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">필드명</Label>
|
||||
{availableFields.length > 0 ? (
|
||||
<Select
|
||||
value={condition.field}
|
||||
onValueChange={(value) => handleConditionChange(index, "field", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableFields.map((field) => (
|
||||
<SelectItem key={field.name} value={field.name}>
|
||||
{field.label || field.name}
|
||||
{field.type && <span className="ml-2 text-xs text-gray-400">({field.type})</span>}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="mt-1 rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
|
||||
소스 노드를 연결하세요
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">연산자</Label>
|
||||
<Select
|
||||
value={condition.operator}
|
||||
onValueChange={(value) => handleConditionChange(index, "operator", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OPERATORS.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{condition.operator !== "IS_NULL" && condition.operator !== "IS_NOT_NULL" && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">비교 값 타입</Label>
|
||||
<Select
|
||||
value={(condition as any).valueType || "static"}
|
||||
onValueChange={(value) => handleConditionChange(index, "valueType", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="static">고정값</SelectItem>
|
||||
<SelectItem value="field">필드 참조</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">
|
||||
{(condition as any).valueType === "field" ? "비교 필드" : "비교 값"}
|
||||
</Label>
|
||||
{(condition as any).valueType === "field" ? (
|
||||
// 필드 참조: 드롭다운으로 선택
|
||||
availableFields.length > 0 ? (
|
||||
<Select
|
||||
value={condition.value as string}
|
||||
onValueChange={(value) => handleConditionChange(index, "value", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="비교할 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableFields.map((field) => (
|
||||
<SelectItem key={field.name} value={field.name}>
|
||||
{field.label || field.name}
|
||||
{field.type && <span className="ml-2 text-xs text-gray-400">({field.type})</span>}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="mt-1 rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
|
||||
소스 노드를 연결하세요
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
// 고정값: 직접 입력
|
||||
<Input
|
||||
value={condition.value as string}
|
||||
onChange={(e) => handleConditionChange(index, "value", e.target.value)}
|
||||
placeholder="비교할 값"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded border border-dashed p-4 text-center text-xs text-gray-400">
|
||||
조건식이 없습니다. "추가" 버튼을 클릭하세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 저장 버튼 */}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSave} className="flex-1" size="sm">
|
||||
적용
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 안내 */}
|
||||
<div className="space-y-2">
|
||||
<div className="rounded bg-blue-50 p-3 text-xs text-blue-700">
|
||||
🔌 <strong>소스 노드 연결</strong>: 테이블/외부DB 노드를 연결하면 자동으로 필드 목록이 표시됩니다.
|
||||
</div>
|
||||
<div className="rounded bg-green-50 p-3 text-xs text-green-700">
|
||||
🔄 <strong>비교 값 타입</strong>:<br />• <strong>고정값</strong>: 직접 입력한 값과 비교 (예: age > 30)
|
||||
<br />• <strong>필드 참조</strong>: 다른 필드의 값과 비교 (예: 주문수량 > 재고수량)
|
||||
</div>
|
||||
<div className="rounded bg-yellow-50 p-3 text-xs text-yellow-700">
|
||||
💡 <strong>AND</strong>: 모든 조건이 참이어야 TRUE 출력
|
||||
</div>
|
||||
<div className="rounded bg-yellow-50 p-3 text-xs text-yellow-700">
|
||||
💡 <strong>OR</strong>: 하나라도 참이면 TRUE 출력
|
||||
</div>
|
||||
<div className="rounded bg-yellow-50 p-3 text-xs text-yellow-700">
|
||||
⚡ TRUE 출력은 오른쪽 위, FALSE 출력은 오른쪽 아래입니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,466 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 데이터 변환 노드 속성 편집 (개선 버전)
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Plus, Trash2, Wand2, ArrowRight } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import type { DataTransformNodeData } from "@/types/node-editor";
|
||||
|
||||
interface DataTransformPropertiesProps {
|
||||
nodeId: string;
|
||||
data: DataTransformNodeData;
|
||||
}
|
||||
|
||||
const TRANSFORM_TYPES = [
|
||||
{ value: "UPPERCASE", label: "대문자 변환", category: "기본" },
|
||||
{ value: "LOWERCASE", label: "소문자 변환", category: "기본" },
|
||||
{ value: "TRIM", label: "공백 제거", category: "기본" },
|
||||
{ value: "CONCAT", label: "문자열 결합", category: "기본" },
|
||||
{ value: "SPLIT", label: "문자열 분리", category: "기본" },
|
||||
{ value: "REPLACE", label: "문자열 치환", category: "기본" },
|
||||
{ value: "EXPLODE", label: "행 확장 (1→N)", category: "고급" },
|
||||
{ value: "CAST", label: "타입 변환", category: "고급" },
|
||||
{ value: "FORMAT", label: "형식화", category: "고급" },
|
||||
{ value: "CALCULATE", label: "계산식", category: "고급" },
|
||||
{ value: "JSON_EXTRACT", label: "JSON 추출", category: "고급" },
|
||||
{ value: "CUSTOM", label: "사용자 정의", category: "고급" },
|
||||
] as const;
|
||||
|
||||
export function DataTransformProperties({ nodeId, data }: DataTransformPropertiesProps) {
|
||||
const { updateNode, nodes, edges } = useFlowEditorStore();
|
||||
|
||||
const [displayName, setDisplayName] = useState(data.displayName || "데이터 변환");
|
||||
const [transformations, setTransformations] = useState(data.transformations || []);
|
||||
|
||||
// 소스 필드 목록 (연결된 입력 노드에서 가져오기)
|
||||
const [sourceFields, setSourceFields] = useState<Array<{ name: string; label?: string }>>([]);
|
||||
|
||||
// 데이터 변경 시 로컬 상태 업데이트
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || "데이터 변환");
|
||||
setTransformations(data.transformations || []);
|
||||
}, [data]);
|
||||
|
||||
// 연결된 소스 노드에서 필드 가져오기
|
||||
useEffect(() => {
|
||||
const inputEdges = edges.filter((edge) => edge.target === nodeId);
|
||||
const sourceNodeIds = inputEdges.map((edge) => edge.source);
|
||||
const sourceNodes = nodes.filter((node) => sourceNodeIds.includes(node.id));
|
||||
|
||||
const fields: Array<{ name: string; label?: string }> = [];
|
||||
sourceNodes.forEach((node) => {
|
||||
if (node.type === "tableSource" && node.data.fields) {
|
||||
node.data.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) => {
|
||||
fields.push({
|
||||
name: field.name,
|
||||
label: field.label || field.displayName,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
setSourceFields(fields);
|
||||
}, [nodeId, nodes, edges]);
|
||||
|
||||
const handleAddTransformation = () => {
|
||||
setTransformations([
|
||||
...transformations,
|
||||
{
|
||||
type: "UPPERCASE" as const,
|
||||
sourceField: "",
|
||||
targetField: "",
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRemoveTransformation = (index: number) => {
|
||||
const newTransformations = transformations.filter((_, i) => i !== index);
|
||||
setTransformations(newTransformations);
|
||||
|
||||
// 즉시 반영
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
transformations: newTransformations,
|
||||
});
|
||||
};
|
||||
|
||||
const handleTransformationChange = (index: number, field: string, value: any) => {
|
||||
const newTransformations = [...transformations];
|
||||
|
||||
// 필드 변경 시 라벨도 함께 저장
|
||||
if (field === "sourceField") {
|
||||
const sourceField = sourceFields.find((f) => f.name === value);
|
||||
newTransformations[index] = {
|
||||
...newTransformations[index],
|
||||
sourceField: value,
|
||||
sourceFieldLabel: sourceField?.label,
|
||||
};
|
||||
} else if (field === "targetField") {
|
||||
// 타겟 필드는 새로 생성하는 필드이므로 라벨은 사용자가 직접 입력
|
||||
newTransformations[index] = {
|
||||
...newTransformations[index],
|
||||
targetField: value,
|
||||
};
|
||||
} else {
|
||||
newTransformations[index] = { ...newTransformations[index], [field]: value };
|
||||
}
|
||||
|
||||
setTransformations(newTransformations);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
transformations,
|
||||
});
|
||||
};
|
||||
|
||||
const renderTransformationFields = (transform: any, index: number) => {
|
||||
const commonFields = (
|
||||
<>
|
||||
{/* 소스 필드 */}
|
||||
{transform.type !== "CONCAT" && (
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">소스 필드</Label>
|
||||
<Select
|
||||
value={transform.sourceField || ""}
|
||||
onValueChange={(value) => handleTransformationChange(index, "sourceField", value)}
|
||||
>
|
||||
<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>
|
||||
<Label className="text-xs text-gray-600">타겟 필드명 (선택)</Label>
|
||||
<Input
|
||||
value={transform.targetField || ""}
|
||||
onChange={(e) => handleTransformationChange(index, "targetField", e.target.value)}
|
||||
placeholder="비어있으면 소스 필드에 적용"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
{transform.targetField ? (
|
||||
transform.targetField === transform.sourceField ? (
|
||||
<span className="text-indigo-600">✓ 소스 필드를 덮어씁니다</span>
|
||||
) : (
|
||||
<span className="text-green-600">✓ 새 필드를 생성합니다</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-indigo-600">비어있음: 소스 필드를 덮어씁니다</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
// 타입별 추가 필드
|
||||
switch (transform.type) {
|
||||
case "EXPLODE":
|
||||
return (
|
||||
<>
|
||||
{commonFields}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">구분자</Label>
|
||||
<Input
|
||||
value={transform.delimiter || ","}
|
||||
onChange={(e) => handleTransformationChange(index, "delimiter", e.target.value)}
|
||||
placeholder="예: , 또는 ; 또는 |"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400">이 문자로 분리하여 여러 행으로 확장합니다</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case "CONCAT":
|
||||
return (
|
||||
<>
|
||||
{/* CONCAT은 다중 소스 필드를 지원 - 간소화 버전 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">첫 번째 필드</Label>
|
||||
<Select
|
||||
value={transform.sourceField || ""}
|
||||
onValueChange={(value) => handleTransformationChange(index, "sourceField", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="첫 번째 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceFields.map((field) => (
|
||||
<SelectItem key={field.name} value={field.name} className="text-xs">
|
||||
{field.label || field.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">구분자</Label>
|
||||
<Input
|
||||
value={transform.separator || " "}
|
||||
onChange={(e) => handleTransformationChange(index, "separator", e.target.value)}
|
||||
placeholder="예: 공백 또는 , 또는 -"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
{commonFields}
|
||||
</>
|
||||
);
|
||||
|
||||
case "SPLIT":
|
||||
return (
|
||||
<>
|
||||
{commonFields}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">구분자</Label>
|
||||
<Input
|
||||
value={transform.delimiter || ","}
|
||||
onChange={(e) => handleTransformationChange(index, "delimiter", e.target.value)}
|
||||
placeholder="예: , 또는 ; 또는 |"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">인덱스 (0부터 시작)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={transform.splitIndex !== undefined ? transform.splitIndex : 0}
|
||||
onChange={(e) => handleTransformationChange(index, "splitIndex", parseInt(e.target.value))}
|
||||
placeholder="0"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400">분리된 값 중 몇 번째를 가져올지 지정 (0=첫번째)</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case "REPLACE":
|
||||
return (
|
||||
<>
|
||||
{commonFields}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">찾을 문자열</Label>
|
||||
<Input
|
||||
value={transform.searchValue || ""}
|
||||
onChange={(e) => handleTransformationChange(index, "searchValue", e.target.value)}
|
||||
placeholder="예: old"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">바꿀 문자열</Label>
|
||||
<Input
|
||||
value={transform.replaceValue || ""}
|
||||
onChange={(e) => handleTransformationChange(index, "replaceValue", e.target.value)}
|
||||
placeholder="예: new"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case "CAST":
|
||||
return (
|
||||
<>
|
||||
{commonFields}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">변환할 타입</Label>
|
||||
<Select
|
||||
value={transform.castType || "string"}
|
||||
onValueChange={(value) => handleTransformationChange(index, "castType", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="string" className="text-xs">
|
||||
문자열 (String)
|
||||
</SelectItem>
|
||||
<SelectItem value="number" className="text-xs">
|
||||
숫자 (Number)
|
||||
</SelectItem>
|
||||
<SelectItem value="boolean" className="text-xs">
|
||||
불린 (Boolean)
|
||||
</SelectItem>
|
||||
<SelectItem value="date" className="text-xs">
|
||||
날짜 (Date)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case "CALCULATE":
|
||||
case "FORMAT":
|
||||
case "JSON_EXTRACT":
|
||||
case "CUSTOM":
|
||||
return (
|
||||
<>
|
||||
{commonFields}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">표현식</Label>
|
||||
<Input
|
||||
value={transform.expression || ""}
|
||||
onChange={(e) => handleTransformationChange(index, "expression", e.target.value)}
|
||||
placeholder="예: field1 + field2"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
{transform.type === "CALCULATE" && "계산 수식을 입력하세요 (예: field1 + field2)"}
|
||||
{transform.type === "FORMAT" && "형식 문자열을 입력하세요 (예: {0}-{1})"}
|
||||
{transform.type === "JSON_EXTRACT" && "JSON 경로를 입력하세요 (예: $.data.name)"}
|
||||
{transform.type === "CUSTOM" && "JavaScript 표현식을 입력하세요"}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
default:
|
||||
// UPPERCASE, LOWERCASE, TRIM 등
|
||||
return commonFields;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-md bg-indigo-50 p-2">
|
||||
<Wand2 className="h-4 w-4 text-indigo-600" />
|
||||
<span className="font-semibold text-indigo-600">데이터 변환</span>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
||||
<div>
|
||||
<Label htmlFor="displayName" className="text-xs">
|
||||
표시 이름
|
||||
</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="노드 표시 이름"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 변환 규칙 */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">변환 규칙</h3>
|
||||
<p className="text-xs text-gray-500">데이터를 변환할 규칙을 추가하세요</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={handleAddTransformation} className="h-7 px-2 text-xs">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
규칙 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{transformations.length === 0 ? (
|
||||
<div className="rounded border border-dashed bg-gray-50 p-4 text-center text-xs text-gray-500">
|
||||
변환 규칙을 추가하세요
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{transformations.map((transform, index) => (
|
||||
<div key={index} className="rounded border bg-indigo-50 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-indigo-700">변환 #{index + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveTransformation(index)}
|
||||
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* 변환 타입 선택 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">변환 타입</Label>
|
||||
<Select
|
||||
value={transform.type}
|
||||
onValueChange={(value) => handleTransformationChange(index, "type", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<div className="px-2 py-1 text-xs font-semibold text-gray-500">기본 변환</div>
|
||||
{TRANSFORM_TYPES.filter((t) => t.category === "기본").map((type) => (
|
||||
<SelectItem key={type.value} value={type.value} className="text-xs">
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
<div className="px-2 py-1 text-xs font-semibold text-gray-500">고급 변환</div>
|
||||
{TRANSFORM_TYPES.filter((t) => t.category === "고급").map((type) => (
|
||||
<SelectItem key={type.value} value={type.value} className="text-xs">
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 타입별 필드 렌더링 */}
|
||||
{renderTransformationFields(transform, index)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 적용 버튼 */}
|
||||
<div className="sticky bottom-0 border-t bg-white pt-3">
|
||||
<Button onClick={handleSave} className="w-full" size="sm">
|
||||
적용
|
||||
</Button>
|
||||
<p className="mt-2 text-center text-xs text-gray-500">✅ 변경 사항이 즉시 노드에 반영됩니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,722 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* DELETE 액션 노드 속성 편집
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Plus, Trash2, AlertTriangle, Database, Globe, Link2, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections";
|
||||
import type { DeleteActionNodeData } from "@/types/node-editor";
|
||||
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
|
||||
|
||||
interface DeleteActionPropertiesProps {
|
||||
nodeId: string;
|
||||
data: DeleteActionNodeData;
|
||||
}
|
||||
|
||||
const OPERATORS = [
|
||||
{ value: "EQUALS", label: "=" },
|
||||
{ value: "NOT_EQUALS", label: "≠" },
|
||||
{ value: "GREATER_THAN", label: ">" },
|
||||
{ value: "LESS_THAN", label: "<" },
|
||||
{ value: "IN", label: "IN" },
|
||||
{ value: "NOT_IN", label: "NOT IN" },
|
||||
] as const;
|
||||
|
||||
export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesProps) {
|
||||
const { updateNode, getExternalConnectionsCache } = useFlowEditorStore();
|
||||
|
||||
// 🔥 타겟 타입 상태
|
||||
const [targetType, setTargetType] = useState<"internal" | "external" | "api">(data.targetType || "internal");
|
||||
|
||||
const [displayName, setDisplayName] = useState(data.displayName || `${data.targetTable} 삭제`);
|
||||
const [targetTable, setTargetTable] = useState(data.targetTable);
|
||||
const [whereConditions, setWhereConditions] = useState(data.whereConditions || []);
|
||||
|
||||
// 🔥 외부 DB 관련 상태
|
||||
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
|
||||
const [externalConnectionsLoading, setExternalConnectionsLoading] = useState(false);
|
||||
const [selectedExternalConnectionId, setSelectedExternalConnectionId] = useState<number | undefined>(
|
||||
data.externalConnectionId,
|
||||
);
|
||||
const [externalTables, setExternalTables] = useState<ExternalTable[]>([]);
|
||||
const [externalTablesLoading, setExternalTablesLoading] = useState(false);
|
||||
const [externalTargetTable, setExternalTargetTable] = useState(data.externalTargetTable);
|
||||
const [externalColumns, setExternalColumns] = useState<ExternalColumn[]>([]);
|
||||
const [externalColumnsLoading, setExternalColumnsLoading] = useState(false);
|
||||
|
||||
// 🔥 REST API 관련 상태 (DELETE는 요청 바디 없음)
|
||||
const [apiEndpoint, setApiEndpoint] = useState(data.apiEndpoint || "");
|
||||
const [apiAuthType, setApiAuthType] = useState<"none" | "basic" | "bearer" | "apikey">(data.apiAuthType || "none");
|
||||
const [apiAuthConfig, setApiAuthConfig] = useState(data.apiAuthConfig || {});
|
||||
const [apiHeaders, setApiHeaders] = useState<Record<string, string>>(data.apiHeaders || {});
|
||||
|
||||
// 🔥 내부 DB 테이블 관련 상태
|
||||
const [tables, setTables] = useState<any[]>([]);
|
||||
const [tablesLoading, setTablesLoading] = useState(false);
|
||||
const [tablesOpen, setTablesOpen] = useState(false);
|
||||
const [selectedTableLabel, setSelectedTableLabel] = useState(data.targetTable);
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || `${data.targetTable} 삭제`);
|
||||
setTargetTable(data.targetTable);
|
||||
setWhereConditions(data.whereConditions || []);
|
||||
}, [data]);
|
||||
|
||||
// 🔥 내부 DB 테이블 목록 로딩
|
||||
useEffect(() => {
|
||||
if (targetType === "internal") {
|
||||
loadTables();
|
||||
}
|
||||
}, [targetType]);
|
||||
|
||||
// 🔥 외부 커넥션 로딩
|
||||
useEffect(() => {
|
||||
if (targetType === "external") {
|
||||
loadExternalConnections();
|
||||
}
|
||||
}, [targetType]);
|
||||
|
||||
// 🔥 외부 테이블 로딩
|
||||
useEffect(() => {
|
||||
if (targetType === "external" && selectedExternalConnectionId) {
|
||||
loadExternalTables(selectedExternalConnectionId);
|
||||
}
|
||||
}, [targetType, selectedExternalConnectionId]);
|
||||
|
||||
// 🔥 외부 컬럼 로딩
|
||||
useEffect(() => {
|
||||
if (targetType === "external" && selectedExternalConnectionId && externalTargetTable) {
|
||||
loadExternalColumns(selectedExternalConnectionId, externalTargetTable);
|
||||
}
|
||||
}, [targetType, selectedExternalConnectionId, externalTargetTable]);
|
||||
|
||||
const loadExternalConnections = async () => {
|
||||
try {
|
||||
setExternalConnectionsLoading(true);
|
||||
|
||||
const cached = getExternalConnectionsCache();
|
||||
if (cached) {
|
||||
setExternalConnections(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await getTestedExternalConnections();
|
||||
setExternalConnections(data);
|
||||
} catch (error) {
|
||||
console.error("외부 커넥션 로딩 실패:", error);
|
||||
} finally {
|
||||
setExternalConnectionsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadExternalTables = async (connectionId: number) => {
|
||||
try {
|
||||
setExternalTablesLoading(true);
|
||||
const data = await getExternalTables(connectionId);
|
||||
setExternalTables(data);
|
||||
} catch (error) {
|
||||
console.error("외부 테이블 로딩 실패:", error);
|
||||
} finally {
|
||||
setExternalTablesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadExternalColumns = async (connectionId: number, tableName: string) => {
|
||||
try {
|
||||
setExternalColumnsLoading(true);
|
||||
const data = await getExternalColumns(connectionId, tableName);
|
||||
setExternalColumns(data);
|
||||
} catch (error) {
|
||||
console.error("외부 컬럼 로딩 실패:", error);
|
||||
} finally {
|
||||
setExternalColumnsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTargetTypeChange = (newType: "internal" | "external" | "api") => {
|
||||
setTargetType(newType);
|
||||
updateNode(nodeId, {
|
||||
targetType: newType,
|
||||
targetTable: newType === "internal" ? targetTable : undefined,
|
||||
externalConnectionId: newType === "external" ? selectedExternalConnectionId : undefined,
|
||||
externalTargetTable: newType === "external" ? externalTargetTable : undefined,
|
||||
apiEndpoint: newType === "api" ? apiEndpoint : undefined,
|
||||
apiAuthType: newType === "api" ? apiAuthType : undefined,
|
||||
apiAuthConfig: newType === "api" ? apiAuthConfig : undefined,
|
||||
apiHeaders: newType === "api" ? apiHeaders : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
// 🔥 테이블 목록 로딩
|
||||
const loadTables = async () => {
|
||||
try {
|
||||
setTablesLoading(true);
|
||||
const tableList = await tableTypeApi.getTables();
|
||||
setTables(tableList);
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로딩 실패:", error);
|
||||
} finally {
|
||||
setTablesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTableSelect = (tableName: string) => {
|
||||
const selectedTable = tables.find((t: any) => t.tableName === tableName);
|
||||
const label = (selectedTable as any)?.tableLabel || selectedTable?.displayName || tableName;
|
||||
|
||||
setTargetTable(tableName);
|
||||
setSelectedTableLabel(label);
|
||||
setTablesOpen(false);
|
||||
|
||||
updateNode(nodeId, {
|
||||
targetTable: tableName,
|
||||
displayName: label,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddCondition = () => {
|
||||
setWhereConditions([
|
||||
...whereConditions,
|
||||
{
|
||||
field: "",
|
||||
operator: "EQUALS",
|
||||
value: "",
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRemoveCondition = (index: number) => {
|
||||
setWhereConditions(whereConditions.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleConditionChange = (index: number, field: string, value: any) => {
|
||||
const newConditions = [...whereConditions];
|
||||
newConditions[index] = { ...newConditions[index], [field]: value };
|
||||
setWhereConditions(newConditions);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
targetTable,
|
||||
whereConditions,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
{/* 경고 */}
|
||||
<div className="rounded-lg border-2 border-red-200 bg-red-50 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 flex-shrink-0 text-red-600" />
|
||||
<div className="text-sm">
|
||||
<p className="font-semibold text-red-800">위험한 작업입니다!</p>
|
||||
<p className="mt-1 text-xs text-red-700">
|
||||
DELETE 작업은 되돌릴 수 없습니다. WHERE 조건을 반드시 설정하세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="displayName" className="text-xs">
|
||||
표시 이름
|
||||
</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 🔥 타겟 타입 선택 */}
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium">타겟 선택</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTargetTypeChange("internal")}
|
||||
className={cn(
|
||||
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
|
||||
targetType === "internal" ? "border-blue-500 bg-blue-50" : "border-gray-200 hover:border-gray-300",
|
||||
)}
|
||||
>
|
||||
<Database className={cn("h-5 w-5", targetType === "internal" ? "text-blue-600" : "text-gray-400")} />
|
||||
<span
|
||||
className={cn("text-xs font-medium", targetType === "internal" ? "text-blue-700" : "text-gray-600")}
|
||||
>
|
||||
내부 DB
|
||||
</span>
|
||||
{targetType === "internal" && <Check className="absolute top-2 right-2 h-4 w-4 text-blue-600" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTargetTypeChange("external")}
|
||||
className={cn(
|
||||
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
|
||||
targetType === "external"
|
||||
? "border-green-500 bg-green-50"
|
||||
: "border-gray-200 hover:border-gray-300",
|
||||
)}
|
||||
>
|
||||
<Globe className={cn("h-5 w-5", targetType === "external" ? "text-green-600" : "text-gray-400")} />
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
targetType === "external" ? "text-green-700" : "text-gray-600",
|
||||
)}
|
||||
>
|
||||
외부 DB
|
||||
</span>
|
||||
{targetType === "external" && <Check className="absolute top-2 right-2 h-4 w-4 text-green-600" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTargetTypeChange("api")}
|
||||
className={cn(
|
||||
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
|
||||
targetType === "api" ? "border-purple-500 bg-purple-50" : "border-gray-200 hover:border-gray-300",
|
||||
)}
|
||||
>
|
||||
<Link2 className={cn("h-5 w-5", targetType === "api" ? "text-purple-600" : "text-gray-400")} />
|
||||
<span
|
||||
className={cn("text-xs font-medium", targetType === "api" ? "text-purple-700" : "text-gray-600")}
|
||||
>
|
||||
REST API
|
||||
</span>
|
||||
{targetType === "api" && <Check className="absolute top-2 right-2 h-4 w-4 text-purple-600" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 내부 DB: 타겟 테이블 Combobox */}
|
||||
{targetType === "internal" && (
|
||||
<div>
|
||||
<Label className="text-xs">타겟 테이블</Label>
|
||||
<Popover open={tablesOpen} onOpenChange={setTablesOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tablesOpen}
|
||||
className="mt-1 w-full justify-between"
|
||||
disabled={tablesLoading}
|
||||
>
|
||||
{tablesLoading ? (
|
||||
<span className="text-muted-foreground">로딩 중...</span>
|
||||
) : targetTable ? (
|
||||
<span className="truncate">{selectedTableLabel}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">테이블을 선택하세요</span>
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[320px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." />
|
||||
<CommandEmpty>테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
{tables.map((table: any) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={`${table.tableLabel || table.displayName} ${table.tableName}`}
|
||||
onSelect={() => handleTableSelect(table.tableName)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
targetTable === table.tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.tableLabel || table.displayName}</span>
|
||||
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 🔥 외부 DB 설정 */}
|
||||
{targetType === "external" && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs font-medium">외부 데이터베이스 커넥션</Label>
|
||||
<Select
|
||||
value={selectedExternalConnectionId?.toString()}
|
||||
onValueChange={(value) => {
|
||||
const connectionId = parseInt(value);
|
||||
const selectedConnection = externalConnections.find((c) => c.id === connectionId);
|
||||
setSelectedExternalConnectionId(connectionId);
|
||||
setExternalTargetTable("");
|
||||
setExternalColumns([]);
|
||||
updateNode(nodeId, {
|
||||
externalConnectionId: connectionId,
|
||||
externalConnectionName: selectedConnection?.name,
|
||||
externalDbType: selectedConnection?.db_type,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="커넥션을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{externalConnectionsLoading ? (
|
||||
<div className="p-2 text-center text-xs text-gray-500">로딩 중...</div>
|
||||
) : externalConnections.length === 0 ? (
|
||||
<div className="p-2 text-center text-xs text-gray-500">사용 가능한 커넥션이 없습니다</div>
|
||||
) : (
|
||||
externalConnections.map((conn) => (
|
||||
<SelectItem key={conn.id} value={conn.id!.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{conn.db_type}</span>
|
||||
<span className="text-gray-500">-</span>
|
||||
<span>{conn.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selectedExternalConnectionId && (
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs font-medium">테이블</Label>
|
||||
<Select
|
||||
value={externalTargetTable}
|
||||
onValueChange={(value) => {
|
||||
const selectedTable = externalTables.find((t) => t.table_name === value);
|
||||
setExternalTargetTable(value);
|
||||
updateNode(nodeId, {
|
||||
externalTargetTable: value,
|
||||
externalTargetSchema: selectedTable?.schema,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="테이블을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{externalTablesLoading ? (
|
||||
<div className="p-2 text-center text-xs text-gray-500">로딩 중...</div>
|
||||
) : externalTables.length === 0 ? (
|
||||
<div className="p-2 text-center text-xs text-gray-500">테이블이 없습니다</div>
|
||||
) : (
|
||||
externalTables.map((table) => (
|
||||
<SelectItem key={table.table_name} value={table.table_name}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{table.table_name}</span>
|
||||
{table.schema && <span className="text-xs text-gray-500">({table.schema})</span>}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{externalTargetTable && externalColumns.length > 0 && (
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs font-medium">컬럼 목록</Label>
|
||||
<div className="max-h-32 space-y-1 overflow-y-auto rounded border bg-gray-50 p-2">
|
||||
{externalColumns.map((col) => (
|
||||
<div key={col.column_name} className="flex items-center justify-between text-xs">
|
||||
<span className="font-medium">{col.column_name}</span>
|
||||
<span className="text-gray-500">{col.data_type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 🔥 REST API 설정 (DELETE는 간단함) */}
|
||||
{targetType === "api" && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs font-medium">API 엔드포인트</Label>
|
||||
<Input
|
||||
placeholder="https://api.example.com/v1/users/{id}"
|
||||
value={apiEndpoint}
|
||||
onChange={(e) => {
|
||||
setApiEndpoint(e.target.value);
|
||||
updateNode(nodeId, { apiEndpoint: e.target.value });
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs font-medium">인증 방식</Label>
|
||||
<Select
|
||||
value={apiAuthType}
|
||||
onValueChange={(value: "none" | "basic" | "bearer" | "apikey") => {
|
||||
setApiAuthType(value);
|
||||
updateNode(nodeId, { apiAuthType: value });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">인증 없음</SelectItem>
|
||||
<SelectItem value="bearer">Bearer Token</SelectItem>
|
||||
<SelectItem value="basic">Basic Auth</SelectItem>
|
||||
<SelectItem value="apikey">API Key</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{apiAuthType !== "none" && (
|
||||
<div className="space-y-2 rounded border bg-gray-50 p-3">
|
||||
<Label className="block text-xs font-medium">인증 정보</Label>
|
||||
|
||||
{apiAuthType === "bearer" && (
|
||||
<Input
|
||||
placeholder="Bearer Token"
|
||||
value={(apiAuthConfig as any)?.token || ""}
|
||||
onChange={(e) => {
|
||||
const newConfig = { token: e.target.value };
|
||||
setApiAuthConfig(newConfig);
|
||||
updateNode(nodeId, { apiAuthConfig: newConfig });
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
)}
|
||||
|
||||
{apiAuthType === "basic" && (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder="사용자명"
|
||||
value={(apiAuthConfig as any)?.username || ""}
|
||||
onChange={(e) => {
|
||||
const newConfig = { ...(apiAuthConfig as any), username: e.target.value };
|
||||
setApiAuthConfig(newConfig);
|
||||
updateNode(nodeId, { apiAuthConfig: newConfig });
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="비밀번호"
|
||||
value={(apiAuthConfig as any)?.password || ""}
|
||||
onChange={(e) => {
|
||||
const newConfig = { ...(apiAuthConfig as any), password: e.target.value };
|
||||
setApiAuthConfig(newConfig);
|
||||
updateNode(nodeId, { apiAuthConfig: newConfig });
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{apiAuthType === "apikey" && (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder="헤더 이름 (예: X-API-Key)"
|
||||
value={(apiAuthConfig as any)?.headerName || ""}
|
||||
onChange={(e) => {
|
||||
const newConfig = { ...(apiAuthConfig as any), headerName: e.target.value };
|
||||
setApiAuthConfig(newConfig);
|
||||
updateNode(nodeId, { apiAuthConfig: newConfig });
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Input
|
||||
placeholder="API Key"
|
||||
value={(apiAuthConfig as any)?.apiKey || ""}
|
||||
onChange={(e) => {
|
||||
const newConfig = { ...(apiAuthConfig as any), apiKey: e.target.value };
|
||||
setApiAuthConfig(newConfig);
|
||||
updateNode(nodeId, { apiAuthConfig: newConfig });
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs font-medium">커스텀 헤더 (선택사항)</Label>
|
||||
<div className="space-y-2 rounded border bg-gray-50 p-3">
|
||||
{Object.entries(apiHeaders).map(([key, value], index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
placeholder="헤더 이름"
|
||||
value={key}
|
||||
onChange={(e) => {
|
||||
const newHeaders = { ...apiHeaders };
|
||||
delete newHeaders[key];
|
||||
newHeaders[e.target.value] = value;
|
||||
setApiHeaders(newHeaders);
|
||||
updateNode(nodeId, { apiHeaders: newHeaders });
|
||||
}}
|
||||
className="h-7 flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
placeholder="헤더 값"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const newHeaders = { ...apiHeaders, [key]: e.target.value };
|
||||
setApiHeaders(newHeaders);
|
||||
updateNode(nodeId, { apiHeaders: newHeaders });
|
||||
}}
|
||||
className="h-7 flex-1 text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
const newHeaders = { ...apiHeaders };
|
||||
delete newHeaders[key];
|
||||
setApiHeaders(newHeaders);
|
||||
updateNode(nodeId, { apiHeaders: newHeaders });
|
||||
}}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const newHeaders = { ...apiHeaders, "": "" };
|
||||
setApiHeaders(newHeaders);
|
||||
updateNode(nodeId, { apiHeaders: newHeaders });
|
||||
}}
|
||||
className="h-7 w-full text-xs"
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
헤더 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* WHERE 조건 */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">WHERE 조건 (필수)</h3>
|
||||
<Button size="sm" variant="outline" onClick={handleAddCondition} className="h-7">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{whereConditions.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{whereConditions.map((condition, index) => (
|
||||
<div key={index} className="rounded border-2 border-red-200 bg-red-50 p-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-red-700">조건 #{index + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveCondition(index)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">필드</Label>
|
||||
<Input
|
||||
value={condition.field}
|
||||
onChange={(e) => handleConditionChange(index, "field", e.target.value)}
|
||||
placeholder="조건 필드명"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">연산자</Label>
|
||||
<Select
|
||||
value={condition.operator}
|
||||
onValueChange={(value) => handleConditionChange(index, "operator", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OPERATORS.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">값</Label>
|
||||
<Input
|
||||
value={condition.value as string}
|
||||
onChange={(e) => handleConditionChange(index, "value", e.target.value)}
|
||||
placeholder="비교 값"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded border-2 border-dashed border-red-300 bg-red-50 p-4 text-center text-xs text-red-600">
|
||||
⚠️ WHERE 조건이 없습니다! 모든 데이터가 삭제됩니다!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSave} variant="destructive" className="w-full" size="sm">
|
||||
적용
|
||||
</Button>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="rounded bg-red-50 p-3 text-xs text-red-700">
|
||||
🚨 WHERE 조건 없이 삭제하면 테이블의 모든 데이터가 영구 삭제됩니다!
|
||||
</div>
|
||||
<div className="rounded bg-red-50 p-3 text-xs text-red-700">💡 실행 전 WHERE 조건을 꼭 확인하세요.</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,375 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 외부 DB 소스 노드 속성 편집
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Database, RefreshCw } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import {
|
||||
getTestedExternalConnections,
|
||||
getExternalTables,
|
||||
getExternalColumns,
|
||||
type ExternalConnection,
|
||||
type ExternalTable,
|
||||
type ExternalColumn,
|
||||
} from "@/lib/api/nodeExternalConnections";
|
||||
import { toast } from "sonner";
|
||||
import type { ExternalDBSourceNodeData } from "@/types/node-editor";
|
||||
|
||||
interface ExternalDBSourcePropertiesProps {
|
||||
nodeId: string;
|
||||
data: ExternalDBSourceNodeData;
|
||||
}
|
||||
|
||||
const DB_TYPE_INFO: Record<string, { label: string; color: string; icon: string }> = {
|
||||
postgresql: { label: "PostgreSQL", color: "#336791", icon: "🐘" },
|
||||
mysql: { label: "MySQL", color: "#4479A1", icon: "🐬" },
|
||||
oracle: { label: "Oracle", color: "#F80000", icon: "🔴" },
|
||||
mssql: { label: "MS SQL Server", color: "#CC2927", icon: "🏢" },
|
||||
mariadb: { label: "MariaDB", color: "#003545", icon: "🌊" },
|
||||
};
|
||||
|
||||
export function ExternalDBSourceProperties({ nodeId, data }: ExternalDBSourcePropertiesProps) {
|
||||
const { updateNode, getExternalConnectionsCache, setExternalConnectionsCache } = useFlowEditorStore();
|
||||
|
||||
const [displayName, setDisplayName] = useState(data.displayName || data.connectionName);
|
||||
const [selectedConnectionId, setSelectedConnectionId] = useState<number | undefined>(data.connectionId);
|
||||
const [tableName, setTableName] = useState(data.tableName);
|
||||
const [schema, setSchema] = useState(data.schema || "");
|
||||
|
||||
const [connections, setConnections] = useState<ExternalConnection[]>([]);
|
||||
const [tables, setTables] = useState<ExternalTable[]>([]);
|
||||
const [columns, setColumns] = useState<ExternalColumn[]>([]);
|
||||
const [loadingConnections, setLoadingConnections] = useState(false);
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
const [lastRefreshTime, setLastRefreshTime] = useState<number>(0); // 🔥 마지막 새로고침 시간
|
||||
const [remainingCooldown, setRemainingCooldown] = useState<number>(0); // 🔥 남은 쿨다운 시간
|
||||
|
||||
const selectedConnection = connections.find((conn) => conn.id === selectedConnectionId);
|
||||
const dbInfo =
|
||||
selectedConnection && DB_TYPE_INFO[selectedConnection.db_type]
|
||||
? DB_TYPE_INFO[selectedConnection.db_type]
|
||||
: {
|
||||
label: selectedConnection ? selectedConnection.db_type.toUpperCase() : "알 수 없음",
|
||||
color: "#666",
|
||||
icon: "💾",
|
||||
};
|
||||
|
||||
// 🔥 첫 로드 시에만 커넥션 목록 로드 (전역 캐싱)
|
||||
useEffect(() => {
|
||||
const cachedData = getExternalConnectionsCache();
|
||||
if (cachedData) {
|
||||
console.log("✅ 캐시된 커넥션 사용:", cachedData.length);
|
||||
setConnections(cachedData);
|
||||
} else {
|
||||
console.log("🔄 API 호출하여 커넥션 로드");
|
||||
loadConnections();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 커넥션 변경 시 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
if (selectedConnectionId) {
|
||||
loadTables();
|
||||
}
|
||||
}, [selectedConnectionId]);
|
||||
|
||||
// 테이블 변경 시 컬럼 목록 로드
|
||||
useEffect(() => {
|
||||
if (selectedConnectionId && tableName) {
|
||||
loadColumns();
|
||||
}
|
||||
}, [selectedConnectionId, tableName]);
|
||||
|
||||
// 🔥 쿨다운 타이머 (1초마다 업데이트)
|
||||
useEffect(() => {
|
||||
const THROTTLE_DURATION = 10000; // 10초
|
||||
|
||||
const timer = setInterval(() => {
|
||||
if (lastRefreshTime > 0) {
|
||||
const elapsed = Date.now() - lastRefreshTime;
|
||||
const remaining = Math.max(0, THROTTLE_DURATION - elapsed);
|
||||
setRemainingCooldown(Math.ceil(remaining / 1000));
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [lastRefreshTime]);
|
||||
|
||||
const loadConnections = async () => {
|
||||
// 🔥 쓰로틀링: 10초 이내 재요청 차단
|
||||
const THROTTLE_DURATION = 10000; // 10초
|
||||
const now = Date.now();
|
||||
|
||||
if (now - lastRefreshTime < THROTTLE_DURATION) {
|
||||
const remainingSeconds = Math.ceil((THROTTLE_DURATION - (now - lastRefreshTime)) / 1000);
|
||||
toast.warning(`잠시 후 다시 시도해주세요 (${remainingSeconds}초 후)`);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingConnections(true);
|
||||
setLastRefreshTime(now); // 🔥 마지막 실행 시간 기록
|
||||
|
||||
try {
|
||||
const data = await getTestedExternalConnections();
|
||||
setConnections(data);
|
||||
setExternalConnectionsCache(data); // 🔥 전역 캐시에 저장
|
||||
console.log("✅ 테스트 성공한 커넥션 로드 및 캐싱:", data.length);
|
||||
toast.success(`${data.length}개의 커넥션을 불러왔습니다.`);
|
||||
} catch (error) {
|
||||
console.error("❌ 커넥션 로드 실패:", error);
|
||||
toast.error("외부 DB 연결 목록을 불러올 수 없습니다.");
|
||||
} finally {
|
||||
setLoadingConnections(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadTables = async () => {
|
||||
if (!selectedConnectionId) return;
|
||||
|
||||
setLoadingTables(true);
|
||||
try {
|
||||
const data = await getExternalTables(selectedConnectionId);
|
||||
setTables(data);
|
||||
console.log("✅ 테이블 목록 로드:", data.length);
|
||||
} catch (error) {
|
||||
console.error("❌ 테이블 로드 실패:", error);
|
||||
toast.error("테이블 목록을 불러올 수 없습니다.");
|
||||
} finally {
|
||||
setLoadingTables(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadColumns = async () => {
|
||||
if (!selectedConnectionId || !tableName) return;
|
||||
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const data = await getExternalColumns(selectedConnectionId, tableName);
|
||||
setColumns(data);
|
||||
console.log("✅ 컬럼 목록 로드:", data.length);
|
||||
|
||||
// 노드에 outputFields 업데이트
|
||||
updateNode(nodeId, {
|
||||
outputFields: data.map((col) => ({
|
||||
name: col.column_name,
|
||||
type: col.data_type,
|
||||
label: col.column_name,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ 컬럼 로드 실패:", error);
|
||||
toast.error("컬럼 목록을 불러올 수 없습니다.");
|
||||
} finally {
|
||||
setLoadingColumns(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnectionChange = (connectionId: string) => {
|
||||
const id = parseInt(connectionId);
|
||||
setSelectedConnectionId(id);
|
||||
setTableName("");
|
||||
setTables([]);
|
||||
setColumns([]);
|
||||
|
||||
const connection = connections.find((conn) => conn.id === id);
|
||||
if (connection) {
|
||||
updateNode(nodeId, {
|
||||
connectionId: id,
|
||||
connectionName: connection.connection_name,
|
||||
dbType: connection.db_type,
|
||||
displayName: connection.connection_name,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleTableChange = (newTableName: string) => {
|
||||
setTableName(newTableName);
|
||||
setColumns([]);
|
||||
|
||||
updateNode(nodeId, {
|
||||
tableName: newTableName,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
connectionId: selectedConnectionId,
|
||||
connectionName: selectedConnection?.connection_name || "",
|
||||
tableName,
|
||||
schema,
|
||||
dbType: selectedConnection?.db_type,
|
||||
});
|
||||
toast.success("설정이 저장되었습니다.");
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
{/* DB 타입 정보 */}
|
||||
<div
|
||||
className="rounded-lg border-2 p-4"
|
||||
style={{
|
||||
borderColor: dbInfo.color,
|
||||
backgroundColor: `${dbInfo.color}10`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="flex h-12 w-12 items-center justify-center rounded-lg"
|
||||
style={{ backgroundColor: dbInfo.color }}
|
||||
>
|
||||
<span className="text-2xl">{dbInfo.icon}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold" style={{ color: dbInfo.color }}>
|
||||
{dbInfo.label}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600">외부 데이터베이스</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 연결 선택 */}
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">외부 DB 연결</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={loadConnections}
|
||||
disabled={loadingConnections || remainingCooldown > 0}
|
||||
className="relative h-7 px-2"
|
||||
title={
|
||||
loadingConnections
|
||||
? "테스트 진행 중..."
|
||||
: remainingCooldown > 0
|
||||
? `${remainingCooldown}초 후 재시도 가능`
|
||||
: "연결 테스트 재실행 (10초 간격 제한)"
|
||||
}
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${loadingConnections ? "animate-spin" : ""}`} />
|
||||
{remainingCooldown > 0 && !loadingConnections && (
|
||||
<span className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-orange-500 text-[9px] text-white">
|
||||
{remainingCooldown}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs">연결 선택 (테스트 성공한 커넥션만 표시)</Label>
|
||||
<Select
|
||||
value={selectedConnectionId?.toString()}
|
||||
onValueChange={handleConnectionChange}
|
||||
disabled={loadingConnections}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="외부 DB 연결 선택..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{connections.map((conn) => (
|
||||
<SelectItem key={conn.id} value={conn.id.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{DB_TYPE_INFO[conn.db_type]?.icon || "💾"}</span>
|
||||
<span>{conn.connection_name}</span>
|
||||
<span className="text-xs text-gray-500">({conn.db_type})</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{loadingConnections && <p className="mt-1 text-xs text-gray-500">테스트 중... ⏳</p>}
|
||||
{connections.length === 0 && !loadingConnections && (
|
||||
<p className="mt-1 text-xs text-orange-600">⚠️ 테스트에 성공한 커넥션이 없습니다.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="displayName" className="text-xs">
|
||||
표시 이름
|
||||
</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="노드 표시 이름"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 선택 */}
|
||||
{selectedConnectionId && (
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">테이블 선택</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs">테이블명</Label>
|
||||
<Select value={tableName} onValueChange={handleTableChange} disabled={loadingTables}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="테이블 선택..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tables.map((table) => (
|
||||
<SelectItem key={table.table_name} value={table.table_name}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>📋</span>
|
||||
<span>{table.table_name}</span>
|
||||
{table.schema && <span className="text-xs text-gray-500">({table.schema})</span>}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{loadingTables && <p className="mt-1 text-xs text-gray-500">테이블 목록 로딩 중... ⏳</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 컬럼 정보 */}
|
||||
{columns.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">출력 필드 ({columns.length}개)</h3>
|
||||
{loadingColumns ? (
|
||||
<p className="text-xs text-gray-500">컬럼 목록 로딩 중... ⏳</p>
|
||||
) : (
|
||||
<div className="max-h-[200px] space-y-1 overflow-y-auto">
|
||||
{columns.map((col, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between rounded border bg-gray-50 px-3 py-2 text-xs"
|
||||
>
|
||||
<span className="font-medium">{col.column_name}</span>
|
||||
<span className="font-mono text-gray-500">{col.data_type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={handleSave} className="w-full" size="sm">
|
||||
적용
|
||||
</Button>
|
||||
|
||||
<div className="rounded p-3 text-xs" style={{ backgroundColor: `${dbInfo.color}15`, color: dbInfo.color }}>
|
||||
💡 외부 DB 연결은 "외부 DB 연결 관리" 메뉴에서 미리 설정해야 합니다.
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 필드 매핑 노드 속성 편집
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Plus, Trash2, ArrowRight } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import type { FieldMappingNodeData } from "@/types/node-editor";
|
||||
|
||||
interface FieldMappingPropertiesProps {
|
||||
nodeId: string;
|
||||
data: FieldMappingNodeData;
|
||||
}
|
||||
|
||||
export function FieldMappingProperties({ nodeId, data }: FieldMappingPropertiesProps) {
|
||||
const { updateNode } = useFlowEditorStore();
|
||||
|
||||
const [displayName, setDisplayName] = useState(data.displayName || "데이터 매핑");
|
||||
const [mappings, setMappings] = useState(data.mappings || []);
|
||||
|
||||
// 데이터 변경 시 로컬 상태 업데이트
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || "데이터 매핑");
|
||||
setMappings(data.mappings || []);
|
||||
}, [data]);
|
||||
|
||||
const handleAddMapping = () => {
|
||||
setMappings([
|
||||
...mappings,
|
||||
{
|
||||
id: `mapping_${Date.now()}`,
|
||||
sourceField: "",
|
||||
targetField: "",
|
||||
transform: undefined,
|
||||
staticValue: undefined,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRemoveMapping = (id: string) => {
|
||||
setMappings(mappings.filter((m) => m.id !== id));
|
||||
};
|
||||
|
||||
const handleMappingChange = (id: string, field: string, value: any) => {
|
||||
const newMappings = mappings.map((m) => (m.id === id ? { ...m, [field]: value } : m));
|
||||
setMappings(newMappings);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
mappings,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="displayName" className="text-xs">
|
||||
표시 이름
|
||||
</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="노드 표시 이름"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 매핑 규칙 */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">매핑 규칙</h3>
|
||||
<Button size="sm" variant="outline" onClick={handleAddMapping} className="h-7">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{mappings.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{mappings.map((mapping, index) => (
|
||||
<div key={mapping.id} className="rounded border bg-purple-50 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-purple-700">규칙 #{index + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveMapping(mapping.id)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* 소스 → 타겟 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs text-gray-600">소스 필드</Label>
|
||||
<Input
|
||||
value={mapping.sourceField || ""}
|
||||
onChange={(e) => handleMappingChange(mapping.id, "sourceField", e.target.value)}
|
||||
placeholder="입력 필드"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-5">
|
||||
<ArrowRight className="h-4 w-4 text-purple-500" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs text-gray-600">타겟 필드</Label>
|
||||
<Input
|
||||
value={mapping.targetField}
|
||||
onChange={(e) => handleMappingChange(mapping.id, "targetField", e.target.value)}
|
||||
placeholder="출력 필드"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 변환 함수 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">변환 함수 (선택)</Label>
|
||||
<Input
|
||||
value={mapping.transform || ""}
|
||||
onChange={(e) => handleMappingChange(mapping.id, "transform", e.target.value)}
|
||||
placeholder="예: UPPER(), TRIM(), CONCAT()"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 정적 값 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">정적 값 (선택)</Label>
|
||||
<Input
|
||||
value={mapping.staticValue || ""}
|
||||
onChange={(e) => handleMappingChange(mapping.id, "staticValue", e.target.value)}
|
||||
placeholder="고정 값 (소스 필드 대신 사용)"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded border border-dashed p-4 text-center text-xs text-gray-400">
|
||||
매핑 규칙이 없습니다. "추가" 버튼을 클릭하세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 저장 버튼 */}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSave} className="flex-1" size="sm">
|
||||
적용
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 안내 */}
|
||||
<div className="space-y-2">
|
||||
<div className="rounded bg-purple-50 p-3 text-xs text-purple-700">
|
||||
💡 <strong>소스 필드</strong>: 입력 데이터의 필드명
|
||||
</div>
|
||||
<div className="rounded bg-purple-50 p-3 text-xs text-purple-700">
|
||||
💡 <strong>타겟 필드</strong>: 출력 데이터의 필드명
|
||||
</div>
|
||||
<div className="rounded bg-purple-50 p-3 text-xs text-purple-700">
|
||||
💡 <strong>변환 함수</strong>: 데이터 변환 로직 (SQL 함수 형식)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,113 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { LogNodeData } from "@/types/node-editor";
|
||||
import { FileText, Info, AlertTriangle, AlertCircle } from "lucide-react";
|
||||
|
||||
interface LogPropertiesProps {
|
||||
nodeId: string;
|
||||
data: LogNodeData;
|
||||
}
|
||||
|
||||
const LOG_LEVELS = [
|
||||
{ value: "debug", label: "Debug", icon: Info, color: "text-blue-600" },
|
||||
{ value: "info", label: "Info", icon: Info, color: "text-green-600" },
|
||||
{ value: "warn", label: "Warning", icon: AlertTriangle, color: "text-yellow-600" },
|
||||
{ value: "error", label: "Error", icon: AlertCircle, color: "text-red-600" },
|
||||
];
|
||||
|
||||
export function LogProperties({ nodeId, data }: LogPropertiesProps) {
|
||||
const { updateNode } = useFlowEditorStore();
|
||||
|
||||
const [level, setLevel] = useState(data.level || "info");
|
||||
const [message, setMessage] = useState(data.message || "");
|
||||
const [includeData, setIncludeData] = useState(data.includeData ?? false);
|
||||
|
||||
useEffect(() => {
|
||||
setLevel(data.level || "info");
|
||||
setMessage(data.message || "");
|
||||
setIncludeData(data.includeData ?? false);
|
||||
}, [data]);
|
||||
|
||||
const handleApply = () => {
|
||||
updateNode(nodeId, {
|
||||
level: level as any,
|
||||
message,
|
||||
includeData,
|
||||
});
|
||||
};
|
||||
|
||||
const selectedLevel = LOG_LEVELS.find((l) => l.value === level);
|
||||
const LevelIcon = selectedLevel?.icon || Info;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="flex items-center gap-2 rounded-md bg-gray-50 p-2">
|
||||
<FileText className="h-4 w-4 text-gray-600" />
|
||||
<span className="font-semibold text-gray-600">로그</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">로그 레벨</Label>
|
||||
<Select value={level} onValueChange={setLevel}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LOG_LEVELS.map((lvl) => {
|
||||
const Icon = lvl.icon;
|
||||
return (
|
||||
<SelectItem key={lvl.value} value={lvl.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={`h-4 w-4 ${lvl.color}`} />
|
||||
<span>{lvl.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="message" className="text-xs">
|
||||
로그 메시지
|
||||
</Label>
|
||||
<Input
|
||||
id="message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="로그 메시지를 입력하세요"
|
||||
className="mt-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-xs">데이터 포함</Label>
|
||||
<p className="text-xs text-gray-500">플로우 데이터를 로그에 포함합니다</p>
|
||||
</div>
|
||||
<Switch checked={includeData} onCheckedChange={setIncludeData} />
|
||||
</div>
|
||||
|
||||
<div className={`rounded-md border p-3 ${selectedLevel?.color || "text-gray-600"}`}>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<LevelIcon className="h-4 w-4" />
|
||||
<span className="text-xs font-semibold uppercase">{level}</span>
|
||||
</div>
|
||||
<div className="text-sm">{message || "메시지가 없습니다"}</div>
|
||||
{includeData && <div className="mt-1 text-xs opacity-70">+ 플로우 데이터</div>}
|
||||
</div>
|
||||
|
||||
<Button onClick={handleApply} className="w-full">
|
||||
적용
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,643 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 참조 테이블 조회 노드 속성 편집
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Plus, Trash2, Search } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Check } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import type { ReferenceLookupNodeData } from "@/types/node-editor";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
|
||||
// 필드 정의
|
||||
interface FieldDefinition {
|
||||
name: string;
|
||||
label?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
interface ReferenceLookupPropertiesProps {
|
||||
nodeId: string;
|
||||
data: ReferenceLookupNodeData;
|
||||
}
|
||||
|
||||
const OPERATORS = [
|
||||
{ value: "=", label: "같음 (=)" },
|
||||
{ value: "!=", label: "같지 않음 (≠)" },
|
||||
{ value: ">", label: "보다 큼 (>)" },
|
||||
{ value: "<", label: "보다 작음 (<)" },
|
||||
{ value: ">=", label: "크거나 같음 (≥)" },
|
||||
{ value: "<=", label: "작거나 같음 (≤)" },
|
||||
{ value: "LIKE", label: "포함 (LIKE)" },
|
||||
{ value: "IN", label: "IN" },
|
||||
] as const;
|
||||
|
||||
export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPropertiesProps) {
|
||||
const { updateNode, nodes, edges } = useFlowEditorStore();
|
||||
|
||||
// 상태
|
||||
const [displayName, setDisplayName] = useState(data.displayName || "참조 조회");
|
||||
const [referenceTable, setReferenceTable] = useState(data.referenceTable || "");
|
||||
const [referenceTableLabel, setReferenceTableLabel] = useState(data.referenceTableLabel || "");
|
||||
const [joinConditions, setJoinConditions] = useState(data.joinConditions || []);
|
||||
const [whereConditions, setWhereConditions] = useState(data.whereConditions || []);
|
||||
const [outputFields, setOutputFields] = useState(data.outputFields || []);
|
||||
|
||||
// 소스 필드 수집
|
||||
const [sourceFields, setSourceFields] = useState<FieldDefinition[]>([]);
|
||||
|
||||
// 참조 테이블 관련
|
||||
const [tables, setTables] = useState<any[]>([]);
|
||||
const [tablesLoading, setTablesLoading] = useState(false);
|
||||
const [tablesOpen, setTablesOpen] = useState(false);
|
||||
const [referenceColumns, setReferenceColumns] = useState<FieldDefinition[]>([]);
|
||||
const [columnsLoading, setColumnsLoading] = useState(false);
|
||||
|
||||
// 데이터 변경 시 로컬 상태 동기화
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || "참조 조회");
|
||||
setReferenceTable(data.referenceTable || "");
|
||||
setReferenceTableLabel(data.referenceTableLabel || "");
|
||||
setJoinConditions(data.joinConditions || []);
|
||||
setWhereConditions(data.whereConditions || []);
|
||||
setOutputFields(data.outputFields || []);
|
||||
}, [data]);
|
||||
|
||||
// 🔍 소스 필드 수집 (업스트림 노드에서)
|
||||
useEffect(() => {
|
||||
const incomingEdges = edges.filter((e) => e.target === nodeId);
|
||||
const fields: FieldDefinition[] = [];
|
||||
|
||||
for (const edge of incomingEdges) {
|
||||
const sourceNode = nodes.find((n) => n.id === edge.source);
|
||||
if (!sourceNode) continue;
|
||||
|
||||
const sourceData = sourceNode.data as any;
|
||||
|
||||
if (sourceNode.type === "tableSource" && sourceData.fields) {
|
||||
fields.push(...sourceData.fields);
|
||||
} else if (sourceNode.type === "externalDBSource" && sourceData.outputFields) {
|
||||
fields.push(...sourceData.outputFields);
|
||||
}
|
||||
}
|
||||
|
||||
setSourceFields(fields);
|
||||
}, [nodeId, nodes, edges]);
|
||||
|
||||
// 📊 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
loadTables();
|
||||
}, []);
|
||||
|
||||
const loadTables = async () => {
|
||||
setTablesLoading(true);
|
||||
try {
|
||||
const data = await tableTypeApi.getTables();
|
||||
setTables(data);
|
||||
} catch (error) {
|
||||
console.error("테이블 로드 실패:", error);
|
||||
} finally {
|
||||
setTablesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 📋 참조 테이블 컬럼 로드
|
||||
useEffect(() => {
|
||||
if (referenceTable) {
|
||||
loadReferenceColumns();
|
||||
} else {
|
||||
setReferenceColumns([]);
|
||||
}
|
||||
}, [referenceTable]);
|
||||
|
||||
const loadReferenceColumns = async () => {
|
||||
if (!referenceTable) return;
|
||||
|
||||
setColumnsLoading(true);
|
||||
try {
|
||||
const cols = await tableTypeApi.getColumns(referenceTable);
|
||||
const formatted = cols.map((col: any) => ({
|
||||
name: col.columnName,
|
||||
type: col.dataType,
|
||||
label: col.displayName || col.columnName,
|
||||
}));
|
||||
setReferenceColumns(formatted);
|
||||
} catch (error) {
|
||||
console.error("컬럼 로드 실패:", error);
|
||||
setReferenceColumns([]);
|
||||
} finally {
|
||||
setColumnsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 테이블 선택 핸들러
|
||||
const handleTableSelect = (tableName: string) => {
|
||||
const selectedTable = tables.find((t) => t.tableName === tableName);
|
||||
if (selectedTable) {
|
||||
setReferenceTable(tableName);
|
||||
setReferenceTableLabel(selectedTable.label);
|
||||
setTablesOpen(false);
|
||||
|
||||
// 기존 설정 초기화
|
||||
setJoinConditions([]);
|
||||
setWhereConditions([]);
|
||||
setOutputFields([]);
|
||||
}
|
||||
};
|
||||
|
||||
// 조인 조건 추가
|
||||
const handleAddJoinCondition = () => {
|
||||
setJoinConditions([
|
||||
...joinConditions,
|
||||
{
|
||||
sourceField: "",
|
||||
referenceField: "",
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRemoveJoinCondition = (index: number) => {
|
||||
setJoinConditions(joinConditions.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleJoinConditionChange = (index: number, field: string, value: any) => {
|
||||
const newConditions = [...joinConditions];
|
||||
newConditions[index] = { ...newConditions[index], [field]: value };
|
||||
|
||||
// 라벨도 함께 저장
|
||||
if (field === "sourceField") {
|
||||
const sourceField = sourceFields.find((f) => f.name === value);
|
||||
newConditions[index].sourceFieldLabel = sourceField?.label || value;
|
||||
} else if (field === "referenceField") {
|
||||
const refField = referenceColumns.find((f) => f.name === value);
|
||||
newConditions[index].referenceFieldLabel = refField?.label || value;
|
||||
}
|
||||
|
||||
setJoinConditions(newConditions);
|
||||
};
|
||||
|
||||
// WHERE 조건 추가
|
||||
const handleAddWhereCondition = () => {
|
||||
setWhereConditions([
|
||||
...whereConditions,
|
||||
{
|
||||
field: "",
|
||||
operator: "=",
|
||||
value: "",
|
||||
valueType: "static",
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRemoveWhereCondition = (index: number) => {
|
||||
setWhereConditions(whereConditions.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleWhereConditionChange = (index: number, field: string, value: any) => {
|
||||
const newConditions = [...whereConditions];
|
||||
newConditions[index] = { ...newConditions[index], [field]: value };
|
||||
|
||||
// 라벨도 함께 저장
|
||||
if (field === "field") {
|
||||
const refField = referenceColumns.find((f) => f.name === value);
|
||||
newConditions[index].fieldLabel = refField?.label || value;
|
||||
}
|
||||
|
||||
setWhereConditions(newConditions);
|
||||
};
|
||||
|
||||
// 출력 필드 추가
|
||||
const handleAddOutputField = () => {
|
||||
setOutputFields([
|
||||
...outputFields,
|
||||
{
|
||||
fieldName: "",
|
||||
alias: "",
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRemoveOutputField = (index: number) => {
|
||||
setOutputFields(outputFields.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleOutputFieldChange = (index: number, field: string, value: any) => {
|
||||
const newFields = [...outputFields];
|
||||
newFields[index] = { ...newFields[index], [field]: value };
|
||||
|
||||
// 라벨도 함께 저장
|
||||
if (field === "fieldName") {
|
||||
const refField = referenceColumns.find((f) => f.name === value);
|
||||
newFields[index].fieldLabel = refField?.label || value;
|
||||
// alias 자동 설정
|
||||
if (!newFields[index].alias) {
|
||||
newFields[index].alias = `ref_${value}`;
|
||||
}
|
||||
}
|
||||
|
||||
setOutputFields(newFields);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
referenceTable,
|
||||
referenceTableLabel,
|
||||
joinConditions,
|
||||
whereConditions,
|
||||
outputFields,
|
||||
});
|
||||
};
|
||||
|
||||
const selectedTableLabel = tables.find((t) => t.tableName === referenceTable)?.label || referenceTable;
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="displayName" className="text-xs">
|
||||
표시 이름
|
||||
</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="노드 표시 이름"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 참조 테이블 선택 */}
|
||||
<div>
|
||||
<Label className="text-xs">참조 테이블</Label>
|
||||
<Popover open={tablesOpen} onOpenChange={setTablesOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tablesOpen}
|
||||
className="mt-1 w-full justify-between"
|
||||
disabled={tablesLoading}
|
||||
>
|
||||
{tablesLoading ? (
|
||||
<span className="text-muted-foreground">로딩 중...</span>
|
||||
) : referenceTable ? (
|
||||
<span className="truncate">{selectedTableLabel}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">테이블 선택...</span>
|
||||
)}
|
||||
<Search className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[320px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="h-9" />
|
||||
<CommandList>
|
||||
<CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<ScrollArea className="h-[300px]">
|
||||
{tables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={`${table.label} ${table.tableName} ${table.description}`}
|
||||
onSelect={() => handleTableSelect(table.tableName)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
referenceTable === table.tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.label}</span>
|
||||
{table.label !== table.tableName && (
|
||||
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 조인 조건 */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">조인 조건 (FK 매핑)</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleAddJoinCondition}
|
||||
className="h-7"
|
||||
disabled={!referenceTable}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{joinConditions.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{joinConditions.map((condition, index) => (
|
||||
<div key={index} className="rounded border bg-purple-50 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-purple-700">조인 #{index + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveJoinCondition(index)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">소스 필드</Label>
|
||||
<Select
|
||||
value={condition.sourceField}
|
||||
onValueChange={(value) => handleJoinConditionChange(index, "sourceField", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="소스 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceFields.map((field) => (
|
||||
<SelectItem key={field.name} value={field.name}>
|
||||
{field.label || field.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">참조 필드</Label>
|
||||
<Select
|
||||
value={condition.referenceField}
|
||||
onValueChange={(value) => handleJoinConditionChange(index, "referenceField", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="참조 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{referenceColumns.map((field) => (
|
||||
<SelectItem key={field.name} value={field.name}>
|
||||
{field.label || field.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded border border-dashed p-4 text-center text-xs text-gray-400">
|
||||
조인 조건을 추가하세요 (필수)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* WHERE 조건 */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">WHERE 조건 (선택사항)</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleAddWhereCondition}
|
||||
className="h-7"
|
||||
disabled={!referenceTable}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{whereConditions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{whereConditions.map((condition, index) => (
|
||||
<div key={index} className="rounded border bg-yellow-50 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-yellow-700">WHERE #{index + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveWhereCondition(index)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">필드</Label>
|
||||
<Select
|
||||
value={condition.field}
|
||||
onValueChange={(value) => handleWhereConditionChange(index, "field", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{referenceColumns.map((field) => (
|
||||
<SelectItem key={field.name} value={field.name}>
|
||||
{field.label || field.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">연산자</Label>
|
||||
<Select
|
||||
value={condition.operator}
|
||||
onValueChange={(value) => handleWhereConditionChange(index, "operator", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OPERATORS.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">값 타입</Label>
|
||||
<Select
|
||||
value={condition.valueType || "static"}
|
||||
onValueChange={(value) => handleWhereConditionChange(index, "valueType", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="static">고정값</SelectItem>
|
||||
<SelectItem value="field">소스 필드 참조</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">
|
||||
{condition.valueType === "field" ? "소스 필드" : "값"}
|
||||
</Label>
|
||||
{condition.valueType === "field" ? (
|
||||
<Select
|
||||
value={condition.value}
|
||||
onValueChange={(value) => handleWhereConditionChange(index, "value", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="소스 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceFields.map((field) => (
|
||||
<SelectItem key={field.name} value={field.name}>
|
||||
{field.label || field.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={condition.value}
|
||||
onChange={(e) => handleWhereConditionChange(index, "value", e.target.value)}
|
||||
placeholder="비교할 값"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 출력 필드 */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">출력 필드</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleAddOutputField}
|
||||
className="h-7"
|
||||
disabled={!referenceTable}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{outputFields.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{outputFields.map((field, index) => (
|
||||
<div key={index} className="rounded border bg-blue-50 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-blue-700">필드 #{index + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveOutputField(index)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">참조 테이블 필드</Label>
|
||||
<Select
|
||||
value={field.fieldName}
|
||||
onValueChange={(value) => handleOutputFieldChange(index, "fieldName", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{referenceColumns.map((col) => (
|
||||
<SelectItem key={col.name} value={col.name}>
|
||||
{col.label || col.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">별칭 (Alias)</Label>
|
||||
<Input
|
||||
value={field.alias}
|
||||
onChange={(e) => handleOutputFieldChange(index, "alias", e.target.value)}
|
||||
placeholder="ref_field_name"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded border border-dashed p-4 text-center text-xs text-gray-400">
|
||||
출력 필드를 추가하세요 (필수)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 저장 버튼 */}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSave} className="flex-1" size="sm">
|
||||
적용
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 안내 */}
|
||||
<div className="space-y-2">
|
||||
<div className="rounded bg-purple-50 p-3 text-xs text-purple-700">
|
||||
🔗 <strong>조인 조건</strong>: 소스 데이터와 참조 테이블을 연결하는 키 (예: customer_id → id)
|
||||
</div>
|
||||
<div className="rounded bg-yellow-50 p-3 text-xs text-yellow-700">
|
||||
⚡ <strong>WHERE 조건</strong>: 참조 테이블에서 특정 조건의 데이터만 가져오기 (예: grade = 'VIP')
|
||||
</div>
|
||||
<div className="rounded bg-blue-50 p-3 text-xs text-blue-700">
|
||||
📤 <strong>출력 필드</strong>: 참조 테이블에서 가져올 필드 선택 (별칭으로 결과에 추가됨)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { RestAPISourceNodeData } from "@/types/node-editor";
|
||||
import { Globe, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
interface RestAPISourcePropertiesProps {
|
||||
nodeId: string;
|
||||
data: RestAPISourceNodeData;
|
||||
}
|
||||
|
||||
const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH"];
|
||||
const AUTH_TYPES = [
|
||||
{ value: "none", label: "인증 없음" },
|
||||
{ value: "bearer", label: "Bearer Token" },
|
||||
{ value: "basic", label: "Basic Auth" },
|
||||
{ value: "apikey", label: "API Key" },
|
||||
];
|
||||
|
||||
export function RestAPISourceProperties({ nodeId, data }: RestAPISourcePropertiesProps) {
|
||||
const { updateNode } = useFlowEditorStore();
|
||||
|
||||
const [displayName, setDisplayName] = useState(data.displayName || "");
|
||||
const [url, setUrl] = useState(data.url || "");
|
||||
const [method, setMethod] = useState(data.method || "GET");
|
||||
const [headers, setHeaders] = useState(data.headers || {});
|
||||
const [newHeaderKey, setNewHeaderKey] = useState("");
|
||||
const [newHeaderValue, setNewHeaderValue] = useState("");
|
||||
const [body, setBody] = useState(JSON.stringify(data.body || {}, null, 2));
|
||||
const [authType, setAuthType] = useState(data.authentication?.type || "none");
|
||||
const [authToken, setAuthToken] = useState(data.authentication?.token || "");
|
||||
const [timeout, setTimeout] = useState(data.timeout?.toString() || "30000");
|
||||
const [responseMapping, setResponseMapping] = useState(data.responseMapping || "");
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || "");
|
||||
setUrl(data.url || "");
|
||||
setMethod(data.method || "GET");
|
||||
setHeaders(data.headers || {});
|
||||
setBody(JSON.stringify(data.body || {}, null, 2));
|
||||
setAuthType(data.authentication?.type || "none");
|
||||
setAuthToken(data.authentication?.token || "");
|
||||
setTimeout(data.timeout?.toString() || "30000");
|
||||
setResponseMapping(data.responseMapping || "");
|
||||
}, [data]);
|
||||
|
||||
const handleApply = () => {
|
||||
let parsedBody = {};
|
||||
try {
|
||||
parsedBody = body.trim() ? JSON.parse(body) : {};
|
||||
} catch (e) {
|
||||
alert("Body JSON 형식이 올바르지 않습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
url,
|
||||
method: method as any,
|
||||
headers,
|
||||
body: parsedBody,
|
||||
authentication: {
|
||||
type: authType as any,
|
||||
token: authToken || undefined,
|
||||
},
|
||||
timeout: parseInt(timeout) || 30000,
|
||||
responseMapping,
|
||||
});
|
||||
};
|
||||
|
||||
const addHeader = () => {
|
||||
if (newHeaderKey.trim() && newHeaderValue.trim()) {
|
||||
setHeaders({ ...headers, [newHeaderKey.trim()]: newHeaderValue.trim() });
|
||||
setNewHeaderKey("");
|
||||
setNewHeaderValue("");
|
||||
}
|
||||
};
|
||||
|
||||
const removeHeader = (key: string) => {
|
||||
const newHeaders = { ...headers };
|
||||
delete newHeaders[key];
|
||||
setHeaders(newHeaders);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="flex items-center gap-2 rounded-md bg-teal-50 p-2">
|
||||
<Globe className="h-4 w-4 text-teal-600" />
|
||||
<span className="font-semibold text-teal-600">REST API 소스</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="displayName" className="text-xs">
|
||||
표시 이름
|
||||
</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="노드에 표시될 이름"
|
||||
className="mt-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="url" className="text-xs">
|
||||
API URL
|
||||
</Label>
|
||||
<Input
|
||||
id="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://api.example.com/data"
|
||||
className="mt-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">HTTP 메서드</Label>
|
||||
<Select value={method} onValueChange={setMethod}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{HTTP_METHODS.map((m) => (
|
||||
<SelectItem key={m} value={m}>
|
||||
{m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">헤더</Label>
|
||||
<div className="mt-1 space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newHeaderKey}
|
||||
onChange={(e) => setNewHeaderKey(e.target.value)}
|
||||
placeholder="Key"
|
||||
className="text-sm"
|
||||
/>
|
||||
<Input
|
||||
value={newHeaderValue}
|
||||
onChange={(e) => setNewHeaderValue(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && addHeader()}
|
||||
placeholder="Value"
|
||||
className="text-sm"
|
||||
/>
|
||||
<Button size="sm" onClick={addHeader}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-[100px] space-y-1 overflow-y-auto">
|
||||
{Object.entries(headers).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center justify-between rounded bg-teal-50 px-2 py-1">
|
||||
<span className="text-xs">
|
||||
<span className="font-medium">{key}:</span> {value}
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeHeader(key)}>
|
||||
<Trash2 className="h-3 w-3 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(method === "POST" || method === "PUT" || method === "PATCH") && (
|
||||
<div>
|
||||
<Label htmlFor="body" className="text-xs">
|
||||
Body (JSON)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="body"
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder='{"key": "value"}'
|
||||
className="mt-1 font-mono text-sm"
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">인증</Label>
|
||||
<Select value={authType} onValueChange={setAuthType}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AUTH_TYPES.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{authType !== "none" && (
|
||||
<Input
|
||||
value={authToken}
|
||||
onChange={(e) => setAuthToken(e.target.value)}
|
||||
placeholder="토큰/키 입력"
|
||||
className="mt-2 text-sm"
|
||||
type="password"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="timeout" className="text-xs">
|
||||
타임아웃 (ms)
|
||||
</Label>
|
||||
<Input
|
||||
id="timeout"
|
||||
type="number"
|
||||
value={timeout}
|
||||
onChange={(e) => setTimeout(e.target.value)}
|
||||
className="mt-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="responseMapping" className="text-xs">
|
||||
응답 매핑 (JSON 경로)
|
||||
</Label>
|
||||
<Input
|
||||
id="responseMapping"
|
||||
value={responseMapping}
|
||||
onChange={(e) => setResponseMapping(e.target.value)}
|
||||
placeholder="예: data.items"
|
||||
className="mt-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleApply} className="w-full">
|
||||
적용
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 테이블 소스 노드 속성 편집
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import type { TableSourceNodeData } from "@/types/node-editor";
|
||||
|
||||
interface TableSourcePropertiesProps {
|
||||
nodeId: string;
|
||||
data: TableSourceNodeData;
|
||||
}
|
||||
|
||||
interface TableOption {
|
||||
tableName: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
label: string; // 표시용 (라벨 또는 테이블명)
|
||||
}
|
||||
|
||||
export function TableSourceProperties({ nodeId, data }: TableSourcePropertiesProps) {
|
||||
const { updateNode } = useFlowEditorStore();
|
||||
|
||||
const [displayName, setDisplayName] = useState(data.displayName || data.tableName);
|
||||
const [tableName, setTableName] = useState(data.tableName);
|
||||
|
||||
// 테이블 선택 관련 상태
|
||||
const [tables, setTables] = useState<TableOption[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// 데이터 변경 시 로컬 상태 업데이트
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || data.tableName);
|
||||
setTableName(data.tableName);
|
||||
}, [data.displayName, data.tableName]);
|
||||
|
||||
// 테이블 목록 로딩
|
||||
useEffect(() => {
|
||||
loadTables();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 테이블 목록 로드
|
||||
*/
|
||||
const loadTables = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
console.log("🔍 테이블 목록 로딩 중...");
|
||||
|
||||
const tableList = await tableTypeApi.getTables();
|
||||
|
||||
// 테이블 목록 변환 (라벨 또는 displayName 우선 표시)
|
||||
const options: TableOption[] = tableList.map((table) => {
|
||||
// tableLabel이 있으면 우선 사용, 없으면 displayName, 그것도 없으면 tableName
|
||||
const label = (table as any).tableLabel || table.displayName || table.tableName || "알 수 없는 테이블";
|
||||
|
||||
return {
|
||||
tableName: table.tableName,
|
||||
displayName: table.displayName || table.tableName,
|
||||
description: table.description || "",
|
||||
label,
|
||||
};
|
||||
});
|
||||
|
||||
setTables(options);
|
||||
console.log(`✅ 테이블 ${options.length}개 로딩 완료`);
|
||||
} catch (error) {
|
||||
console.error("❌ 테이블 목록 로딩 실패:", error);
|
||||
setTables([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 테이블 선택 핸들러 (즉시 노드 업데이트 + 컬럼 로드)
|
||||
*/
|
||||
const handleTableSelect = async (selectedTableName: string) => {
|
||||
const selectedTable = tables.find((t) => t.tableName === selectedTableName);
|
||||
if (selectedTable) {
|
||||
const newTableName = selectedTable.tableName;
|
||||
const newDisplayName = selectedTable.label;
|
||||
|
||||
setTableName(newTableName);
|
||||
setDisplayName(newDisplayName);
|
||||
setOpen(false);
|
||||
|
||||
// 컬럼 정보 로드
|
||||
console.log(`🔍 테이블 "${newTableName}" 컬럼 로드 중...`);
|
||||
try {
|
||||
const columns = await tableTypeApi.getColumns(newTableName);
|
||||
console.log("🔍 API에서 받은 컬럼 데이터:", columns);
|
||||
|
||||
const fields = columns.map((col: any) => ({
|
||||
name: col.column_name || col.columnName,
|
||||
type: col.data_type || col.dataType || "unknown",
|
||||
nullable: col.is_nullable === "YES" || col.isNullable === true,
|
||||
// displayName이 라벨입니다!
|
||||
label: col.displayName || col.label_ko || col.columnLabel || col.column_label,
|
||||
}));
|
||||
|
||||
console.log(`✅ ${fields.length}개 컬럼 로드 완료:`, fields);
|
||||
|
||||
// 필드 정보와 함께 노드 업데이트
|
||||
updateNode(nodeId, {
|
||||
displayName: newDisplayName,
|
||||
tableName: newTableName,
|
||||
fields,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ 컬럼 로드 실패:", error);
|
||||
|
||||
// 실패해도 테이블 정보는 업데이트
|
||||
updateNode(nodeId, {
|
||||
displayName: newDisplayName,
|
||||
tableName: newTableName,
|
||||
fields: [],
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`✅ 테이블 선택: ${newTableName} (${newDisplayName})`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 표시 이름 변경 핸들러
|
||||
*/
|
||||
const handleDisplayNameChange = (newDisplayName: string) => {
|
||||
setDisplayName(newDisplayName);
|
||||
updateNode(nodeId, {
|
||||
displayName: newDisplayName,
|
||||
tableName,
|
||||
});
|
||||
};
|
||||
|
||||
// 현재 선택된 테이블의 라벨 찾기
|
||||
const selectedTableLabel = tables.find((t) => t.tableName === tableName)?.label || tableName;
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="displayName" className="text-xs">
|
||||
표시 이름
|
||||
</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => handleDisplayNameChange(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="노드 표시 이름"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 테이블 선택 Combobox */}
|
||||
<div>
|
||||
<Label className="text-xs">테이블 선택</Label>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="mt-1 w-full justify-between"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="text-muted-foreground">로딩 중...</span>
|
||||
) : tableName ? (
|
||||
<span className="truncate">{selectedTableLabel}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">테이블을 선택하세요</span>
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[320px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="h-9" />
|
||||
<CommandList>
|
||||
<CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<ScrollArea className="h-[300px]">
|
||||
{tables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={`${table.label} ${table.tableName} ${table.description}`}
|
||||
onSelect={() => handleTableSelect(table.tableName)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
tableName === table.tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.label}</span>
|
||||
{table.label !== table.tableName && (
|
||||
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
||||
)}
|
||||
{table.description && (
|
||||
<span className="text-muted-foreground text-xs">{table.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{tableName && selectedTableLabel !== tableName && (
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
실제 테이블명: <code className="rounded bg-gray-100 px-1 py-0.5">{tableName}</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필드 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">출력 필드</h3>
|
||||
{data.fields && data.fields.length > 0 ? (
|
||||
<div className="space-y-1 rounded border p-2">
|
||||
{data.fields.map((field) => (
|
||||
<div key={field.name} className="flex items-center justify-between text-xs">
|
||||
<span className="font-mono text-gray-700">{field.name}</span>
|
||||
<span className="text-gray-400">{field.type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded border p-4 text-center text-xs text-gray-400">필드 정보가 없습니다</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 안내 */}
|
||||
<div className="rounded bg-green-50 p-3 text-xs text-green-700">✅ 변경 사항이 즉시 노드에 반영됩니다.</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,114 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 노드 팔레트 사이드바
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { NODE_CATEGORIES, getNodesByCategory } from "./nodePaletteConfig";
|
||||
import type { NodePaletteItem } from "@/types/node-editor";
|
||||
|
||||
export function NodePalette() {
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(["source", "transform", "action"]));
|
||||
|
||||
const toggleCategory = (categoryId: string) => {
|
||||
setExpandedCategories((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(categoryId)) {
|
||||
next.delete(categoryId);
|
||||
} else {
|
||||
next.add(categoryId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="border-b bg-gray-50 p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900">노드 라이브러리</h3>
|
||||
<p className="mt-1 text-xs text-gray-500">캔버스로 드래그하여 추가</p>
|
||||
</div>
|
||||
|
||||
{/* 노드 목록 */}
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{NODE_CATEGORIES.map((category) => {
|
||||
const isExpanded = expandedCategories.has(category.id);
|
||||
const nodes = getNodesByCategory(category.id);
|
||||
|
||||
return (
|
||||
<div key={category.id} className="mb-2">
|
||||
{/* 카테고리 헤더 */}
|
||||
<button
|
||||
onClick={() => toggleCategory(category.id)}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm font-medium text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
<span>{category.icon}</span>
|
||||
<span>{category.label}</span>
|
||||
<span className="ml-auto text-xs text-gray-400">{nodes.length}</span>
|
||||
</button>
|
||||
|
||||
{/* 노드 아이템들 */}
|
||||
{isExpanded && (
|
||||
<div className="mt-1 ml-2 space-y-1">
|
||||
{nodes.map((node) => (
|
||||
<NodePaletteItemComponent key={node.type} node={node} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 푸터 도움말 */}
|
||||
<div className="border-t bg-gray-50 p-3">
|
||||
<p className="text-xs text-gray-500">💡 노드를 드래그하여 캔버스에 배치하고 연결선으로 연결하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 노드 팔레트 아이템 컴포넌트
|
||||
*/
|
||||
function NodePaletteItemComponent({ node }: { node: NodePaletteItem }) {
|
||||
const onDragStart = (event: React.DragEvent) => {
|
||||
event.dataTransfer.setData("application/reactflow", node.type);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group cursor-move rounded-lg border border-gray-200 bg-white p-3 shadow-sm transition-all hover:border-gray-300 hover:shadow-md"
|
||||
draggable
|
||||
onDragStart={onDragStart}
|
||||
title={node.description}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{/* 아이콘 */}
|
||||
<div
|
||||
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded text-lg"
|
||||
style={{ backgroundColor: `${node.color}20` }}
|
||||
>
|
||||
{node.icon}
|
||||
</div>
|
||||
|
||||
{/* 라벨 및 설명 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-gray-900">{node.label}</div>
|
||||
<div className="mt-0.5 truncate text-xs text-gray-500">{node.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 색상 인디케이터 */}
|
||||
<div
|
||||
className="mt-2 h-1 w-full rounded-full opacity-0 transition-opacity group-hover:opacity-100"
|
||||
style={{ backgroundColor: node.color }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
/**
|
||||
* 노드 팔레트 설정
|
||||
*/
|
||||
|
||||
import type { NodePaletteItem } from "@/types/node-editor";
|
||||
|
||||
export const NODE_PALETTE: NodePaletteItem[] = [
|
||||
// ========================================================================
|
||||
// 데이터 소스
|
||||
// ========================================================================
|
||||
{
|
||||
type: "tableSource",
|
||||
label: "테이블",
|
||||
icon: "📊",
|
||||
description: "내부 데이터베이스 테이블에서 데이터를 읽어옵니다",
|
||||
category: "source",
|
||||
color: "#3B82F6", // 파란색
|
||||
},
|
||||
{
|
||||
type: "externalDBSource",
|
||||
label: "외부 DB",
|
||||
icon: "🔌",
|
||||
description: "외부 데이터베이스에서 데이터를 읽어옵니다",
|
||||
category: "source",
|
||||
color: "#F59E0B", // 주황색
|
||||
},
|
||||
{
|
||||
type: "restAPISource",
|
||||
label: "REST API",
|
||||
icon: "📁",
|
||||
description: "REST API를 호출하여 데이터를 가져옵니다",
|
||||
category: "source",
|
||||
color: "#10B981", // 초록색
|
||||
},
|
||||
{
|
||||
type: "referenceLookup",
|
||||
label: "참조 조회",
|
||||
icon: "🔗",
|
||||
description: "다른 테이블에서 데이터를 조회합니다 (내부 DB 전용)",
|
||||
category: "source",
|
||||
color: "#A855F7", // 보라색
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// 변환/조건
|
||||
// ========================================================================
|
||||
{
|
||||
type: "condition",
|
||||
label: "조건 분기",
|
||||
icon: "⚡",
|
||||
description: "조건에 따라 데이터 흐름을 분기합니다",
|
||||
category: "transform",
|
||||
color: "#EAB308", // 노란색
|
||||
},
|
||||
{
|
||||
type: "fieldMapping",
|
||||
label: "필드 매핑",
|
||||
icon: "🔀",
|
||||
description: "소스 필드를 타겟 필드로 매핑합니다",
|
||||
category: "transform",
|
||||
color: "#8B5CF6", // 보라색
|
||||
},
|
||||
{
|
||||
type: "dataTransform",
|
||||
label: "데이터 변환",
|
||||
icon: "🔧",
|
||||
description: "데이터를 변환하거나 가공합니다",
|
||||
category: "transform",
|
||||
color: "#06B6D4", // 청록색
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// 액션
|
||||
// ========================================================================
|
||||
{
|
||||
type: "insertAction",
|
||||
label: "INSERT",
|
||||
icon: "➕",
|
||||
description: "데이터를 삽입합니다",
|
||||
category: "action",
|
||||
color: "#22C55E", // 초록색
|
||||
},
|
||||
{
|
||||
type: "updateAction",
|
||||
label: "UPDATE",
|
||||
icon: "✏️",
|
||||
description: "데이터를 수정합니다",
|
||||
category: "action",
|
||||
color: "#3B82F6", // 파란색
|
||||
},
|
||||
{
|
||||
type: "deleteAction",
|
||||
label: "DELETE",
|
||||
icon: "❌",
|
||||
description: "데이터를 삭제합니다",
|
||||
category: "action",
|
||||
color: "#EF4444", // 빨간색
|
||||
},
|
||||
{
|
||||
type: "upsertAction",
|
||||
label: "UPSERT",
|
||||
icon: "🔄",
|
||||
description: "데이터를 삽입하거나 수정합니다",
|
||||
category: "action",
|
||||
color: "#8B5CF6", // 보라색
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// 유틸리티
|
||||
// ========================================================================
|
||||
{
|
||||
type: "comment",
|
||||
label: "주석",
|
||||
icon: "💬",
|
||||
description: "주석을 추가합니다",
|
||||
category: "utility",
|
||||
color: "#6B7280", // 회색
|
||||
},
|
||||
{
|
||||
type: "log",
|
||||
label: "로그",
|
||||
icon: "🔍",
|
||||
description: "로그를 출력합니다",
|
||||
category: "utility",
|
||||
color: "#6B7280", // 회색
|
||||
},
|
||||
];
|
||||
|
||||
export const NODE_CATEGORIES = [
|
||||
{
|
||||
id: "source",
|
||||
label: "데이터 소스",
|
||||
icon: "📂",
|
||||
},
|
||||
{
|
||||
id: "transform",
|
||||
label: "변환/조건",
|
||||
icon: "🔀",
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
label: "액션",
|
||||
icon: "⚡",
|
||||
},
|
||||
{
|
||||
id: "utility",
|
||||
label: "유틸리티",
|
||||
icon: "🛠️",
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 노드 타입별 팔레트 아이템 조회
|
||||
*/
|
||||
export function getNodePaletteItem(type: string): NodePaletteItem | undefined {
|
||||
return NODE_PALETTE.find((item) => item.type === type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리별 노드 목록 조회
|
||||
*/
|
||||
export function getNodesByCategory(category: string): NodePaletteItem[] {
|
||||
return NODE_PALETTE.filter((item) => item.category === category);
|
||||
}
|
||||
|
|
@ -18,6 +18,8 @@ interface EditModalProps {
|
|||
editData?: any;
|
||||
onSave?: () => void;
|
||||
onDataChange?: (formData: Record<string, any>) => void; // 폼 데이터 변경 콜백 추가
|
||||
modalTitle?: string; // 모달 제목
|
||||
modalDescription?: string; // 모달 설명
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -32,6 +34,8 @@ export const EditModal: React.FC<EditModalProps> = ({
|
|||
editData,
|
||||
onSave,
|
||||
onDataChange,
|
||||
modalTitle,
|
||||
modalDescription,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formData, setFormData] = useState<any>({});
|
||||
|
|
@ -261,9 +265,20 @@ export const EditModal: React.FC<EditModalProps> = ({
|
|||
}}
|
||||
data-radix-portal="true"
|
||||
>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>수정</DialogTitle>
|
||||
</DialogHeader>
|
||||
{/* 모달 헤더 (제목/설명이 있으면 표시) */}
|
||||
{(modalTitle || modalDescription) && (
|
||||
<DialogHeader className="border-b bg-gray-50 px-6 py-4">
|
||||
<DialogTitle className="text-lg font-semibold">{modalTitle || "수정"}</DialogTitle>
|
||||
{modalDescription && <p className="mt-1 text-sm text-gray-600">{modalDescription}</p>}
|
||||
</DialogHeader>
|
||||
)}
|
||||
|
||||
{/* 제목/설명이 없으면 접근성을 위한 숨김 헤더만 표시 */}
|
||||
{!modalTitle && !modalDescription && (
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>수정</DialogTitle>
|
||||
</DialogHeader>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{loading ? (
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
||||
|
||||
|
||||
// SaveModal 상태 (등록/수정 통합)
|
||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||
const [saveModalData, setSaveModalData] = useState<Record<string, any> | undefined>(undefined);
|
||||
|
|
@ -169,7 +169,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
};
|
||||
|
||||
window.addEventListener("refreshTable", handleRefreshTable);
|
||||
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("refreshTable", handleRefreshTable);
|
||||
};
|
||||
|
|
@ -531,7 +531,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
useEffect(() => {
|
||||
const handleRefreshFileStatus = async (event: CustomEvent) => {
|
||||
const { tableName, recordId, columnName, targetObjid, fileCount } = event.detail;
|
||||
|
||||
|
||||
// console.log("🔄 InteractiveDataTable 파일 상태 새로고침 이벤트 수신:", {
|
||||
// tableName,
|
||||
// recordId,
|
||||
|
|
@ -545,10 +545,10 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
if (tableName === component.tableName) {
|
||||
// 해당 행의 파일 상태 업데이트
|
||||
const columnKey = `${recordId}_${columnName}`;
|
||||
setFileStatusMap(prev => ({
|
||||
setFileStatusMap((prev) => ({
|
||||
...prev,
|
||||
[recordId]: { hasFiles: fileCount > 0, fileCount },
|
||||
[columnKey]: { hasFiles: fileCount > 0, fileCount }
|
||||
[columnKey]: { hasFiles: fileCount > 0, fileCount },
|
||||
}));
|
||||
|
||||
// console.log("✅ 파일 상태 업데이트 완료:", {
|
||||
|
|
@ -560,11 +560,11 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('refreshFileStatus', handleRefreshFileStatus as EventListener);
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("refreshFileStatus", handleRefreshFileStatus as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('refreshFileStatus', handleRefreshFileStatus as EventListener);
|
||||
window.removeEventListener("refreshFileStatus", handleRefreshFileStatus as EventListener);
|
||||
};
|
||||
}
|
||||
}, [component.tableName]);
|
||||
|
|
@ -732,7 +732,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
|
||||
// SaveModal 열기 (등록 모드)
|
||||
const screenId = component.addModalConfig?.screenId;
|
||||
|
||||
|
||||
if (!screenId) {
|
||||
toast.error("화면 설정이 필요합니다. 테이블 설정에서 추가 모달 화면을 지정해주세요.");
|
||||
return;
|
||||
|
|
@ -768,11 +768,25 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
initialData[col.columnName] = selectedRowData[col.columnName] || "";
|
||||
});
|
||||
|
||||
// SaveModal 사용
|
||||
setSaveModalData(initialData); // 데이터 있음 = 수정 모드
|
||||
setSaveModalScreenId(screenId);
|
||||
setShowSaveModal(true);
|
||||
}, [selectedRows, data, getDisplayColumns, component.addModalConfig]);
|
||||
setEditFormData(initialData);
|
||||
setEditingRowData(selectedRowData);
|
||||
|
||||
// 수정 모달 설정에서 제목과 설명 가져오기
|
||||
const editModalTitle = component.editModalConfig?.title || "";
|
||||
const editModalDescription = component.editModalConfig?.description || "";
|
||||
|
||||
console.log("📝 수정 모달 설정:", { editModalTitle, editModalDescription });
|
||||
|
||||
setShowEditModal(true);
|
||||
}, [selectedRows, data, getDisplayColumns, component.editModalConfig]);
|
||||
|
||||
// 수정 폼 데이터 변경 핸들러
|
||||
const handleEditFormChange = useCallback((columnName: string, value: any) => {
|
||||
setEditFormData((prev) => ({
|
||||
...prev,
|
||||
[columnName]: value,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 파일 업로드 핸들러
|
||||
const handleFileUpload = useCallback(
|
||||
|
|
@ -882,7 +896,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
{currentFiles.map((file, index) => (
|
||||
<div key={index} className="flex items-center justify-between rounded border bg-gray-50 p-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="text-xs text-muted-foreground">📄</div>
|
||||
<div className="text-muted-foreground text-xs">📄</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{file.name}</p>
|
||||
<p className="text-xs text-gray-500">{(file.size / 1024).toFixed(1)} KB</p>
|
||||
|
|
@ -900,9 +914,9 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
</div>
|
||||
))}
|
||||
{isUploading && (
|
||||
<div className="flex items-center space-x-2 rounded border bg-accent p-2">
|
||||
<div className="bg-accent flex items-center space-x-2 rounded border p-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm text-primary">업로드 중...</span>
|
||||
<span className="text-primary text-sm">업로드 중...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1682,13 +1696,13 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 hover:bg-accent"
|
||||
className="hover:bg-accent h-8 w-8 p-0"
|
||||
onClick={() => handleColumnFileClick(rowData, column)}
|
||||
title={hasFiles ? `${fileCount}개 파일 보기` : "파일 업로드"}
|
||||
>
|
||||
{hasFiles ? (
|
||||
<div className="relative">
|
||||
<FolderOpen className="h-4 w-4 text-primary" />
|
||||
<FolderOpen className="text-primary h-4 w-4" />
|
||||
{fileCount > 0 && (
|
||||
<div className="absolute -top-1 -right-1 flex h-3 w-3 items-center justify-center rounded-full bg-blue-600 text-[10px] text-white">
|
||||
{fileCount > 9 ? "9+" : fileCount}
|
||||
|
|
@ -1741,7 +1755,13 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex h-full flex-col rounded-xl border border-gray-200/60 bg-gradient-to-br from-white to-gray-50/30 shadow-sm", className)} style={{ ...style, minHeight: "680px" }}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full flex-col rounded-xl border border-gray-200/60 bg-gradient-to-br from-white to-gray-50/30 shadow-sm",
|
||||
className,
|
||||
)}
|
||||
style={{ ...style, minHeight: "680px" }}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="p-6 pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -1831,85 +1851,85 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
<div className="flex h-full flex-col">
|
||||
{visibleColumns.length > 0 ? (
|
||||
<>
|
||||
<div className="rounded-lg border border-gray-200/60 bg-white shadow-sm overflow-hidden">
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200/60 bg-white shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{/* 체크박스 컬럼 (삭제 기능이 활성화된 경우) */}
|
||||
{component.enableDelete && (
|
||||
<TableHead className="w-12 px-4">
|
||||
<Checkbox
|
||||
checked={selectedRows.size === data.length && data.length > 0}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
)}
|
||||
{visibleColumns.map((column: DataTableColumn) => (
|
||||
<TableHead
|
||||
key={column.id}
|
||||
className="px-4 font-semibold text-gray-700 bg-gradient-to-r from-gray-50 to-slate-50"
|
||||
style={{ width: `${((column.gridColumns || 2) / totalGridColumns) * 100}%` }}
|
||||
>
|
||||
{column.label}
|
||||
</TableHead>
|
||||
))}
|
||||
{/* 자동 파일 컬럼 표시 제거됨 - 명시적으로 추가된 파일 컬럼만 표시 */}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={visibleColumns.length + (component.enableDelete ? 1 : 0)}
|
||||
className="h-32 text-center"
|
||||
>
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
데이터를 불러오는 중...
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* 체크박스 컬럼 (삭제 기능이 활성화된 경우) */}
|
||||
{component.enableDelete && (
|
||||
<TableHead className="w-12 px-4">
|
||||
<Checkbox
|
||||
checked={selectedRows.size === data.length && data.length > 0}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
)}
|
||||
{visibleColumns.map((column: DataTableColumn) => (
|
||||
<TableHead
|
||||
key={column.id}
|
||||
className="bg-gradient-to-r from-gray-50 to-slate-50 px-4 font-semibold text-gray-700"
|
||||
style={{ width: `${((column.gridColumns || 2) / totalGridColumns) * 100}%` }}
|
||||
>
|
||||
{column.label}
|
||||
</TableHead>
|
||||
))}
|
||||
{/* 자동 파일 컬럼 표시 제거됨 - 명시적으로 추가된 파일 컬럼만 표시 */}
|
||||
</TableRow>
|
||||
) : data.length > 0 ? (
|
||||
data.map((row, rowIndex) => (
|
||||
<TableRow key={rowIndex} className="hover:bg-orange-100 transition-all duration-200">
|
||||
{/* 체크박스 셀 (삭제 기능이 활성화된 경우) */}
|
||||
{component.enableDelete && (
|
||||
<TableCell className="w-12 px-4">
|
||||
<Checkbox
|
||||
checked={selectedRows.has(rowIndex)}
|
||||
onCheckedChange={(checked) => handleRowSelect(rowIndex, checked as boolean)}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
{visibleColumns.map((column: DataTableColumn) => (
|
||||
<TableCell key={column.id} className="px-4 text-sm font-medium text-gray-900">
|
||||
{formatCellValue(row[column.columnName], column, row)}
|
||||
</TableCell>
|
||||
))}
|
||||
{/* 자동 파일 셀 표시 제거됨 - 명시적으로 추가된 파일 컬럼만 표시 */}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={visibleColumns.length + (component.enableDelete ? 1 : 0)}
|
||||
className="h-32 text-center"
|
||||
>
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
데이터를 불러오는 중...
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={visibleColumns.length + (component.enableDelete ? 1 : 0)}
|
||||
className="h-32 text-center"
|
||||
>
|
||||
<div className="text-muted-foreground flex flex-col items-center gap-2">
|
||||
<Database className="h-8 w-8" />
|
||||
<p>검색 결과가 없습니다</p>
|
||||
<p className="text-xs">검색 조건을 변경하거나 새로고침을 시도해보세요</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : data.length > 0 ? (
|
||||
data.map((row, rowIndex) => (
|
||||
<TableRow key={rowIndex} className="transition-all duration-200 hover:bg-orange-100">
|
||||
{/* 체크박스 셀 (삭제 기능이 활성화된 경우) */}
|
||||
{component.enableDelete && (
|
||||
<TableCell className="w-12 px-4">
|
||||
<Checkbox
|
||||
checked={selectedRows.has(rowIndex)}
|
||||
onCheckedChange={(checked) => handleRowSelect(rowIndex, checked as boolean)}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
{visibleColumns.map((column: DataTableColumn) => (
|
||||
<TableCell key={column.id} className="px-4 text-sm font-medium text-gray-900">
|
||||
{formatCellValue(row[column.columnName], column, row)}
|
||||
</TableCell>
|
||||
))}
|
||||
{/* 자동 파일 셀 표시 제거됨 - 명시적으로 추가된 파일 컬럼만 표시 */}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={visibleColumns.length + (component.enableDelete ? 1 : 0)}
|
||||
className="h-32 text-center"
|
||||
>
|
||||
<div className="text-muted-foreground flex flex-col items-center gap-2">
|
||||
<Database className="h-8 w-8" />
|
||||
<p>검색 결과가 없습니다</p>
|
||||
<p className="text-xs">검색 조건을 변경하거나 새로고침을 시도해보세요</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{component.pagination?.enabled && totalPages > 1 && (
|
||||
<div className="bg-gradient-to-r from-gray-50 to-slate-50 mt-auto border-t border-gray-200/60">
|
||||
<div className="mt-auto border-t border-gray-200/60 bg-gradient-to-r from-gray-50 to-slate-50">
|
||||
<div className="flex items-center justify-between px-6 py-3">
|
||||
{component.pagination.showPageInfo && (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
|
|
@ -2141,7 +2161,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
<h4 className="truncate font-medium text-gray-900" title={fileInfo.name}>
|
||||
{fileInfo.name}
|
||||
</h4>
|
||||
<div className="mt-1 space-y-1 text-sm text-muted-foreground">
|
||||
<div className="text-muted-foreground mt-1 space-y-1 text-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<span>크기: {(fileInfo.size / 1024 / 1024).toFixed(2)} MB</span>
|
||||
<span>타입: {fileInfo.type || "알 수 없음"}</span>
|
||||
|
|
@ -2187,7 +2207,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
|
||||
{/* 요약 정보 */}
|
||||
{currentFileData && (
|
||||
<div className="mt-4 rounded-lg border border-primary/20 bg-accent p-3">
|
||||
<div className="border-primary/20 bg-accent mt-4 rounded-lg border p-3">
|
||||
<h5 className="mb-2 font-medium text-blue-900">파일 요약</h5>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm text-blue-800">
|
||||
<div>
|
||||
|
|
@ -2331,7 +2351,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
{linkedFiles.map((file: any, index: number) => (
|
||||
<div key={index} className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<File className="h-5 w-5 text-primary" />
|
||||
<File className="text-primary h-5 w-5" />
|
||||
<div>
|
||||
<div className="font-medium">{file.realFileName}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
|
|
@ -2390,7 +2410,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleDeleteLinkedFile(file.objid, file.realFileName)}
|
||||
className="text-red-500 hover:bg-destructive/10 hover:text-red-700"
|
||||
className="hover:bg-destructive/10 text-red-500 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1386,9 +1386,29 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
|
||||
// 편집 액션
|
||||
const handleEditAction = () => {
|
||||
// console.log("✏️ 편집 모드 활성화");
|
||||
// 읽기 전용 모드를 편집 모드로 전환
|
||||
alert("편집 모드로 전환되었습니다.");
|
||||
console.log("✏️ 수정 액션 실행");
|
||||
|
||||
// 버튼 컴포넌트의 수정 모달 설정 가져오기
|
||||
const editModalTitle = config?.editModalTitle || "";
|
||||
const editModalDescription = config?.editModalDescription || "";
|
||||
|
||||
console.log("📝 버튼 수정 모달 설정:", { editModalTitle, editModalDescription });
|
||||
|
||||
// EditModal 열기 이벤트 발생
|
||||
const event = new CustomEvent("openEditModal", {
|
||||
detail: {
|
||||
screenId: screenInfo?.id,
|
||||
modalSize: "lg",
|
||||
editData: formData,
|
||||
modalTitle: editModalTitle,
|
||||
modalDescription: editModalDescription,
|
||||
onSave: () => {
|
||||
console.log("✅ 수정 완료");
|
||||
// 필요시 폼 새로고침 또는 콜백 실행
|
||||
},
|
||||
},
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
};
|
||||
|
||||
// 추가 액션
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
import { dataflowJobQueue } from "@/lib/services/dataflowJobQueue";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { executeButtonWithFlow, handleFlowExecutionResult } from "@/lib/utils/nodeFlowButtonExecutor";
|
||||
|
||||
interface OptimizedButtonProps {
|
||||
component: ComponentData;
|
||||
|
|
@ -98,7 +99,44 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
|
|||
// });
|
||||
|
||||
if (config?.enableDataflowControl && config?.dataflowConfig) {
|
||||
// 🔥 확장된 제어 검증 먼저 실행
|
||||
// 🆕 노드 플로우 방식 실행
|
||||
if (config.dataflowConfig.controlMode === "flow" && config.dataflowConfig.flowConfig) {
|
||||
console.log("🔄 노드 플로우 방식 실행:", config.dataflowConfig.flowConfig);
|
||||
|
||||
const flowResult = await executeButtonWithFlow(
|
||||
config.dataflowConfig.flowConfig,
|
||||
{
|
||||
buttonId: component.id,
|
||||
screenId: component.screenId,
|
||||
companyCode,
|
||||
userId: contextData.userId,
|
||||
formData,
|
||||
selectedRows: selectedRows || [],
|
||||
selectedRowsData: selectedRowsData || [],
|
||||
controlDataSource: config.dataflowConfig.controlDataSource,
|
||||
},
|
||||
// 원래 액션 (timing이 before나 after일 때 실행)
|
||||
async () => {
|
||||
if (!isControlOnlyAction) {
|
||||
await executeOriginalAction(config?.actionType || "save", contextData);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
handleFlowExecutionResult(flowResult, {
|
||||
buttonId: component.id,
|
||||
formData,
|
||||
onRefresh: onDataflowComplete,
|
||||
});
|
||||
|
||||
if (onActionComplete) {
|
||||
onActionComplete(flowResult);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 🔥 기존 관계 방식 실행
|
||||
const validationResult = await OptimizedButtonDataflowService.executeExtendedValidation(
|
||||
config.dataflowConfig,
|
||||
extendedContext as ExtendedControlContext,
|
||||
|
|
|
|||
|
|
@ -395,29 +395,69 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
|||
</div>
|
||||
|
||||
{config.action?.editMode === "modal" && (
|
||||
<div>
|
||||
<Label htmlFor="edit-modal-size">모달 크기</Label>
|
||||
<Select
|
||||
value={config.action?.modalSize || "lg"}
|
||||
onValueChange={(value) =>
|
||||
onUpdateProperty("componentConfig.action", {
|
||||
...config.action,
|
||||
modalSize: value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="모달 크기 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sm">작음 (Small)</SelectItem>
|
||||
<SelectItem value="md">보통 (Medium)</SelectItem>
|
||||
<SelectItem value="lg">큼 (Large)</SelectItem>
|
||||
<SelectItem value="xl">매우 큼 (Extra Large)</SelectItem>
|
||||
<SelectItem value="full">전체 화면 (Full)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="edit-modal-title">모달 제목</Label>
|
||||
<Input
|
||||
id="edit-modal-title"
|
||||
placeholder="모달 제목을 입력하세요 (예: 데이터 수정)"
|
||||
value={config.action?.editModalTitle || ""}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
onUpdateProperty("componentConfig.action", {
|
||||
...config.action,
|
||||
editModalTitle: newValue,
|
||||
});
|
||||
// webTypeConfig에도 저장
|
||||
onUpdateProperty("webTypeConfig.editModalTitle", newValue);
|
||||
}}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">비워두면 기본 제목이 표시됩니다</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="edit-modal-description">모달 설명</Label>
|
||||
<Input
|
||||
id="edit-modal-description"
|
||||
placeholder="모달 설명을 입력하세요 (예: 선택한 데이터를 수정합니다)"
|
||||
value={config.action?.editModalDescription || ""}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
onUpdateProperty("componentConfig.action", {
|
||||
...config.action,
|
||||
editModalDescription: newValue,
|
||||
});
|
||||
// webTypeConfig에도 저장
|
||||
onUpdateProperty("webTypeConfig.editModalDescription", newValue);
|
||||
}}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">비워두면 설명이 표시되지 않습니다</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="edit-modal-size">모달 크기</Label>
|
||||
<Select
|
||||
value={config.action?.modalSize || "lg"}
|
||||
onValueChange={(value) =>
|
||||
onUpdateProperty("componentConfig.action", {
|
||||
...config.action,
|
||||
modalSize: value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="모달 크기 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sm">작음 (Small)</SelectItem>
|
||||
<SelectItem value="md">보통 (Medium)</SelectItem>
|
||||
<SelectItem value="lg">큼 (Large)</SelectItem>
|
||||
<SelectItem value="xl">매우 큼 (Extra Large)</SelectItem>
|
||||
<SelectItem value="full">전체 화면 (Full)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -8,36 +8,20 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Settings,
|
||||
GitBranch,
|
||||
Clock,
|
||||
Zap,
|
||||
Info
|
||||
} from "lucide-react";
|
||||
import { ComponentData, ButtonDataflowConfig } from "@/types/screen";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { Settings, Clock, Zap, Info, Workflow } from "lucide-react";
|
||||
import { ComponentData } from "@/types/screen";
|
||||
import { getNodeFlows, NodeFlow } from "@/lib/api/nodeFlows";
|
||||
|
||||
interface ImprovedButtonControlConfigPanelProps {
|
||||
component: ComponentData;
|
||||
onUpdateProperty: (path: string, value: any) => void;
|
||||
}
|
||||
|
||||
interface RelationshipOption {
|
||||
id: string;
|
||||
name: string;
|
||||
sourceTable: string;
|
||||
targetTable: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 🔥 단순화된 버튼 제어 설정 패널
|
||||
*
|
||||
* 관계 실행만 지원:
|
||||
* - 관계 선택 및 실행 타이밍 설정
|
||||
* - 관계 내부에 데이터 저장/외부호출 로직 포함
|
||||
*
|
||||
* 노드 플로우 실행만 지원:
|
||||
* - 플로우 선택 및 실행 타이밍 설정
|
||||
*/
|
||||
export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlConfigPanelProps> = ({
|
||||
component,
|
||||
|
|
@ -47,57 +31,45 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
|
|||
const dataflowConfig = config.dataflowConfig || {};
|
||||
|
||||
// 🔥 State 관리
|
||||
const [relationships, setRelationships] = useState<RelationshipOption[]>([]);
|
||||
const [flows, setFlows] = useState<NodeFlow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 🔥 관계 목록 로딩
|
||||
// 🔥 플로우 목록 로딩
|
||||
useEffect(() => {
|
||||
if (config.enableDataflowControl) {
|
||||
loadRelationships();
|
||||
loadFlows();
|
||||
}
|
||||
}, [config.enableDataflowControl]);
|
||||
|
||||
/**
|
||||
* 🔥 전체 관계 목록 로드 (관계도별 구분 없이)
|
||||
* 🔥 플로우 목록 로드
|
||||
*/
|
||||
const loadRelationships = async () => {
|
||||
const loadFlows = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// console.log("🔍 전체 관계 목록 로딩...");
|
||||
console.log("🔍 플로우 목록 로딩...");
|
||||
|
||||
const response = await apiClient.get("/test-button-dataflow/relationships/all");
|
||||
|
||||
if (response.data.success && Array.isArray(response.data.data)) {
|
||||
const relationshipList = response.data.data.map((rel: any) => ({
|
||||
id: rel.id,
|
||||
name: rel.name || `${rel.sourceTable} → ${rel.targetTable}`,
|
||||
sourceTable: rel.sourceTable,
|
||||
targetTable: rel.targetTable,
|
||||
category: rel.category || "데이터 흐름",
|
||||
}));
|
||||
|
||||
setRelationships(relationshipList);
|
||||
// console.log(`✅ 관계 ${relationshipList.length}개 로딩 완료`);
|
||||
}
|
||||
const flowList = await getNodeFlows();
|
||||
setFlows(flowList);
|
||||
console.log(`✅ 플로우 ${flowList.length}개 로딩 완료`);
|
||||
} catch (error) {
|
||||
// console.error("❌ 관계 목록 로딩 실패:", error);
|
||||
setRelationships([]);
|
||||
console.error("❌ 플로우 목록 로딩 실패:", error);
|
||||
setFlows([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 🔥 관계 선택 핸들러
|
||||
* 🔥 플로우 선택 핸들러
|
||||
*/
|
||||
const handleRelationshipSelect = (relationshipId: string) => {
|
||||
const selectedRelationship = relationships.find(r => r.id === relationshipId);
|
||||
if (selectedRelationship) {
|
||||
onUpdateProperty("webTypeConfig.dataflowConfig.relationshipConfig", {
|
||||
relationshipId: selectedRelationship.id,
|
||||
relationshipName: selectedRelationship.name,
|
||||
executionTiming: "after", // 기본값
|
||||
const handleFlowSelect = (flowId: string) => {
|
||||
const selectedFlow = flows.find((f) => f.flowId.toString() === flowId);
|
||||
if (selectedFlow) {
|
||||
onUpdateProperty("webTypeConfig.dataflowConfig.flowConfig", {
|
||||
flowId: selectedFlow.flowId,
|
||||
flowName: selectedFlow.flowName,
|
||||
executionTiming: "before", // 기본값
|
||||
contextData: {},
|
||||
});
|
||||
}
|
||||
|
|
@ -110,19 +82,19 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
|
|||
// 기존 설정 초기화
|
||||
onUpdateProperty("webTypeConfig.dataflowConfig", {
|
||||
controlMode: controlType,
|
||||
relationshipConfig: controlType === "relationship" ? undefined : null,
|
||||
flowConfig: controlType === "flow" ? undefined : null,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 🔥 제어관리 활성화 스위치 */}
|
||||
<div className="flex items-center justify-between rounded-lg border bg-accent p-4">
|
||||
<div className="bg-accent flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Settings className="h-4 w-4 text-primary" />
|
||||
<Settings className="text-primary h-4 w-4" />
|
||||
<div>
|
||||
<Label className="text-sm font-medium">🎮 고급 제어 기능</Label>
|
||||
<p className="mt-1 text-xs text-muted-foreground">버튼 클릭 시 추가 작업을 자동으로 실행합니다</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">버튼 클릭 시 추가 작업을 자동으로 실행합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
|
|
@ -138,46 +110,45 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
|
|||
<CardTitle>버튼 제어 설정</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs
|
||||
value={dataflowConfig.controlMode || "none"}
|
||||
onValueChange={handleControlTypeChange}
|
||||
>
|
||||
<Tabs value={dataflowConfig.controlMode || "none"} onValueChange={handleControlTypeChange}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="none">제어 없음</TabsTrigger>
|
||||
<TabsTrigger value="relationship">관계 실행</TabsTrigger>
|
||||
<TabsTrigger value="flow">노드 플로우</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="none" className="mt-4">
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Zap className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<div className="py-8 text-center text-gray-500">
|
||||
<Zap className="mx-auto mb-2 h-8 w-8 opacity-50" />
|
||||
<p>추가 제어 없이 기본 버튼 액션만 실행됩니다.</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="relationship" className="mt-4">
|
||||
<RelationshipSelector
|
||||
relationships={relationships}
|
||||
selectedRelationshipId={dataflowConfig.relationshipConfig?.relationshipId}
|
||||
onSelect={handleRelationshipSelect}
|
||||
<TabsContent value="flow" className="mt-4">
|
||||
<FlowSelector
|
||||
flows={flows}
|
||||
selectedFlowId={dataflowConfig.flowConfig?.flowId}
|
||||
onSelect={handleFlowSelect}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
{dataflowConfig.relationshipConfig && (
|
||||
|
||||
{dataflowConfig.flowConfig && (
|
||||
<div className="mt-4 space-y-4">
|
||||
<Separator />
|
||||
<ExecutionTimingSelector
|
||||
value={dataflowConfig.relationshipConfig.executionTiming}
|
||||
onChange={(timing) =>
|
||||
onUpdateProperty("webTypeConfig.dataflowConfig.relationshipConfig.executionTiming", timing)
|
||||
value={dataflowConfig.flowConfig.executionTiming}
|
||||
onChange={(timing) =>
|
||||
onUpdateProperty("webTypeConfig.dataflowConfig.flowConfig.executionTiming", timing)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="rounded bg-accent p-3">
|
||||
|
||||
<div className="rounded bg-green-50 p-3">
|
||||
<div className="flex items-start space-x-2">
|
||||
<Info className="h-4 w-4 text-primary mt-0.5" />
|
||||
<div className="text-xs text-blue-800">
|
||||
<p className="font-medium">관계 실행 정보:</p>
|
||||
<p className="mt-1">선택한 관계에 설정된 데이터 저장, 외부호출 등의 모든 액션이 자동으로 실행됩니다.</p>
|
||||
<Info className="mt-0.5 h-4 w-4 text-green-600" />
|
||||
<div className="text-xs text-green-800">
|
||||
<p className="font-medium">노드 플로우 실행 정보:</p>
|
||||
<p className="mt-1">선택한 플로우의 모든 노드가 순차적/병렬로 실행됩니다.</p>
|
||||
<p className="mt-1">• 독립 트랜잭션: 각 액션은 독립적으로 커밋/롤백</p>
|
||||
<p>• 연쇄 중단: 부모 노드 실패 시 자식 노드 스킵</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -193,38 +164,41 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
|
|||
};
|
||||
|
||||
/**
|
||||
* 🔥 관계 선택 컴포넌트
|
||||
* 🔥 플로우 선택 컴포넌트
|
||||
*/
|
||||
const RelationshipSelector: React.FC<{
|
||||
relationships: RelationshipOption[];
|
||||
selectedRelationshipId?: string;
|
||||
onSelect: (relationshipId: string) => void;
|
||||
const FlowSelector: React.FC<{
|
||||
flows: NodeFlow[];
|
||||
selectedFlowId?: number;
|
||||
onSelect: (flowId: string) => void;
|
||||
loading: boolean;
|
||||
}> = ({ relationships, selectedRelationshipId, onSelect, loading }) => {
|
||||
}> = ({ flows, selectedFlowId, onSelect, loading }) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<GitBranch className="h-4 w-4 text-primary" />
|
||||
<Label>실행할 관계 선택</Label>
|
||||
<Workflow className="h-4 w-4 text-green-600" />
|
||||
<Label>실행할 노드 플로우 선택</Label>
|
||||
</div>
|
||||
|
||||
<Select value={selectedRelationshipId || ""} onValueChange={onSelect}>
|
||||
|
||||
<Select value={selectedFlowId?.toString() || ""} onValueChange={onSelect}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="관계를 선택하세요" />
|
||||
<SelectValue placeholder="플로우를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{loading ? (
|
||||
<div className="p-4 text-center text-sm text-gray-500">관계 목록을 불러오는 중...</div>
|
||||
) : relationships.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-gray-500">사용 가능한 관계가 없습니다</div>
|
||||
<div className="p-4 text-center text-sm text-gray-500">플로우 목록을 불러오는 중...</div>
|
||||
) : flows.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-gray-500">
|
||||
<p>사용 가능한 플로우가 없습니다</p>
|
||||
<p className="mt-2 text-xs">노드 편집기에서 플로우를 먼저 생성하세요</p>
|
||||
</div>
|
||||
) : (
|
||||
relationships.map((rel) => (
|
||||
<SelectItem key={rel.id} value={rel.id}>
|
||||
flows.map((flow) => (
|
||||
<SelectItem key={flow.flowId} value={flow.flowId.toString()}>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{rel.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{rel.sourceTable} → {rel.targetTable}
|
||||
</span>
|
||||
<span className="font-medium">{flow.flowName}</span>
|
||||
{flow.flowDescription && (
|
||||
<span className="text-muted-foreground text-xs">{flow.flowDescription}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
|
|
@ -235,7 +209,6 @@ const RelationshipSelector: React.FC<{
|
|||
);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 🔥 실행 타이밍 선택 컴포넌트
|
||||
*/
|
||||
|
|
@ -249,7 +222,7 @@ const ExecutionTimingSelector: React.FC<{
|
|||
<Clock className="h-4 w-4 text-orange-600" />
|
||||
<Label>실행 타이밍</Label>
|
||||
</div>
|
||||
|
||||
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="실행 타이밍을 선택하세요" />
|
||||
|
|
@ -258,19 +231,19 @@ const ExecutionTimingSelector: React.FC<{
|
|||
<SelectItem value="before">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">Before (사전 실행)</span>
|
||||
<span className="text-xs text-muted-foreground">버튼 액션 실행 전에 제어를 실행합니다</span>
|
||||
<span className="text-muted-foreground text-xs">버튼 액션 실행 전에 플로우를 실행합니다</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="after">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">After (사후 실행)</span>
|
||||
<span className="text-xs text-muted-foreground">버튼 액션 실행 후에 제어를 실행합니다</span>
|
||||
<span className="text-muted-foreground text-xs">버튼 액션 실행 후에 플로우를 실행합니다</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="replace">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">Replace (대체 실행)</span>
|
||||
<span className="text-xs text-muted-foreground">버튼 액션 대신 제어만 실행합니다</span>
|
||||
<span className="text-muted-foreground text-xs">버튼 액션 대신 플로우만 실행합니다</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
|
|
@ -278,4 +251,3 @@ const ExecutionTimingSelector: React.FC<{
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
addButtonText: component.addButtonText || "추가",
|
||||
editButtonText: component.editButtonText || "수정",
|
||||
deleteButtonText: component.deleteButtonText || "삭제",
|
||||
// 모달 설정
|
||||
// 추가 모달 설정
|
||||
modalTitle: component.addModalConfig?.title || "새 데이터 추가",
|
||||
// 테이블명도 로컬 상태로 관리
|
||||
tableName: component.tableName || "",
|
||||
|
|
@ -62,6 +62,9 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
modalGridColumns: component.addModalConfig?.gridColumns || 2,
|
||||
modalSubmitButtonText: component.addModalConfig?.submitButtonText || "추가",
|
||||
modalCancelButtonText: component.addModalConfig?.cancelButtonText || "취소",
|
||||
// 수정 모달 설정
|
||||
editModalTitle: component.editModalConfig?.title || "",
|
||||
editModalDescription: component.editModalConfig?.description || "",
|
||||
paginationEnabled: component.pagination?.enabled ?? true,
|
||||
showPageSizeSelector: component.pagination?.showPageSizeSelector ?? true,
|
||||
showPageInfo: component.pagination?.showPageInfo ?? true,
|
||||
|
|
@ -161,7 +164,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
addButtonText: component.addButtonText || "추가",
|
||||
editButtonText: component.editButtonText || "수정",
|
||||
deleteButtonText: component.deleteButtonText || "삭제",
|
||||
// 모달 설정
|
||||
// 추가 모달 설정
|
||||
modalTitle: component.addModalConfig?.title || "새 데이터 추가",
|
||||
modalDescription: component.addModalConfig?.description || "",
|
||||
modalWidth: component.addModalConfig?.width || "lg",
|
||||
|
|
@ -169,6 +172,9 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
modalGridColumns: component.addModalConfig?.gridColumns || 2,
|
||||
modalSubmitButtonText: component.addModalConfig?.submitButtonText || "추가",
|
||||
modalCancelButtonText: component.addModalConfig?.cancelButtonText || "취소",
|
||||
// 수정 모달 설정
|
||||
editModalTitle: component.editModalConfig?.title || "",
|
||||
editModalDescription: component.editModalConfig?.description || "",
|
||||
paginationEnabled: component.pagination?.enabled ?? true,
|
||||
showPageSizeSelector: component.pagination?.showPageSizeSelector ?? true,
|
||||
showPageInfo: component.pagination?.showPageInfo ?? true,
|
||||
|
|
@ -1379,6 +1385,55 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 수정 모달 설정 */}
|
||||
{localValues.enableEdit && (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<h4 className="mb-3 text-sm font-medium text-gray-900">수정 모달 설정</h4>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-modal-title" className="text-sm">
|
||||
모달 제목
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-modal-title"
|
||||
value={localValues.editModalTitle}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalValues((prev) => ({ ...prev, editModalTitle: newValue }));
|
||||
onUpdateComponent({
|
||||
editModalConfig: { ...component.editModalConfig, title: newValue },
|
||||
});
|
||||
}}
|
||||
placeholder="데이터 수정"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">비워두면 기본 제목이 표시됩니다</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-modal-description" className="text-sm">
|
||||
모달 설명
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-modal-description"
|
||||
value={localValues.editModalDescription}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalValues((prev) => ({ ...prev, editModalDescription: newValue }));
|
||||
onUpdateComponent({
|
||||
editModalConfig: { ...component.editModalConfig, description: newValue },
|
||||
});
|
||||
}}
|
||||
placeholder="선택한 데이터를 수정합니다"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">비워두면 설명이 표시되지 않습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="grid-columns">그리드 컬럼 수</Label>
|
||||
<select
|
||||
|
|
|
|||
|
|
@ -0,0 +1,251 @@
|
|||
# REST API UI 구현 패턴
|
||||
|
||||
UPDATE, DELETE, UPSERT 노드에 적용할 REST API UI 패턴입니다.
|
||||
|
||||
## 1. Import 추가
|
||||
|
||||
```typescript
|
||||
import { Database, Globe, Link2 } from "lucide-react";
|
||||
import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections";
|
||||
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
|
||||
```
|
||||
|
||||
## 2. 상태 변수 추가
|
||||
|
||||
```typescript
|
||||
// 타겟 타입 상태
|
||||
const [targetType, setTargetType] = useState<"internal" | "external" | "api">(data.targetType || "internal");
|
||||
|
||||
// 외부 DB 관련 상태
|
||||
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
|
||||
const [externalConnectionsLoading, setExternalConnectionsLoading] = useState(false);
|
||||
const [selectedExternalConnectionId, setSelectedExternalConnectionId] = useState<number | undefined>(data.externalConnectionId);
|
||||
const [externalTables, setExternalTables] = useState<ExternalTable[]>([]);
|
||||
const [externalTablesLoading, setExternalTablesLoading] = useState(false);
|
||||
const [externalTargetTable, setExternalTargetTable] = useState(data.externalTargetTable);
|
||||
const [externalColumns, setExternalColumns] = useState<ExternalColumn[]>([]);
|
||||
const [externalColumnsLoading, setExternalColumnsLoading] = useState(false);
|
||||
|
||||
// REST API 관련 상태
|
||||
const [apiEndpoint, setApiEndpoint] = useState(data.apiEndpoint || "");
|
||||
const [apiMethod, setApiMethod] = useState<"PUT" | "PATCH" | "DELETE">(data.apiMethod || "PUT");
|
||||
const [apiAuthType, setApiAuthType] = useState<"none" | "basic" | "bearer" | "apikey">(data.apiAuthType || "none");
|
||||
const [apiAuthConfig, setApiAuthConfig] = useState(data.apiAuthConfig || {});
|
||||
const [apiHeaders, setApiHeaders] = useState<Record<string, string>>(data.apiHeaders || {});
|
||||
const [apiBodyTemplate, setApiBodyTemplate] = useState(data.apiBodyTemplate || "");
|
||||
```
|
||||
|
||||
## 3. 타겟 타입 선택 UI (기본 정보 섹션 내부)
|
||||
|
||||
기존 "타겟 테이블" 입력 필드 위에 추가:
|
||||
|
||||
```tsx
|
||||
{/* 🔥 타겟 타입 선택 */}
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium">타겟 선택</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{/* 내부 데이터베이스 */}
|
||||
<button
|
||||
onClick={() => handleTargetTypeChange("internal")}
|
||||
className={cn(
|
||||
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
|
||||
targetType === "internal"
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "border-gray-200 hover:border-gray-300"
|
||||
)}
|
||||
>
|
||||
<Database className={cn("h-5 w-5", targetType === "internal" ? "text-blue-600" : "text-gray-400")} />
|
||||
<span className={cn("text-xs font-medium", targetType === "internal" ? "text-blue-700" : "text-gray-600")}>
|
||||
내부 DB
|
||||
</span>
|
||||
{targetType === "internal" && (
|
||||
<Check className="absolute right-2 top-2 h-4 w-4 text-blue-600" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 외부 데이터베이스 */}
|
||||
<button
|
||||
onClick={() => handleTargetTypeChange("external")}
|
||||
className={cn(
|
||||
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
|
||||
targetType === "external"
|
||||
? "border-green-500 bg-green-50"
|
||||
: "border-gray-200 hover:border-gray-300"
|
||||
)}
|
||||
>
|
||||
<Globe className={cn("h-5 w-5", targetType === "external" ? "text-green-600" : "text-gray-400")} />
|
||||
<span className={cn("text-xs font-medium", targetType === "external" ? "text-green-700" : "text-gray-600")}>
|
||||
외부 DB
|
||||
</span>
|
||||
{targetType === "external" && (
|
||||
<Check className="absolute right-2 top-2 h-4 w-4 text-green-600" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* REST API */}
|
||||
<button
|
||||
onClick={() => handleTargetTypeChange("api")}
|
||||
className={cn(
|
||||
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
|
||||
targetType === "api"
|
||||
? "border-purple-500 bg-purple-50"
|
||||
: "border-gray-200 hover:border-gray-300"
|
||||
)}
|
||||
>
|
||||
<Link2 className={cn("h-5 w-5", targetType === "api" ? "text-purple-600" : "text-gray-400")} />
|
||||
<span className={cn("text-xs font-medium", targetType === "api" ? "text-purple-700" : "text-gray-600")}>
|
||||
REST API
|
||||
</span>
|
||||
{targetType === "api" && (
|
||||
<Check className="absolute right-2 top-2 h-4 w-4 text-purple-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 4. REST API 설정 UI (타겟 타입이 "api"일 때)
|
||||
|
||||
기존 테이블 선택 UI를 조건부로 변경하고, REST API UI 추가:
|
||||
|
||||
```tsx
|
||||
{/* 내부 DB 설정 */}
|
||||
{targetType === "internal" && (
|
||||
<div>
|
||||
{/* 기존 타겟 테이블 Combobox */}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 외부 DB 설정 (INSERT 노드 참고) */}
|
||||
{targetType === "external" && (
|
||||
<div className="space-y-4">
|
||||
{/* 외부 커넥션 선택, 테이블 선택, 컬럼 표시 */}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* REST API 설정 */}
|
||||
{targetType === "api" && (
|
||||
<div className="space-y-4">
|
||||
{/* API 엔드포인트 */}
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs font-medium">API 엔드포인트</Label>
|
||||
<Input
|
||||
placeholder="https://api.example.com/v1/users/{id}"
|
||||
value={apiEndpoint}
|
||||
onChange={(e) => {
|
||||
setApiEndpoint(e.target.value);
|
||||
updateNode(nodeId, { apiEndpoint: e.target.value });
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* HTTP 메서드 (UPDATE: PUT/PATCH, DELETE: DELETE만) */}
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs font-medium">HTTP 메서드</Label>
|
||||
<Select
|
||||
value={apiMethod}
|
||||
onValueChange={(value) => {
|
||||
setApiMethod(value);
|
||||
updateNode(nodeId, { apiMethod: value });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* UPDATE 노드: PUT, PATCH */}
|
||||
<SelectItem value="PUT">PUT</SelectItem>
|
||||
<SelectItem value="PATCH">PATCH</SelectItem>
|
||||
{/* DELETE 노드: DELETE만 */}
|
||||
{/* <SelectItem value="DELETE">DELETE</SelectItem> */}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 인증 방식, 인증 정보, 커스텀 헤더 (INSERT와 동일) */}
|
||||
|
||||
{/* 요청 바디 템플릿 (DELETE는 제외) */}
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs font-medium">
|
||||
요청 바디 템플릿
|
||||
<span className="ml-1 text-gray-500">{`{{fieldName}}`}으로 소스 필드 참조</span>
|
||||
</Label>
|
||||
<textarea
|
||||
placeholder={`{\n "id": "{{id}}",\n "name": "{{name}}"\n}`}
|
||||
value={apiBodyTemplate}
|
||||
onChange={(e) => {
|
||||
setApiBodyTemplate(e.target.value);
|
||||
updateNode(nodeId, { apiBodyTemplate: e.target.value });
|
||||
}}
|
||||
className="w-full rounded border p-2 font-mono text-xs"
|
||||
rows={8}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
소스 데이터의 필드명을 {`{{필드명}}`} 형태로 참조할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
## 5. 필드 매핑 섹션 조건부 렌더링
|
||||
|
||||
```tsx
|
||||
{/* 필드 매핑 (REST API 타입에서는 숨김) */}
|
||||
{targetType !== "api" && (
|
||||
<div>
|
||||
{/* 기존 필드 매핑 UI */}
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
## 6. handleTargetTypeChange 함수
|
||||
|
||||
```typescript
|
||||
const handleTargetTypeChange = (newType: "internal" | "external" | "api") => {
|
||||
setTargetType(newType);
|
||||
updateNode(nodeId, {
|
||||
targetType: newType,
|
||||
// 타입별로 필요한 데이터만 유지
|
||||
...(newType === "internal" && {
|
||||
targetTable: data.targetTable,
|
||||
targetConnection: data.targetConnection,
|
||||
displayName: data.displayName,
|
||||
}),
|
||||
...(newType === "external" && {
|
||||
externalConnectionId: data.externalConnectionId,
|
||||
externalConnectionName: data.externalConnectionName,
|
||||
externalDbType: data.externalDbType,
|
||||
externalTargetTable: data.externalTargetTable,
|
||||
externalTargetSchema: data.externalTargetSchema,
|
||||
}),
|
||||
...(newType === "api" && {
|
||||
apiEndpoint: data.apiEndpoint,
|
||||
apiMethod: data.apiMethod,
|
||||
apiAuthType: data.apiAuthType,
|
||||
apiAuthConfig: data.apiAuthConfig,
|
||||
apiHeaders: data.apiHeaders,
|
||||
apiBodyTemplate: data.apiBodyTemplate,
|
||||
}),
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
## 노드별 차이점
|
||||
|
||||
### UPDATE 노드
|
||||
- HTTP 메서드: `PUT`, `PATCH`
|
||||
- WHERE 조건 필요
|
||||
- 요청 바디 템플릿 필요
|
||||
|
||||
### DELETE 노드
|
||||
- HTTP 메서드: `DELETE` 만
|
||||
- WHERE 조건 필요
|
||||
- 요청 바디 템플릿 **불필요** (쿼리 파라미터로 ID 전달)
|
||||
|
||||
### UPSERT 노드
|
||||
- HTTP 메서드: `POST`, `PUT`, `PATCH`
|
||||
- Conflict Keys 필요
|
||||
- 요청 바디 템플릿 필요
|
||||
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import { apiClient } from "./client";
|
||||
|
||||
export interface ExternalConnection {
|
||||
id: number;
|
||||
connection_name: string;
|
||||
description?: string;
|
||||
db_type: string;
|
||||
host: string;
|
||||
port: number;
|
||||
database_name: string;
|
||||
}
|
||||
|
||||
export interface ExternalTable {
|
||||
table_name: string;
|
||||
table_type?: string;
|
||||
schema?: string;
|
||||
}
|
||||
|
||||
export interface ExternalColumn {
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable?: string;
|
||||
column_default?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트에 성공한 외부 DB 커넥션 목록 조회
|
||||
*/
|
||||
export async function getTestedExternalConnections(): Promise<ExternalConnection[]> {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: ExternalConnection[];
|
||||
message?: string;
|
||||
}>("/dataflow/node-external-connections/tested");
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
throw new Error(response.data.message || "커넥션 목록을 조회할 수 없습니다.");
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 외부 DB의 테이블 목록 조회
|
||||
*/
|
||||
export async function getExternalTables(connectionId: number): Promise<ExternalTable[]> {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: ExternalTable[];
|
||||
message?: string;
|
||||
}>(`/dataflow/node-external-connections/${connectionId}/tables`);
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
throw new Error(response.data.message || "테이블 목록을 조회할 수 없습니다.");
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 외부 DB 테이블의 컬럼 목록 조회
|
||||
*/
|
||||
export async function getExternalColumns(connectionId: number, tableName: string): Promise<ExternalColumn[]> {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: ExternalColumn[];
|
||||
message?: string;
|
||||
}>(`/dataflow/node-external-connections/${connectionId}/tables/${tableName}/columns`);
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
throw new Error(response.data.message || "컬럼 목록을 조회할 수 없습니다.");
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
/**
|
||||
* 노드 플로우 API
|
||||
*/
|
||||
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export interface NodeFlow {
|
||||
flowId: number;
|
||||
flowName: string;
|
||||
flowDescription: string;
|
||||
flowData: string | any; // JSONB는 문자열 또는 객체로 반환될 수 있음
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 플로우 목록 조회
|
||||
*/
|
||||
export async function getNodeFlows(): Promise<NodeFlow[]> {
|
||||
const response = await apiClient.get<ApiResponse<NodeFlow[]>>("/dataflow/node-flows");
|
||||
if (response.data.success && response.data.data) {
|
||||
return response.data.data;
|
||||
}
|
||||
throw new Error(response.data.message || "플로우 목록을 불러올 수 없습니다.");
|
||||
}
|
||||
|
||||
/**
|
||||
* 플로우 상세 조회
|
||||
*/
|
||||
export async function getNodeFlow(flowId: number): Promise<NodeFlow> {
|
||||
const response = await apiClient.get<ApiResponse<NodeFlow>>(`/dataflow/node-flows/${flowId}`);
|
||||
if (response.data.success && response.data.data) {
|
||||
return response.data.data;
|
||||
}
|
||||
throw new Error(response.data.message || "플로우를 불러올 수 없습니다.");
|
||||
}
|
||||
|
||||
/**
|
||||
* 플로우 저장 (신규)
|
||||
*/
|
||||
export async function createNodeFlow(data: {
|
||||
flowName: string;
|
||||
flowDescription: string;
|
||||
flowData: string;
|
||||
}): Promise<{ flowId: number }> {
|
||||
const response = await apiClient.post<ApiResponse<{ flowId: number }>>("/dataflow/node-flows", data);
|
||||
if (response.data.success && response.data.data) {
|
||||
return response.data.data;
|
||||
}
|
||||
throw new Error(response.data.message || "플로우를 저장할 수 없습니다.");
|
||||
}
|
||||
|
||||
/**
|
||||
* 플로우 수정
|
||||
*/
|
||||
export async function updateNodeFlow(data: {
|
||||
flowId: number;
|
||||
flowName: string;
|
||||
flowDescription: string;
|
||||
flowData: string;
|
||||
}): Promise<{ flowId: number }> {
|
||||
const response = await apiClient.put<ApiResponse<{ flowId: number }>>("/dataflow/node-flows", data);
|
||||
if (response.data.success && response.data.data) {
|
||||
return response.data.data;
|
||||
}
|
||||
throw new Error(response.data.message || "플로우를 수정할 수 없습니다.");
|
||||
}
|
||||
|
||||
/**
|
||||
* 플로우 삭제
|
||||
*/
|
||||
export async function deleteNodeFlow(flowId: number): Promise<void> {
|
||||
const response = await apiClient.delete<ApiResponse<void>>(`/dataflow/node-flows/${flowId}`);
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "플로우를 삭제할 수 없습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 플로우 실행
|
||||
*/
|
||||
export async function executeNodeFlow(flowId: number, contextData: Record<string, any>): Promise<ExecutionResult> {
|
||||
const response = await apiClient.post<ApiResponse<ExecutionResult>>(
|
||||
`/dataflow/node-flows/${flowId}/execute`,
|
||||
contextData,
|
||||
);
|
||||
if (response.data.success && response.data.data) {
|
||||
return response.data.data;
|
||||
}
|
||||
throw new Error(response.data.message || "플로우를 실행할 수 없습니다.");
|
||||
}
|
||||
|
||||
/**
|
||||
* 플로우 실행 결과 인터페이스
|
||||
*/
|
||||
export interface ExecutionResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
executionTime: number;
|
||||
nodes: NodeExecutionSummary[];
|
||||
summary: {
|
||||
total: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NodeExecutionSummary {
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
nodeType: string;
|
||||
status: "success" | "failed" | "skipped" | "pending";
|
||||
duration?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,792 @@
|
|||
/**
|
||||
* 노드 에디터 상태 관리 스토어
|
||||
*/
|
||||
|
||||
import { create } from "zustand";
|
||||
import { Connection, Edge, EdgeChange, Node, NodeChange, addEdge, applyNodeChanges, applyEdgeChanges } from "reactflow";
|
||||
import type { FlowNode, FlowEdge, NodeType, ValidationResult } from "@/types/node-editor";
|
||||
import { createNodeFlow, updateNodeFlow } from "../api/nodeFlows";
|
||||
|
||||
// 🔥 외부 커넥션 캐시 타입
|
||||
interface ExternalConnectionCache {
|
||||
data: any[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface FlowEditorState {
|
||||
// 노드 및 엣지
|
||||
nodes: FlowNode[];
|
||||
edges: FlowEdge[];
|
||||
|
||||
// 선택 상태
|
||||
selectedNodes: string[];
|
||||
selectedEdges: string[];
|
||||
|
||||
// 플로우 메타데이터
|
||||
flowId: number | null;
|
||||
flowName: string;
|
||||
flowDescription: string;
|
||||
|
||||
// UI 상태
|
||||
isExecuting: boolean;
|
||||
isSaving: boolean;
|
||||
showValidationPanel: boolean;
|
||||
showPropertiesPanel: boolean;
|
||||
|
||||
// 검증 결과
|
||||
validationResult: ValidationResult | null;
|
||||
|
||||
// 🔥 외부 커넥션 캐시 (전역 캐싱)
|
||||
externalConnectionsCache: ExternalConnectionCache | null;
|
||||
|
||||
// ========================================================================
|
||||
// 노드 관리
|
||||
// ========================================================================
|
||||
|
||||
setNodes: (nodes: FlowNode[]) => void;
|
||||
onNodesChange: (changes: NodeChange[]) => void;
|
||||
addNode: (node: FlowNode) => void;
|
||||
updateNode: (id: string, data: Partial<FlowNode["data"]>) => void;
|
||||
removeNode: (id: string) => void;
|
||||
removeNodes: (ids: string[]) => void;
|
||||
|
||||
// ========================================================================
|
||||
// 🔥 외부 커넥션 캐시 관리
|
||||
// ========================================================================
|
||||
|
||||
setExternalConnectionsCache: (data: any[]) => void;
|
||||
clearExternalConnectionsCache: () => void;
|
||||
getExternalConnectionsCache: () => any[] | null;
|
||||
|
||||
// ========================================================================
|
||||
// 엣지 관리
|
||||
// ========================================================================
|
||||
|
||||
setEdges: (edges: FlowEdge[]) => void;
|
||||
onEdgesChange: (changes: EdgeChange[]) => void;
|
||||
onConnect: (connection: Connection) => void;
|
||||
removeEdge: (id: string) => void;
|
||||
removeEdges: (ids: string[]) => void;
|
||||
|
||||
// ========================================================================
|
||||
// 선택 관리
|
||||
// ========================================================================
|
||||
|
||||
selectNode: (id: string, multi?: boolean) => void;
|
||||
selectNodes: (ids: string[]) => void;
|
||||
selectEdge: (id: string, multi?: boolean) => void;
|
||||
clearSelection: () => void;
|
||||
|
||||
// ========================================================================
|
||||
// 플로우 관리
|
||||
// ========================================================================
|
||||
|
||||
loadFlow: (id: number, name: string, description: string, nodes: FlowNode[], edges: FlowEdge[]) => void;
|
||||
clearFlow: () => void;
|
||||
setFlowName: (name: string) => void;
|
||||
setFlowDescription: (description: string) => void;
|
||||
saveFlow: () => Promise<{ success: boolean; flowId?: number; message?: string }>;
|
||||
exportFlow: () => string;
|
||||
|
||||
// ========================================================================
|
||||
// 검증
|
||||
// ========================================================================
|
||||
|
||||
validateFlow: () => ValidationResult;
|
||||
setValidationResult: (result: ValidationResult | null) => void;
|
||||
|
||||
// ========================================================================
|
||||
// UI 상태
|
||||
// ========================================================================
|
||||
|
||||
setIsExecuting: (value: boolean) => void;
|
||||
setIsSaving: (value: boolean) => void;
|
||||
setShowValidationPanel: (value: boolean) => void;
|
||||
setShowPropertiesPanel: (value: boolean) => void;
|
||||
|
||||
// ========================================================================
|
||||
// 유틸리티
|
||||
// ========================================================================
|
||||
|
||||
getNodeById: (id: string) => FlowNode | undefined;
|
||||
getEdgeById: (id: string) => FlowEdge | undefined;
|
||||
getConnectedNodes: (nodeId: string) => { incoming: FlowNode[]; outgoing: FlowNode[] };
|
||||
}
|
||||
|
||||
export const useFlowEditorStore = create<FlowEditorState>((set, get) => ({
|
||||
// 초기 상태
|
||||
nodes: [],
|
||||
edges: [],
|
||||
selectedNodes: [],
|
||||
selectedEdges: [],
|
||||
flowId: null,
|
||||
flowName: "새 제어 플로우",
|
||||
flowDescription: "",
|
||||
isExecuting: false,
|
||||
isSaving: false,
|
||||
showValidationPanel: false,
|
||||
showPropertiesPanel: true,
|
||||
validationResult: null,
|
||||
externalConnectionsCache: null, // 🔥 캐시 초기화
|
||||
|
||||
// ========================================================================
|
||||
// 노드 관리
|
||||
// ========================================================================
|
||||
|
||||
setNodes: (nodes) => set({ nodes }),
|
||||
|
||||
onNodesChange: (changes) => {
|
||||
set({
|
||||
nodes: applyNodeChanges(changes, get().nodes) as FlowNode[],
|
||||
});
|
||||
},
|
||||
|
||||
addNode: (node) => {
|
||||
set((state) => ({
|
||||
nodes: [...state.nodes, node],
|
||||
}));
|
||||
},
|
||||
|
||||
updateNode: (id, data) => {
|
||||
set((state) => ({
|
||||
nodes: state.nodes.map((node) =>
|
||||
node.id === id
|
||||
? {
|
||||
...node,
|
||||
data: { ...node.data, ...data },
|
||||
}
|
||||
: node,
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
removeNode: (id) => {
|
||||
set((state) => ({
|
||||
nodes: state.nodes.filter((node) => node.id !== id),
|
||||
edges: state.edges.filter((edge) => edge.source !== id && edge.target !== id),
|
||||
}));
|
||||
},
|
||||
|
||||
removeNodes: (ids) => {
|
||||
set((state) => ({
|
||||
nodes: state.nodes.filter((node) => !ids.includes(node.id)),
|
||||
edges: state.edges.filter((edge) => !ids.includes(edge.source) && !ids.includes(edge.target)),
|
||||
}));
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// 엣지 관리
|
||||
// ========================================================================
|
||||
|
||||
setEdges: (edges) => set({ edges }),
|
||||
|
||||
onEdgesChange: (changes) => {
|
||||
set({
|
||||
edges: applyEdgeChanges(changes, get().edges) as FlowEdge[],
|
||||
});
|
||||
},
|
||||
|
||||
onConnect: (connection) => {
|
||||
// 연결 검증
|
||||
const validation = validateConnection(connection, get().nodes);
|
||||
if (!validation.valid) {
|
||||
console.warn("연결 검증 실패:", validation.error);
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
edges: addEdge(
|
||||
{
|
||||
...connection,
|
||||
type: "smoothstep",
|
||||
animated: false,
|
||||
data: {
|
||||
validation: { valid: true },
|
||||
},
|
||||
},
|
||||
state.edges,
|
||||
) as FlowEdge[],
|
||||
}));
|
||||
},
|
||||
|
||||
removeEdge: (id) => {
|
||||
set((state) => ({
|
||||
edges: state.edges.filter((edge) => edge.id !== id),
|
||||
}));
|
||||
},
|
||||
|
||||
removeEdges: (ids) => {
|
||||
set((state) => ({
|
||||
edges: state.edges.filter((edge) => !ids.includes(edge.id)),
|
||||
}));
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// 선택 관리
|
||||
// ========================================================================
|
||||
|
||||
selectNode: (id, multi = false) => {
|
||||
set((state) => ({
|
||||
selectedNodes: multi ? [...state.selectedNodes, id] : [id],
|
||||
}));
|
||||
},
|
||||
|
||||
selectNodes: (ids) => {
|
||||
set({
|
||||
selectedNodes: ids,
|
||||
showPropertiesPanel: ids.length > 0, // 노드가 선택되면 속성창 자동으로 열기
|
||||
});
|
||||
},
|
||||
|
||||
selectEdge: (id, multi = false) => {
|
||||
set((state) => ({
|
||||
selectedEdges: multi ? [...state.selectedEdges, id] : [id],
|
||||
}));
|
||||
},
|
||||
|
||||
clearSelection: () => {
|
||||
set({ selectedNodes: [], selectedEdges: [] });
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// 플로우 관리
|
||||
// ========================================================================
|
||||
|
||||
loadFlow: (id, name, description, nodes, edges) => {
|
||||
set({
|
||||
flowId: id,
|
||||
flowName: name,
|
||||
flowDescription: description,
|
||||
nodes,
|
||||
edges,
|
||||
selectedNodes: [],
|
||||
selectedEdges: [],
|
||||
});
|
||||
},
|
||||
|
||||
clearFlow: () => {
|
||||
set({
|
||||
flowId: null,
|
||||
flowName: "새 제어 플로우",
|
||||
flowDescription: "",
|
||||
nodes: [],
|
||||
edges: [],
|
||||
selectedNodes: [],
|
||||
selectedEdges: [],
|
||||
validationResult: null,
|
||||
});
|
||||
},
|
||||
|
||||
setFlowName: (name) => set({ flowName: name }),
|
||||
setFlowDescription: (description) => set({ flowDescription: description }),
|
||||
|
||||
saveFlow: async () => {
|
||||
const { flowId, flowName, flowDescription, nodes, edges } = get();
|
||||
|
||||
if (!flowName || flowName.trim() === "") {
|
||||
return { success: false, message: "플로우 이름을 입력해주세요." };
|
||||
}
|
||||
|
||||
// 검증
|
||||
const validation = get().validateFlow();
|
||||
if (!validation.valid) {
|
||||
return { success: false, message: `검증 실패: ${validation.errors[0]?.message || "오류가 있습니다."}` };
|
||||
}
|
||||
|
||||
set({ isSaving: true });
|
||||
|
||||
try {
|
||||
// 플로우 데이터 직렬화
|
||||
const flowData = {
|
||||
nodes: nodes.map((node) => ({
|
||||
id: node.id,
|
||||
type: node.type,
|
||||
position: node.position,
|
||||
data: node.data,
|
||||
})),
|
||||
edges: edges.map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
sourceHandle: edge.sourceHandle,
|
||||
targetHandle: edge.targetHandle,
|
||||
})),
|
||||
};
|
||||
|
||||
const result = flowId
|
||||
? await updateNodeFlow({
|
||||
flowId,
|
||||
flowName,
|
||||
flowDescription,
|
||||
flowData: JSON.stringify(flowData),
|
||||
})
|
||||
: await createNodeFlow({
|
||||
flowName,
|
||||
flowDescription,
|
||||
flowData: JSON.stringify(flowData),
|
||||
});
|
||||
|
||||
set({ flowId: result.flowId });
|
||||
return { success: true, flowId: result.flowId, message: "저장 완료!" };
|
||||
} catch (error) {
|
||||
console.error("플로우 저장 오류:", error);
|
||||
return { success: false, message: error instanceof Error ? error.message : "저장 중 오류 발생" };
|
||||
} finally {
|
||||
set({ isSaving: false });
|
||||
}
|
||||
},
|
||||
|
||||
exportFlow: () => {
|
||||
const { flowName, flowDescription, nodes, edges } = get();
|
||||
const flowData = {
|
||||
flowName,
|
||||
flowDescription,
|
||||
nodes,
|
||||
edges,
|
||||
version: "1.0",
|
||||
exportedAt: new Date().toISOString(),
|
||||
};
|
||||
return JSON.stringify(flowData, null, 2);
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// 검증
|
||||
// ========================================================================
|
||||
|
||||
validateFlow: () => {
|
||||
const { nodes, edges } = get();
|
||||
const result = performFlowValidation(nodes, edges);
|
||||
set({ validationResult: result });
|
||||
return result;
|
||||
},
|
||||
|
||||
setValidationResult: (result) => set({ validationResult: result }),
|
||||
|
||||
// ========================================================================
|
||||
// UI 상태
|
||||
// ========================================================================
|
||||
|
||||
setIsExecuting: (value) => set({ isExecuting: value }),
|
||||
setIsSaving: (value) => set({ isSaving: value }),
|
||||
setShowValidationPanel: (value) => set({ showValidationPanel: value }),
|
||||
setShowPropertiesPanel: (value) => set({ showPropertiesPanel: value }),
|
||||
|
||||
// ========================================================================
|
||||
// 유틸리티
|
||||
// ========================================================================
|
||||
|
||||
getNodeById: (id) => {
|
||||
return get().nodes.find((node) => node.id === id);
|
||||
},
|
||||
|
||||
getEdgeById: (id) => {
|
||||
return get().edges.find((edge) => edge.id === id);
|
||||
},
|
||||
|
||||
getConnectedNodes: (nodeId) => {
|
||||
const { nodes, edges } = get();
|
||||
|
||||
const incoming = edges
|
||||
.filter((edge) => edge.target === nodeId)
|
||||
.map((edge) => nodes.find((node) => node.id === edge.source))
|
||||
.filter((node): node is FlowNode => node !== undefined);
|
||||
|
||||
const outgoing = edges
|
||||
.filter((edge) => edge.source === nodeId)
|
||||
.map((edge) => nodes.find((node) => node.id === edge.target))
|
||||
.filter((node): node is FlowNode => node !== undefined);
|
||||
|
||||
return { incoming, outgoing };
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// 🔥 외부 커넥션 캐시 관리
|
||||
// ========================================================================
|
||||
|
||||
setExternalConnectionsCache: (data) => {
|
||||
set({
|
||||
externalConnectionsCache: {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
clearExternalConnectionsCache: () => {
|
||||
set({ externalConnectionsCache: null });
|
||||
},
|
||||
|
||||
getExternalConnectionsCache: () => {
|
||||
const cache = get().externalConnectionsCache;
|
||||
if (!cache) return null;
|
||||
|
||||
// 🔥 5분 후 캐시 만료
|
||||
const CACHE_DURATION = 5 * 60 * 1000; // 5분
|
||||
const isExpired = Date.now() - cache.timestamp > CACHE_DURATION;
|
||||
|
||||
if (isExpired) {
|
||||
set({ externalConnectionsCache: null });
|
||||
return null;
|
||||
}
|
||||
|
||||
return cache.data;
|
||||
},
|
||||
}));
|
||||
|
||||
// ============================================================================
|
||||
// 헬퍼 함수들
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 연결 검증
|
||||
*/
|
||||
function validateConnection(connection: Connection, nodes: FlowNode[]): { valid: boolean; error?: string } {
|
||||
const sourceNode = nodes.find((n) => n.id === connection.source);
|
||||
const targetNode = nodes.find((n) => n.id === connection.target);
|
||||
|
||||
if (!sourceNode || !targetNode) {
|
||||
return { valid: false, error: "노드를 찾을 수 없습니다" };
|
||||
}
|
||||
|
||||
// 소스 노드가 출력을 가져야 함
|
||||
if (isSourceOnlyNode(sourceNode.type)) {
|
||||
// OK
|
||||
} else if (isActionNode(targetNode.type)) {
|
||||
// 액션 노드는 입력만 받을 수 있음
|
||||
} else {
|
||||
// 기타 경우는 허용
|
||||
}
|
||||
|
||||
// 자기 자신에게 연결 방지
|
||||
if (connection.source === connection.target) {
|
||||
return { valid: false, error: "자기 자신에게 연결할 수 없습니다" };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 플로우 검증
|
||||
*/
|
||||
function performFlowValidation(nodes: FlowNode[], edges: FlowEdge[]): ValidationResult {
|
||||
const errors: ValidationResult["errors"] = [];
|
||||
|
||||
// 1. 노드가 하나도 없으면 경고
|
||||
if (nodes.length === 0) {
|
||||
errors.push({
|
||||
message: "노드가 없습니다. 최소 하나의 노드를 추가하세요.",
|
||||
severity: "warning",
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 소스 노드 확인
|
||||
const sourceNodes = nodes.filter((n) => isSourceNode(n.type));
|
||||
if (sourceNodes.length === 0 && nodes.length > 0) {
|
||||
errors.push({
|
||||
message: "데이터 소스 노드가 필요합니다.",
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 액션 노드 확인
|
||||
const actionNodes = nodes.filter((n) => isActionNode(n.type));
|
||||
if (actionNodes.length === 0 && nodes.length > 0) {
|
||||
errors.push({
|
||||
message: "최소 하나의 액션 노드가 필요합니다.",
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
|
||||
// 4. 고아 노드 확인 (연결되지 않은 노드) - Comment와 Log는 제외
|
||||
nodes.forEach((node) => {
|
||||
// Comment와 Log는 독립적으로 존재 가능
|
||||
if (node.type === "comment" || node.type === "log") {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasIncoming = edges.some((e) => e.target === node.id);
|
||||
const hasOutgoing = edges.some((e) => e.source === node.id);
|
||||
|
||||
if (!hasIncoming && !hasOutgoing && !isSourceNode(node.type)) {
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: `노드 "${node.data.displayName || node.id}"가 연결되어 있지 않습니다.`,
|
||||
severity: "warning",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 5. 액션 노드가 입력을 받는지 확인
|
||||
actionNodes.forEach((node) => {
|
||||
const hasInput = edges.some((e) => e.target === node.id);
|
||||
if (!hasInput) {
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: `액션 노드 "${node.data.displayName || node.id}"에 입력 데이터가 없습니다.`,
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 6. 순환 참조 검증
|
||||
const cycles = detectCycles(nodes, edges);
|
||||
cycles.forEach((cycle) => {
|
||||
errors.push({
|
||||
message: `순환 참조가 감지되었습니다: ${cycle.join(" → ")}`,
|
||||
severity: "error",
|
||||
});
|
||||
});
|
||||
|
||||
// 7. 노드별 필수 속성 검증
|
||||
nodes.forEach((node) => {
|
||||
const nodeErrors = validateNodeProperties(node);
|
||||
errors.push(...nodeErrors);
|
||||
});
|
||||
|
||||
return {
|
||||
valid: errors.filter((e) => e.severity === "error").length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 소스 노드 여부 확인
|
||||
*/
|
||||
function isSourceNode(type: NodeType): boolean {
|
||||
return type === "tableSource" || type === "externalDBSource" || type === "restAPISource";
|
||||
}
|
||||
|
||||
/**
|
||||
* 소스 전용 노드 여부 확인
|
||||
*/
|
||||
function isSourceOnlyNode(type: NodeType): boolean {
|
||||
return isSourceNode(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 액션 노드 여부 확인
|
||||
*/
|
||||
function isActionNode(type: NodeType): boolean {
|
||||
return type === "insertAction" || type === "updateAction" || type === "deleteAction" || type === "upsertAction";
|
||||
}
|
||||
|
||||
/**
|
||||
* 순환 참조 검증 (DFS 사용)
|
||||
*/
|
||||
function detectCycles(nodes: FlowNode[], edges: FlowEdge[]): string[][] {
|
||||
const cycles: string[][] = [];
|
||||
const visited = new Set<string>();
|
||||
const recursionStack = new Set<string>();
|
||||
|
||||
// 인접 리스트 생성
|
||||
const adjacencyList = new Map<string, string[]>();
|
||||
nodes.forEach((node) => adjacencyList.set(node.id, []));
|
||||
edges.forEach((edge) => {
|
||||
const targets = adjacencyList.get(edge.source) || [];
|
||||
targets.push(edge.target);
|
||||
adjacencyList.set(edge.source, targets);
|
||||
});
|
||||
|
||||
function dfs(nodeId: string, path: string[]): boolean {
|
||||
visited.add(nodeId);
|
||||
recursionStack.add(nodeId);
|
||||
path.push(nodeId);
|
||||
|
||||
const neighbors = adjacencyList.get(nodeId) || [];
|
||||
for (const neighbor of neighbors) {
|
||||
if (!visited.has(neighbor)) {
|
||||
if (dfs(neighbor, [...path])) {
|
||||
return true;
|
||||
}
|
||||
} else if (recursionStack.has(neighbor)) {
|
||||
// 순환 발견
|
||||
const cycleStart = path.indexOf(neighbor);
|
||||
const cycle = path.slice(cycleStart).concat(neighbor);
|
||||
const nodeNames = cycle.map((id) => {
|
||||
const node = nodes.find((n) => n.id === id);
|
||||
return node?.data.displayName || id;
|
||||
});
|
||||
cycles.push(nodeNames);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
recursionStack.delete(nodeId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 모든 노드에서 DFS 시작
|
||||
nodes.forEach((node) => {
|
||||
if (!visited.has(node.id)) {
|
||||
dfs(node.id, []);
|
||||
}
|
||||
});
|
||||
|
||||
return cycles;
|
||||
}
|
||||
|
||||
/**
|
||||
* 노드별 필수 속성 검증
|
||||
*/
|
||||
function validateNodeProperties(node: FlowNode): ValidationResult["errors"] {
|
||||
const errors: ValidationResult["errors"] = [];
|
||||
|
||||
switch (node.type) {
|
||||
case "tableSource":
|
||||
if (!node.data.tableName || node.data.tableName.trim() === "") {
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: `테이블 소스 노드 "${node.data.displayName || node.id}": 테이블명이 필요합니다.`,
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "externalDBSource":
|
||||
if (!node.data.connectionName || node.data.connectionName.trim() === "") {
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: `외부 DB 소스 노드 "${node.data.displayName || node.id}": 연결 이름이 필요합니다.`,
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
if (!node.data.tableName || node.data.tableName.trim() === "") {
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: `외부 DB 소스 노드 "${node.data.displayName || node.id}": 테이블명이 필요합니다.`,
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "restAPISource":
|
||||
if (!node.data.url || node.data.url.trim() === "") {
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: `REST API 소스 노드 "${node.data.displayName || node.id}": URL이 필요합니다.`,
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
if (!node.data.method) {
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: `REST API 소스 노드 "${node.data.displayName || node.id}": HTTP 메서드가 필요합니다.`,
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "insertAction":
|
||||
case "updateAction":
|
||||
case "deleteAction":
|
||||
if (!node.data.targetTable || node.data.targetTable.trim() === "") {
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: `${getActionTypeName(node.type)} 노드 "${node.data.displayName || node.id}": 타겟 테이블이 필요합니다.`,
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
if (node.type === "insertAction" || node.type === "updateAction") {
|
||||
if (!node.data.fieldMappings || node.data.fieldMappings.length === 0) {
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: `${getActionTypeName(node.type)} 노드 "${node.data.displayName || node.id}": 최소 하나의 필드 매핑이 필요합니다.`,
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (node.type === "updateAction" || node.type === "deleteAction") {
|
||||
if (!node.data.whereConditions || node.data.whereConditions.length === 0) {
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: `${getActionTypeName(node.type)} 노드 "${node.data.displayName || node.id}": WHERE 조건이 필요합니다.`,
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "upsertAction":
|
||||
if (!node.data.targetTable || node.data.targetTable.trim() === "") {
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: `UPSERT 액션 노드 "${node.data.displayName || node.id}": 타겟 테이블이 필요합니다.`,
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
if (!node.data.conflictKeys || node.data.conflictKeys.length === 0) {
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: `UPSERT 액션 노드 "${node.data.displayName || node.id}": 충돌 키(ON CONFLICT)가 필요합니다.`,
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
if (!node.data.fieldMappings || node.data.fieldMappings.length === 0) {
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: `UPSERT 액션 노드 "${node.data.displayName || node.id}": 최소 하나의 필드 매핑이 필요합니다.`,
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "condition":
|
||||
if (!node.data.conditions || node.data.conditions.length === 0) {
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: `조건 노드 "${node.data.displayName || node.id}": 최소 하나의 조건이 필요합니다.`,
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "fieldMapping":
|
||||
if (!node.data.mappings || node.data.mappings.length === 0) {
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: `필드 매핑 노드 "${node.data.displayName || node.id}": 최소 하나의 매핑이 필요합니다.`,
|
||||
severity: "warning",
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "dataTransform":
|
||||
if (!node.data.transformations || node.data.transformations.length === 0) {
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: `데이터 변환 노드 "${node.data.displayName || node.id}": 최소 하나의 변환 규칙이 필요합니다.`,
|
||||
severity: "warning",
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "log":
|
||||
if (!node.data.message || node.data.message.trim() === "") {
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: `로그 노드 "${node.data.displayName || node.id}": 로그 메시지가 필요합니다.`,
|
||||
severity: "warning",
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "comment":
|
||||
// Comment 노드는 내용이 없어도 괜찮음
|
||||
break;
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* 액션 타입 이름 반환
|
||||
*/
|
||||
function getActionTypeName(type: string): string {
|
||||
const names: Record<string, string> = {
|
||||
insertAction: "INSERT",
|
||||
updateAction: "UPDATE",
|
||||
deleteAction: "DELETE",
|
||||
upsertAction: "UPSERT",
|
||||
};
|
||||
return names[type] || type;
|
||||
}
|
||||
|
|
@ -795,13 +795,74 @@ export class ButtonActionExecutor {
|
|||
});
|
||||
|
||||
// 🔥 새로운 버튼 액션 실행 시스템 사용
|
||||
if (config.dataflowConfig?.controlMode === "relationship" && config.dataflowConfig?.relationshipConfig) {
|
||||
if (config.dataflowConfig?.controlMode === "flow" && config.dataflowConfig?.flowConfig) {
|
||||
console.log("🎯 노드 플로우 실행:", config.dataflowConfig.flowConfig);
|
||||
|
||||
const { flowId, executionTiming } = config.dataflowConfig.flowConfig;
|
||||
|
||||
if (!flowId) {
|
||||
console.error("❌ 플로우 ID가 없습니다");
|
||||
toast.error("플로우가 설정되지 않았습니다.");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 노드 플로우 실행 API 호출 (API 클라이언트 사용)
|
||||
const { executeNodeFlow } = await import("@/lib/api/nodeFlows");
|
||||
|
||||
// 데이터 소스 준비: 선택된 행 또는 폼 데이터
|
||||
let sourceData: any = null;
|
||||
let dataSourceType: string = "none";
|
||||
|
||||
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
|
||||
// 테이블에서 선택된 행 데이터 사용
|
||||
sourceData = context.selectedRowsData;
|
||||
dataSourceType = "table-selection";
|
||||
console.log("📊 테이블 선택 데이터 사용:", sourceData);
|
||||
} else if (context.formData && Object.keys(context.formData).length > 0) {
|
||||
// 폼 데이터 사용 (배열로 감싸서 일관성 유지)
|
||||
sourceData = [context.formData];
|
||||
dataSourceType = "form";
|
||||
console.log("📝 폼 데이터 사용:", sourceData);
|
||||
}
|
||||
|
||||
const result = await executeNodeFlow(flowId, {
|
||||
dataSourceType,
|
||||
sourceData,
|
||||
context,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
console.log("✅ 노드 플로우 실행 완료:", result);
|
||||
toast.success(config.successMessage || "플로우 실행이 완료되었습니다.");
|
||||
|
||||
// 새로고침이 필요한 경우
|
||||
if (context.onRefresh) {
|
||||
context.onRefresh();
|
||||
}
|
||||
|
||||
return true;
|
||||
} else {
|
||||
console.error("❌ 노드 플로우 실행 실패:", result);
|
||||
toast.error(config.errorMessage || result.message || "플로우 실행 중 오류가 발생했습니다.");
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 노드 플로우 실행 오류:", error);
|
||||
toast.error("플로우 실행 중 오류가 발생했습니다.");
|
||||
return false;
|
||||
}
|
||||
} else if (config.dataflowConfig?.controlMode === "relationship" && config.dataflowConfig?.relationshipConfig) {
|
||||
console.log("🔗 관계 기반 제어 실행:", config.dataflowConfig.relationshipConfig);
|
||||
|
||||
// 🔥 table-selection 모드일 때 선택된 행 데이터를 formData에 병합
|
||||
let mergedFormData = { ...context.formData } || {};
|
||||
|
||||
if (controlDataSource === "table-selection" && context.selectedRowsData && context.selectedRowsData.length > 0) {
|
||||
|
||||
if (
|
||||
controlDataSource === "table-selection" &&
|
||||
context.selectedRowsData &&
|
||||
context.selectedRowsData.length > 0
|
||||
) {
|
||||
// 선택된 첫 번째 행의 데이터를 formData에 병합
|
||||
const selectedRowData = context.selectedRowsData[0];
|
||||
mergedFormData = { ...mergedFormData, ...selectedRowData };
|
||||
|
|
@ -819,28 +880,24 @@ export class ButtonActionExecutor {
|
|||
enableDataflowControl: true, // 관계 기반 제어가 설정된 경우 활성화
|
||||
};
|
||||
|
||||
const executionResult = await ImprovedButtonActionExecutor.executeButtonAction(
|
||||
buttonConfig,
|
||||
mergedFormData,
|
||||
{
|
||||
buttonId: context.buttonId || "unknown",
|
||||
screenId: context.screenId || "unknown",
|
||||
userId: context.userId || "unknown",
|
||||
companyCode: context.companyCode || "*",
|
||||
startTime: Date.now(),
|
||||
contextData: context,
|
||||
}
|
||||
);
|
||||
const executionResult = await ImprovedButtonActionExecutor.executeButtonAction(buttonConfig, mergedFormData, {
|
||||
buttonId: context.buttonId || "unknown",
|
||||
screenId: context.screenId || "unknown",
|
||||
userId: context.userId || "unknown",
|
||||
companyCode: context.companyCode || "*",
|
||||
startTime: Date.now(),
|
||||
contextData: context,
|
||||
});
|
||||
|
||||
if (executionResult.success) {
|
||||
console.log("✅ 관계 실행 완료:", executionResult);
|
||||
toast.success(config.successMessage || "관계 실행이 완료되었습니다.");
|
||||
|
||||
|
||||
// 새로고침이 필요한 경우
|
||||
if (context.onRefresh) {
|
||||
context.onRefresh();
|
||||
}
|
||||
|
||||
|
||||
return true;
|
||||
} else {
|
||||
console.error("❌ 관계 실행 실패:", executionResult);
|
||||
|
|
@ -848,15 +905,14 @@ export class ButtonActionExecutor {
|
|||
return false;
|
||||
}
|
||||
} else {
|
||||
// 제어 없음 - 메인 액션만 실행
|
||||
console.log("⚡ 제어 없음 - 메인 액션 실행");
|
||||
await this.executeMainAction(config, context);
|
||||
|
||||
// 제어 없음 - 성공 처리
|
||||
console.log("⚡ 제어 없음 - 버튼 액션만 실행");
|
||||
|
||||
// 새로고침이 필요한 경우
|
||||
if (context.onRefresh) {
|
||||
context.onRefresh();
|
||||
}
|
||||
|
||||
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -869,7 +925,10 @@ export class ButtonActionExecutor {
|
|||
/**
|
||||
* 저장 후 제어 실행 (After Timing)
|
||||
*/
|
||||
private static async executeAfterSaveControl(config: ButtonActionConfig, context: ButtonActionContext): Promise<void> {
|
||||
private static async executeAfterSaveControl(
|
||||
config: ButtonActionConfig,
|
||||
context: ButtonActionContext,
|
||||
): Promise<void> {
|
||||
console.log("🎯 저장 후 제어 실행:", {
|
||||
enableDataflowControl: config.enableDataflowControl,
|
||||
dataflowConfig: config.dataflowConfig,
|
||||
|
|
@ -915,7 +974,7 @@ export class ButtonActionExecutor {
|
|||
companyCode: context.companyCode || "*",
|
||||
startTime: Date.now(),
|
||||
contextData: context,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (executionResult.success) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,189 @@
|
|||
/**
|
||||
* 노드 플로우 기반 버튼 실행기
|
||||
*/
|
||||
|
||||
import { executeNodeFlow, ExecutionResult } from "../api/nodeFlows";
|
||||
import { logger } from "../utils/logger";
|
||||
import { toast } from "sonner";
|
||||
import type { ButtonDataflowConfig, ExtendedControlContext } from "@/types/control-management";
|
||||
|
||||
export interface ButtonExecutionContext {
|
||||
buttonId: string;
|
||||
screenId?: number;
|
||||
companyCode?: string;
|
||||
userId?: string;
|
||||
formData: Record<string, any>;
|
||||
selectedRows?: any[];
|
||||
selectedRowsData?: Record<string, any>[];
|
||||
controlDataSource?: "form" | "table-selection" | "both";
|
||||
onRefresh?: () => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export interface FlowExecutionResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
executionTime?: number;
|
||||
data?: ExecutionResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* 노드 플로우 실행 함수
|
||||
*/
|
||||
export async function executeButtonWithFlow(
|
||||
flowConfig: ButtonDataflowConfig["flowConfig"],
|
||||
context: ButtonExecutionContext,
|
||||
originalAction?: () => Promise<void>,
|
||||
): Promise<FlowExecutionResult> {
|
||||
if (!flowConfig) {
|
||||
throw new Error("플로우 설정이 없습니다.");
|
||||
}
|
||||
|
||||
const { flowId, flowName, executionTiming = "before" } = flowConfig;
|
||||
|
||||
logger.info(`🚀 노드 플로우 실행 시작:`, {
|
||||
flowId,
|
||||
flowName,
|
||||
timing: executionTiming,
|
||||
contextKeys: Object.keys(context),
|
||||
});
|
||||
|
||||
try {
|
||||
// 컨텍스트 데이터 준비
|
||||
const contextData = prepareContextData(context);
|
||||
|
||||
// 타이밍에 따라 실행
|
||||
switch (executionTiming) {
|
||||
case "before":
|
||||
// 1. 플로우 먼저 실행
|
||||
const beforeResult = await executeNodeFlow(flowId, contextData);
|
||||
|
||||
if (!beforeResult.success) {
|
||||
toast.error(`플로우 실행 실패: ${beforeResult.message}`);
|
||||
return {
|
||||
success: false,
|
||||
message: beforeResult.message,
|
||||
data: beforeResult,
|
||||
};
|
||||
}
|
||||
|
||||
toast.success(`플로우 실행 완료: ${flowName}`);
|
||||
|
||||
// 2. 원래 버튼 액션 실행
|
||||
if (originalAction) {
|
||||
await originalAction();
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "플로우 및 버튼 액션이 성공적으로 실행되었습니다.",
|
||||
executionTime: beforeResult.executionTime,
|
||||
data: beforeResult,
|
||||
};
|
||||
|
||||
case "after":
|
||||
// 1. 원래 버튼 액션 먼저 실행
|
||||
if (originalAction) {
|
||||
await originalAction();
|
||||
}
|
||||
|
||||
// 2. 플로우 실행
|
||||
const afterResult = await executeNodeFlow(flowId, contextData);
|
||||
|
||||
if (!afterResult.success) {
|
||||
toast.warning(`버튼 액션은 성공했으나 플로우 실행 실패: ${afterResult.message}`);
|
||||
} else {
|
||||
toast.success(`플로우 실행 완료: ${flowName}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: afterResult.success,
|
||||
message: afterResult.message,
|
||||
executionTime: afterResult.executionTime,
|
||||
data: afterResult,
|
||||
};
|
||||
|
||||
case "replace":
|
||||
// 플로우만 실행 (원래 액션 대체)
|
||||
const replaceResult = await executeNodeFlow(flowId, contextData);
|
||||
|
||||
if (!replaceResult.success) {
|
||||
toast.error(`플로우 실행 실패: ${replaceResult.message}`);
|
||||
} else {
|
||||
toast.success(`플로우 실행 완료: ${flowName}`, {
|
||||
description: `${replaceResult.summary.success}/${replaceResult.summary.total} 노드 성공`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: replaceResult.success,
|
||||
message: replaceResult.message,
|
||||
executionTime: replaceResult.executionTime,
|
||||
data: replaceResult,
|
||||
};
|
||||
|
||||
default:
|
||||
throw new Error(`지원하지 않는 실행 타이밍: ${executionTiming}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("플로우 실행 오류:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.";
|
||||
toast.error(`플로우 실행 오류: ${errorMessage}`);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: errorMessage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 컨텍스트 데이터 준비
|
||||
*/
|
||||
function prepareContextData(context: ButtonExecutionContext): Record<string, any> {
|
||||
return {
|
||||
buttonId: context.buttonId,
|
||||
screenId: context.screenId,
|
||||
companyCode: context.companyCode,
|
||||
userId: context.userId,
|
||||
formData: context.formData || {},
|
||||
selectedRowsData: context.selectedRowsData || [],
|
||||
controlDataSource: context.controlDataSource || "form",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 플로우 실행 결과 처리
|
||||
*/
|
||||
export function handleFlowExecutionResult(result: FlowExecutionResult, context: ButtonExecutionContext): void {
|
||||
if (result.success) {
|
||||
logger.info("✅ 플로우 실행 성공:", result);
|
||||
|
||||
// 성공 시 데이터 새로고침
|
||||
if (context.onRefresh) {
|
||||
context.onRefresh();
|
||||
}
|
||||
|
||||
// 실행 결과 요약 표시
|
||||
if (result.data) {
|
||||
const { summary } = result.data;
|
||||
console.log("📊 플로우 실행 요약:", {
|
||||
전체: summary.total,
|
||||
성공: summary.success,
|
||||
실패: summary.failed,
|
||||
스킵: summary.skipped,
|
||||
실행시간: `${result.executionTime}ms`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
logger.error("❌ 플로우 실행 실패:", result);
|
||||
|
||||
// 실패한 노드 정보 표시
|
||||
if (result.data) {
|
||||
const failedNodes = result.data.nodes.filter((n) => n.status === "failed");
|
||||
if (failedNodes.length > 0) {
|
||||
console.error("❌ 실패한 노드들:", failedNodes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -49,6 +49,7 @@
|
|||
"react-hook-form": "^7.62.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-window": "^2.1.0",
|
||||
"reactflow": "^11.10.4",
|
||||
"recharts": "^3.2.1",
|
||||
"sheetjs-style": "^0.15.8",
|
||||
"sonner": "^2.0.7",
|
||||
|
|
@ -2280,6 +2281,108 @@
|
|||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@reactflow/background": {
|
||||
"version": "11.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.9.tgz",
|
||||
"integrity": "sha512-byj/G9pEC8tN0wT/ptcl/LkEP/BBfa33/SvBkqE4XwyofckqF87lKp573qGlisfnsijwAbpDlf81PuFL41So4Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/core": "11.10.4",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/controls": {
|
||||
"version": "11.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.9.tgz",
|
||||
"integrity": "sha512-e8nWplbYfOn83KN1BrxTXS17+enLyFnjZPbyDgHSRLtI5ZGPKF/8iRXV+VXb2LFVzlu4Wh3la/pkxtfP/0aguA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/core": "11.10.4",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/core": {
|
||||
"version": "11.10.4",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.10.4.tgz",
|
||||
"integrity": "sha512-j3i9b2fsTX/sBbOm+RmNzYEFWbNx4jGWGuGooh2r1jQaE2eV+TLJgiG/VNOp0q5mBl9f6g1IXs3Gm86S9JfcGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3": "^7.4.0",
|
||||
"@types/d3-drag": "^3.0.1",
|
||||
"@types/d3-selection": "^3.0.3",
|
||||
"@types/d3-zoom": "^3.0.1",
|
||||
"classcat": "^5.0.3",
|
||||
"d3-drag": "^3.0.0",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/minimap": {
|
||||
"version": "11.7.9",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.9.tgz",
|
||||
"integrity": "sha512-le95jyTtt3TEtJ1qa7tZ5hyM4S7gaEQkW43cixcMOZLu33VAdc2aCpJg/fXcRrrf7moN2Mbl9WIMNXUKsp5ILA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/core": "11.10.4",
|
||||
"@types/d3-selection": "^3.0.3",
|
||||
"@types/d3-zoom": "^3.0.1",
|
||||
"classcat": "^5.0.3",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/node-resizer": {
|
||||
"version": "2.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.9.tgz",
|
||||
"integrity": "sha512-HfickMm0hPDIHt9qH997nLdgLt0kayQyslKE0RS/GZvZ4UMQJlx/NRRyj5y47Qyg0NnC66KYOQWDM9LLzRTnUg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/core": "11.10.4",
|
||||
"classcat": "^5.0.4",
|
||||
"d3-drag": "^3.0.0",
|
||||
"d3-selection": "^3.0.0",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/node-toolbar": {
|
||||
"version": "1.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.9.tgz",
|
||||
"integrity": "sha512-VmgxKmToax4sX1biZ9LXA7cj/TBJ+E5cklLGwquCCVVxh+lxpZGTBF3a5FJGVHiUNBBtFsC8ldcSZIK4cAlQww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/core": "11.10.4",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.0.tgz",
|
||||
|
|
@ -2716,18 +2819,102 @@
|
|||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3": {
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
|
||||
"integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "*",
|
||||
"@types/d3-axis": "*",
|
||||
"@types/d3-brush": "*",
|
||||
"@types/d3-chord": "*",
|
||||
"@types/d3-color": "*",
|
||||
"@types/d3-contour": "*",
|
||||
"@types/d3-delaunay": "*",
|
||||
"@types/d3-dispatch": "*",
|
||||
"@types/d3-drag": "*",
|
||||
"@types/d3-dsv": "*",
|
||||
"@types/d3-ease": "*",
|
||||
"@types/d3-fetch": "*",
|
||||
"@types/d3-force": "*",
|
||||
"@types/d3-format": "*",
|
||||
"@types/d3-geo": "*",
|
||||
"@types/d3-hierarchy": "*",
|
||||
"@types/d3-interpolate": "*",
|
||||
"@types/d3-path": "*",
|
||||
"@types/d3-polygon": "*",
|
||||
"@types/d3-quadtree": "*",
|
||||
"@types/d3-random": "*",
|
||||
"@types/d3-scale": "*",
|
||||
"@types/d3-scale-chromatic": "*",
|
||||
"@types/d3-selection": "*",
|
||||
"@types/d3-shape": "*",
|
||||
"@types/d3-time": "*",
|
||||
"@types/d3-time-format": "*",
|
||||
"@types/d3-timer": "*",
|
||||
"@types/d3-transition": "*",
|
||||
"@types/d3-zoom": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-axis": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
|
||||
"integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-brush": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
|
||||
"integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-chord": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
|
||||
"integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-contour": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
|
||||
"integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "*",
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-delaunay": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
|
||||
"integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-dispatch": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
|
||||
"integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-drag": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
||||
|
|
@ -2737,12 +2924,54 @@
|
|||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-dsv": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
|
||||
"integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-fetch": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
|
||||
"integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-dsv": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-force": {
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
|
||||
"integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-format": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
|
||||
"integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-geo": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
|
||||
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-hierarchy": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
|
||||
"integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
|
|
@ -2758,6 +2987,24 @@
|
|||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-polygon": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
|
||||
"integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-quadtree": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
|
||||
"integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-random": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz",
|
||||
"integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
|
|
@ -2767,6 +3014,12 @@
|
|||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-scale-chromatic": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
|
||||
"integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-selection": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
||||
|
|
@ -2788,6 +3041,12 @@
|
|||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-time-format": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
|
||||
"integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
|
|
@ -2820,6 +3079,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
|
|
@ -7943,6 +8208,24 @@
|
|||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reactflow": {
|
||||
"version": "11.10.4",
|
||||
"resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.10.4.tgz",
|
||||
"integrity": "sha512-0CApYhtYicXEDg/x2kvUHiUk26Qur8lAtTtiSlptNKuyEuGti6P1y5cS32YGaUoDMoCqkm/m+jcKkfMOvSCVRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/background": "11.3.9",
|
||||
"@reactflow/controls": "11.2.9",
|
||||
"@reactflow/core": "11.10.4",
|
||||
"@reactflow/minimap": "11.7.9",
|
||||
"@reactflow/node-resizer": "2.2.9",
|
||||
"@reactflow/node-toolbar": "1.3.9"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@
|
|||
"react-hook-form": "^7.62.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-window": "^2.1.0",
|
||||
"reactflow": "^11.10.4",
|
||||
"recharts": "^3.2.1",
|
||||
"sheetjs-style": "^0.15.8",
|
||||
"sonner": "^2.0.7",
|
||||
|
|
|
|||
|
|
@ -59,8 +59,8 @@ export interface ExtendedButtonTypeConfig {
|
|||
* 🔥 단순화된 버튼 데이터플로우 설정
|
||||
*/
|
||||
export interface ButtonDataflowConfig {
|
||||
// 제어 방식 선택 (관계 실행만)
|
||||
controlMode: "relationship" | "none";
|
||||
// 제어 방식 선택 (관계 실행 + 🆕 노드 플로우)
|
||||
controlMode: "relationship" | "flow" | "none";
|
||||
|
||||
// 관계 기반 제어
|
||||
relationshipConfig?: {
|
||||
|
|
@ -70,6 +70,14 @@ export interface ButtonDataflowConfig {
|
|||
contextData?: Record<string, any>; // 실행 시 전달할 컨텍스트
|
||||
};
|
||||
|
||||
// 🆕 노드 플로우 기반 제어
|
||||
flowConfig?: {
|
||||
flowId: number; // 노드 플로우 ID
|
||||
flowName: string; // 플로우명 표시
|
||||
executionTiming: "before" | "after" | "replace";
|
||||
contextData?: Record<string, any>; // 실행 시 전달할 컨텍스트
|
||||
};
|
||||
|
||||
// 제어 데이터 소스 (기존 호환성 유지)
|
||||
controlDataSource?: ControlDataSource;
|
||||
|
||||
|
|
@ -80,7 +88,7 @@ export interface ButtonDataflowConfig {
|
|||
selectedDiagramId?: number;
|
||||
selectedRelationshipId?: number;
|
||||
directControl?: DirectControlConfig;
|
||||
|
||||
|
||||
// 🔧 제거된 필드들 (하위 호환성을 위해 optional로 유지)
|
||||
externalCallConfig?: any; // deprecated
|
||||
customConfig?: any; // deprecated
|
||||
|
|
|
|||
|
|
@ -0,0 +1,555 @@
|
|||
/**
|
||||
* 노드 기반 데이터 제어 시스템 타입 정의
|
||||
*/
|
||||
|
||||
import { Node as ReactFlowNode, Edge as ReactFlowEdge } from "reactflow";
|
||||
|
||||
// ============================================================================
|
||||
// 기본 노드 타입
|
||||
// ============================================================================
|
||||
|
||||
export type NodeType =
|
||||
| "tableSource" // 테이블 소스
|
||||
| "externalDBSource" // 외부 DB 소스
|
||||
| "restAPISource" // REST API 소스
|
||||
| "referenceLookup" // 참조 테이블 조회 (내부 DB 전용)
|
||||
| "condition" // 조건 분기
|
||||
| "fieldMapping" // 필드 매핑
|
||||
| "dataTransform" // 데이터 변환
|
||||
| "insertAction" // INSERT 액션
|
||||
| "updateAction" // UPDATE 액션
|
||||
| "deleteAction" // DELETE 액션
|
||||
| "upsertAction" // UPSERT 액션
|
||||
| "comment" // 주석
|
||||
| "log"; // 로그
|
||||
|
||||
// ============================================================================
|
||||
// 타겟 타입 (액션 노드용)
|
||||
// ============================================================================
|
||||
|
||||
export type TargetType = "internal" | "external" | "api";
|
||||
|
||||
// API 인증 타입
|
||||
export type ApiAuthType = "none" | "basic" | "bearer" | "apikey";
|
||||
|
||||
// API 인증 설정
|
||||
export interface ApiAuthConfig {
|
||||
username?: string;
|
||||
password?: string;
|
||||
token?: string;
|
||||
apiKey?: string;
|
||||
apiKeyHeader?: string; // 기본값: "X-API-Key"
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 필드 정의
|
||||
// ============================================================================
|
||||
|
||||
export interface FieldDefinition {
|
||||
name: string;
|
||||
type: string;
|
||||
nullable: boolean;
|
||||
primaryKey?: boolean;
|
||||
label?: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 노드 데이터 타입별 정의
|
||||
// ============================================================================
|
||||
|
||||
// 테이블 소스 노드
|
||||
export interface TableSourceNodeData {
|
||||
connectionId: number;
|
||||
tableName: string;
|
||||
schema?: string;
|
||||
fields: FieldDefinition[];
|
||||
filters?: Array<{
|
||||
field: string;
|
||||
operator: string;
|
||||
value: any;
|
||||
}>;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
// 외부 DB 소스 노드
|
||||
export interface ExternalDBSourceNodeData {
|
||||
connectionId: number;
|
||||
connectionName: string;
|
||||
dbType: string;
|
||||
tableName: string;
|
||||
fields: FieldDefinition[];
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
// REST API 소스 노드
|
||||
export interface RestAPISourceNodeData {
|
||||
method: "GET" | "POST" | "PUT" | "DELETE";
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
responseFields: FieldDefinition[];
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
// 참조 테이블 조회 노드 (내부 DB 전용)
|
||||
export interface ReferenceLookupNodeData {
|
||||
type: "referenceLookup";
|
||||
referenceTable: string; // 참조할 테이블명
|
||||
referenceTableLabel?: string; // 테이블 라벨
|
||||
joinConditions: Array<{
|
||||
// 조인 조건 (FK 매핑)
|
||||
sourceField: string; // 소스 데이터의 필드
|
||||
sourceFieldLabel?: string;
|
||||
referenceField: string; // 참조 테이블의 필드
|
||||
referenceFieldLabel?: string;
|
||||
}>;
|
||||
whereConditions?: Array<{
|
||||
// 추가 WHERE 조건
|
||||
field: string;
|
||||
fieldLabel?: string;
|
||||
operator: string;
|
||||
value: any;
|
||||
valueType?: "static" | "field"; // 고정값 또는 소스 필드 참조
|
||||
}>;
|
||||
outputFields: Array<{
|
||||
// 가져올 필드들
|
||||
fieldName: string; // 참조 테이블의 컬럼명
|
||||
fieldLabel?: string;
|
||||
alias: string; // 결과 데이터에서 사용할 이름
|
||||
}>;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
// 조건 분기 노드
|
||||
export interface ConditionNodeData {
|
||||
conditions: Array<{
|
||||
field: string;
|
||||
operator:
|
||||
| "EQUALS"
|
||||
| "NOT_EQUALS"
|
||||
| "GREATER_THAN"
|
||||
| "LESS_THAN"
|
||||
| "GREATER_THAN_OR_EQUAL"
|
||||
| "LESS_THAN_OR_EQUAL"
|
||||
| "LIKE"
|
||||
| "NOT_LIKE"
|
||||
| "IN"
|
||||
| "NOT_IN"
|
||||
| "IS_NULL"
|
||||
| "IS_NOT_NULL";
|
||||
value: any;
|
||||
}>;
|
||||
logic: "AND" | "OR";
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
// 필드 매핑 노드
|
||||
export interface FieldMappingNodeData {
|
||||
mappings: Array<{
|
||||
id: string;
|
||||
sourceField: string | null;
|
||||
targetField: string;
|
||||
transform?: string;
|
||||
staticValue?: any;
|
||||
}>;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
// 데이터 변환 노드
|
||||
export interface DataTransformNodeData {
|
||||
transformations: Array<{
|
||||
type:
|
||||
| "UPPERCASE"
|
||||
| "LOWERCASE"
|
||||
| "TRIM"
|
||||
| "CONCAT"
|
||||
| "SPLIT"
|
||||
| "REPLACE"
|
||||
| "CALCULATE"
|
||||
| "EXPLODE"
|
||||
| "CAST"
|
||||
| "FORMAT"
|
||||
| "JSON_EXTRACT"
|
||||
| "CUSTOM";
|
||||
sourceField?: string; // 소스 필드
|
||||
sourceFieldLabel?: string; // 소스 필드 라벨
|
||||
targetField?: string; // 타겟 필드 (선택, 비어있으면 소스 필드에 적용)
|
||||
targetFieldLabel?: string; // 타겟 필드 라벨
|
||||
expression?: string; // 표현식
|
||||
parameters?: Record<string, any>; // 추가 파라미터
|
||||
// EXPLODE 전용
|
||||
delimiter?: string; // 구분자 (예: ",")
|
||||
// CONCAT 전용
|
||||
sourceFields?: string[]; // 다중 소스 필드
|
||||
sourceFieldLabels?: string[]; // 다중 소스 필드 라벨
|
||||
separator?: string; // 구분자 (예: " ")
|
||||
// SPLIT 전용
|
||||
splitIndex?: number; // 분리 후 가져올 인덱스
|
||||
// REPLACE 전용
|
||||
searchValue?: string; // 찾을 문자열
|
||||
replaceValue?: string; // 바꿀 문자열
|
||||
// CAST 전용
|
||||
castType?: "string" | "number" | "boolean" | "date"; // 변환할 타입
|
||||
}>;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
// INSERT 액션 노드
|
||||
export interface InsertActionNodeData {
|
||||
displayName?: string;
|
||||
|
||||
// 🔥 타겟 타입 (새로 추가)
|
||||
targetType: TargetType; // "internal" | "external" | "api"
|
||||
|
||||
// === 내부 DB 타겟 (targetType === "internal") ===
|
||||
targetConnection?: number;
|
||||
targetTable?: string;
|
||||
targetTableLabel?: string;
|
||||
|
||||
// === 외부 DB 타겟 (targetType === "external") ===
|
||||
externalConnectionId?: number;
|
||||
externalConnectionName?: string;
|
||||
externalDbType?: string;
|
||||
externalTargetTable?: string;
|
||||
externalTargetSchema?: string;
|
||||
|
||||
// === REST API 타겟 (targetType === "api") ===
|
||||
apiEndpoint?: string;
|
||||
apiMethod?: "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
apiAuthType?: ApiAuthType;
|
||||
apiAuthConfig?: ApiAuthConfig;
|
||||
apiHeaders?: Record<string, string>;
|
||||
apiBodyTemplate?: string; // JSON 템플릿
|
||||
|
||||
// === 공통 필드 ===
|
||||
fieldMappings: Array<{
|
||||
sourceField: string | null;
|
||||
sourceFieldLabel?: string;
|
||||
targetField: string;
|
||||
targetFieldLabel?: string;
|
||||
staticValue?: any;
|
||||
}>;
|
||||
options: {
|
||||
batchSize?: number;
|
||||
ignoreErrors?: boolean;
|
||||
ignoreDuplicates?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// UPDATE 액션 노드
|
||||
export interface UpdateActionNodeData {
|
||||
displayName?: string;
|
||||
|
||||
// 🔥 타겟 타입 (새로 추가)
|
||||
targetType: TargetType; // "internal" | "external" | "api"
|
||||
|
||||
// === 내부 DB 타겟 (targetType === "internal") ===
|
||||
targetConnection?: number;
|
||||
targetTable?: string;
|
||||
targetTableLabel?: string;
|
||||
|
||||
// === 외부 DB 타겟 (targetType === "external") ===
|
||||
externalConnectionId?: number;
|
||||
externalConnectionName?: string;
|
||||
externalDbType?: string;
|
||||
externalTargetTable?: string;
|
||||
externalTargetSchema?: string;
|
||||
|
||||
// === REST API 타겟 (targetType === "api") ===
|
||||
apiEndpoint?: string;
|
||||
apiMethod?: "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
apiAuthType?: ApiAuthType;
|
||||
apiAuthConfig?: ApiAuthConfig;
|
||||
apiHeaders?: Record<string, string>;
|
||||
apiBodyTemplate?: string; // JSON 템플릿
|
||||
|
||||
// === 공통 필드 ===
|
||||
fieldMappings: Array<{
|
||||
sourceField: string | null;
|
||||
sourceFieldLabel?: string;
|
||||
targetField: string;
|
||||
targetFieldLabel?: string;
|
||||
staticValue?: any;
|
||||
}>;
|
||||
whereConditions: Array<{
|
||||
field: string;
|
||||
fieldLabel?: string;
|
||||
operator: string;
|
||||
sourceField?: string;
|
||||
sourceFieldLabel?: string;
|
||||
staticValue?: any;
|
||||
}>;
|
||||
options: {
|
||||
batchSize?: number;
|
||||
ignoreErrors?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// DELETE 액션 노드
|
||||
export interface DeleteActionNodeData {
|
||||
displayName?: string;
|
||||
|
||||
// 🔥 타겟 타입 (새로 추가)
|
||||
targetType: TargetType; // "internal" | "external" | "api"
|
||||
|
||||
// === 내부 DB 타겟 (targetType === "internal") ===
|
||||
targetConnection?: number;
|
||||
targetTable?: string;
|
||||
targetTableLabel?: string;
|
||||
|
||||
// === 외부 DB 타겟 (targetType === "external") ===
|
||||
externalConnectionId?: number;
|
||||
externalConnectionName?: string;
|
||||
externalDbType?: string;
|
||||
externalTargetTable?: string;
|
||||
externalTargetSchema?: string;
|
||||
|
||||
// === REST API 타겟 (targetType === "api") ===
|
||||
apiEndpoint?: string;
|
||||
apiMethod?: "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
apiAuthType?: ApiAuthType;
|
||||
apiAuthConfig?: ApiAuthConfig;
|
||||
apiHeaders?: Record<string, string>;
|
||||
apiBodyTemplate?: string;
|
||||
|
||||
// === 공통 필드 ===
|
||||
whereConditions: Array<{
|
||||
field: string;
|
||||
operator: string;
|
||||
sourceField?: string;
|
||||
staticValue?: any;
|
||||
}>;
|
||||
options: {
|
||||
requireConfirmation?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// UPSERT 액션 노드
|
||||
export interface UpsertActionNodeData {
|
||||
displayName?: string;
|
||||
|
||||
// 🔥 타겟 타입 (새로 추가)
|
||||
targetType: TargetType; // "internal" | "external" | "api"
|
||||
|
||||
// === 내부 DB 타겟 (targetType === "internal") ===
|
||||
targetConnection?: number;
|
||||
targetTable?: string;
|
||||
targetTableLabel?: string;
|
||||
|
||||
// === 외부 DB 타겟 (targetType === "external") ===
|
||||
externalConnectionId?: number;
|
||||
externalConnectionName?: string;
|
||||
externalDbType?: string;
|
||||
externalTargetTable?: string;
|
||||
externalTargetSchema?: string;
|
||||
|
||||
// === REST API 타겟 (targetType === "api") ===
|
||||
apiEndpoint?: string;
|
||||
apiMethod?: "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
apiAuthType?: ApiAuthType;
|
||||
apiAuthConfig?: ApiAuthConfig;
|
||||
apiHeaders?: Record<string, string>;
|
||||
apiBodyTemplate?: string;
|
||||
|
||||
// === 공통 필드 ===
|
||||
conflictKeys: string[]; // ON CONFLICT 키
|
||||
conflictKeyLabels?: string[]; // 충돌 키 라벨
|
||||
fieldMappings: Array<{
|
||||
sourceField: string | null;
|
||||
sourceFieldLabel?: string;
|
||||
targetField: string;
|
||||
targetFieldLabel?: string;
|
||||
staticValue?: any;
|
||||
}>;
|
||||
options?: {
|
||||
batchSize?: number;
|
||||
updateOnConflict?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// REST API 소스 노드
|
||||
export interface RestAPISourceNodeData {
|
||||
url: string;
|
||||
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
||||
headers?: Record<string, string>;
|
||||
body?: any;
|
||||
authentication?: {
|
||||
type: "none" | "bearer" | "basic" | "apikey";
|
||||
token?: string;
|
||||
};
|
||||
timeout?: number;
|
||||
responseMapping?: string; // JSON 경로 (예: "data.items")
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
// 주석 노드
|
||||
export interface CommentNodeData {
|
||||
content: string;
|
||||
}
|
||||
|
||||
// 로그 노드
|
||||
export interface LogNodeData {
|
||||
level: "debug" | "info" | "warn" | "error";
|
||||
message: string;
|
||||
includeData?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 통합 노드 데이터 타입
|
||||
// ============================================================================
|
||||
|
||||
export type NodeData =
|
||||
| TableSourceNodeData
|
||||
| ExternalDBSourceNodeData
|
||||
| RestAPISourceNodeData
|
||||
| ReferenceLookupNodeData
|
||||
| ConditionNodeData
|
||||
| FieldMappingNodeData
|
||||
| DataTransformNodeData
|
||||
| InsertActionNodeData
|
||||
| UpdateActionNodeData
|
||||
| DeleteActionNodeData
|
||||
| UpsertActionNodeData
|
||||
| CommentNodeData
|
||||
| LogNodeData;
|
||||
|
||||
// ============================================================================
|
||||
// React Flow 노드 확장
|
||||
// ============================================================================
|
||||
|
||||
export interface FlowNode extends ReactFlowNode {
|
||||
type: NodeType;
|
||||
data: NodeData & {
|
||||
// 공통 메타데이터
|
||||
validation?: {
|
||||
valid: boolean;
|
||||
errors?: string[];
|
||||
warnings?: string[];
|
||||
};
|
||||
// 실행 상태 (디버그 모드용)
|
||||
executionState?: {
|
||||
status: "pending" | "running" | "success" | "error" | "idle";
|
||||
progress?: number;
|
||||
processedCount?: number;
|
||||
errorMessage?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 엣지 타입
|
||||
// ============================================================================
|
||||
|
||||
export type EdgeType =
|
||||
| "dataFlow" // 일반 데이터 흐름
|
||||
| "fieldConnection" // 필드 연결
|
||||
| "conditionalTrue" // 조건 TRUE
|
||||
| "conditionalFalse"; // 조건 FALSE
|
||||
|
||||
export interface FlowEdge extends ReactFlowEdge {
|
||||
type?: EdgeType;
|
||||
data?: {
|
||||
dataType?: string;
|
||||
validation?: {
|
||||
valid: boolean;
|
||||
errors?: string[];
|
||||
};
|
||||
// 조건 분기용
|
||||
condition?: "TRUE" | "FALSE";
|
||||
// 필드 연결용
|
||||
sourceFieldName?: string;
|
||||
targetFieldName?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 전체 플로우 데이터
|
||||
// ============================================================================
|
||||
|
||||
export interface DataFlow {
|
||||
id?: number;
|
||||
name: string;
|
||||
description: string;
|
||||
companyCode: string;
|
||||
nodes: FlowNode[];
|
||||
edges: FlowEdge[];
|
||||
viewport?: {
|
||||
x: number;
|
||||
y: number;
|
||||
zoom: number;
|
||||
};
|
||||
metadata?: {
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
createdBy?: string;
|
||||
version?: number;
|
||||
tags?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 검증 결과
|
||||
// ============================================================================
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors: Array<{
|
||||
nodeId?: string;
|
||||
edgeId?: string;
|
||||
message: string;
|
||||
severity: "error" | "warning" | "info";
|
||||
}>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 실행 결과
|
||||
// ============================================================================
|
||||
|
||||
export interface ExecutionResult {
|
||||
success: boolean;
|
||||
processedCount: number;
|
||||
errorCount: number;
|
||||
duration: number;
|
||||
nodeResults: Array<{
|
||||
nodeId: string;
|
||||
status: "success" | "error" | "skipped";
|
||||
processedCount?: number;
|
||||
errorMessage?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 노드 팔레트 아이템
|
||||
// ============================================================================
|
||||
|
||||
export interface NodePaletteItem {
|
||||
type: NodeType;
|
||||
label: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
category: "source" | "transform" | "action" | "utility";
|
||||
color: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 템플릿
|
||||
// ============================================================================
|
||||
|
||||
export interface FlowTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: "builtin" | "custom";
|
||||
thumbnail?: string;
|
||||
nodes: FlowNode[];
|
||||
edges: FlowEdge[];
|
||||
parameters?: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
type: "string" | "number" | "select";
|
||||
defaultValue?: any;
|
||||
options?: Array<{ label: string; value: any }>;
|
||||
}>;
|
||||
tags?: string[];
|
||||
}
|
||||
|
|
@ -531,6 +531,7 @@ export interface DataTableComponent extends BaseComponent {
|
|||
editButtonText: string; // 수정 버튼 텍스트
|
||||
deleteButtonText: string; // 삭제 버튼 텍스트
|
||||
addModalConfig: DataTableAddModalConfig; // 추가 모달 커스터마이징 설정
|
||||
editModalConfig?: { title?: string; description?: string }; // 수정 모달 설정
|
||||
gridColumns: number; // 테이블이 차지할 그리드 컬럼 수
|
||||
}
|
||||
|
||||
|
|
@ -881,6 +882,10 @@ export interface ButtonTypeConfig {
|
|||
navigateScreenId?: number; // 이동할 화면 ID
|
||||
navigateTarget?: "_self" | "_blank";
|
||||
|
||||
// 수정 모달 설정
|
||||
editModalTitle?: string; // 수정 모달 제목
|
||||
editModalDescription?: string; // 수정 모달 설명
|
||||
|
||||
// 커스텀 액션 설정
|
||||
customAction?: string; // JavaScript 코드 또는 함수명
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
"dependencies": {
|
||||
"@prisma/client": "^6.16.2",
|
||||
"@types/mssql": "^9.1.8",
|
||||
"@xyflow/react": "^12.8.6",
|
||||
"axios": "^1.12.2",
|
||||
"mssql": "^11.0.1",
|
||||
"prisma": "^6.16.2"
|
||||
|
|
@ -371,6 +372,55 @@
|
|||
"integrity": "sha512-7qSgZbincDDDFyRweCIEvZULFAw5iz/DeunhvuxpL31nfntX3P4Yd4HkHBRg9H8CdqY1e5WFN1PZIz/REL9MVQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-drag": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
||||
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-selection": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
||||
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-transition": {
|
||||
"version": "3.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
||||
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-zoom": {
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
|
||||
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-interpolate": "*",
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mssql": {
|
||||
"version": "9.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/mssql/-/mssql-9.1.8.tgz",
|
||||
|
|
@ -436,6 +486,38 @@
|
|||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/react": {
|
||||
"version": "12.8.6",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.8.6.tgz",
|
||||
"integrity": "sha512-SksAm2m4ySupjChphMmzvm55djtgMDPr+eovPDdTnyGvShf73cvydfoBfWDFllooIQ4IaiUL5yfxHRwU0c37EA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@xyflow/system": "0.0.70",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/system": {
|
||||
"version": "0.0.70",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.70.tgz",
|
||||
"integrity": "sha512-PpC//u9zxdjj0tfTSmZrg3+sRbTz6kop/Amky44U2Dl51sxzDTIUfXMwETOYpmr2dqICWXBIJwXL2a9QWtX2XA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-drag": "^3.0.7",
|
||||
"@types/d3-interpolate": "^3.0.4",
|
||||
"@types/d3-selection": "^3.0.10",
|
||||
"@types/d3-transition": "^3.0.8",
|
||||
"@types/d3-zoom": "^3.0.8",
|
||||
"d3-drag": "^3.0.0",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/abort-controller": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
|
|
@ -616,6 +698,12 @@
|
|||
"consola": "^3.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/classcat": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
|
||||
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
|
|
@ -652,6 +740,111 @@
|
|||
"node": "^14.18.0 || >=16.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dispatch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-drag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-selection": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-selection": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-transition": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-ease": "1 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"d3-selection": "2 - 3"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-zoom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-selection": "2 - 3",
|
||||
"d3-transition": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
|
@ -1523,6 +1716,29 @@
|
|||
"destr": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
|
||||
|
|
@ -1596,6 +1812,13 @@
|
|||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
||||
|
|
@ -1671,6 +1894,15 @@
|
|||
"integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
|
|
@ -1704,6 +1936,34 @@
|
|||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "4.5.7",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=16.8",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=16.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
"dependencies": {
|
||||
"@prisma/client": "^6.16.2",
|
||||
"@types/mssql": "^9.1.8",
|
||||
"@xyflow/react": "^12.8.6",
|
||||
"axios": "^1.12.2",
|
||||
"mssql": "^11.0.1",
|
||||
"prisma": "^6.16.2"
|
||||
|
|
|
|||
Loading…
Reference in New Issue