restapi 버튼 동작

This commit is contained in:
kjs 2025-09-29 12:17:10 +09:00
parent cedb5e3ec3
commit c9afdec09f
19 changed files with 1910 additions and 81 deletions

View File

@ -38,6 +38,7 @@ import ddlRoutes from "./routes/ddlRoutes";
import entityReferenceRoutes from "./routes/entityReferenceRoutes"; import entityReferenceRoutes from "./routes/entityReferenceRoutes";
import externalCallRoutes from "./routes/externalCallRoutes"; import externalCallRoutes from "./routes/externalCallRoutes";
import externalCallConfigRoutes from "./routes/externalCallConfigRoutes"; import externalCallConfigRoutes from "./routes/externalCallConfigRoutes";
import dataflowExecutionRoutes from "./routes/dataflowExecutionRoutes";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석
// import userRoutes from './routes/userRoutes'; // import userRoutes from './routes/userRoutes';
@ -148,6 +149,7 @@ app.use("/api/ddl", ddlRoutes);
app.use("/api/entity-reference", entityReferenceRoutes); app.use("/api/entity-reference", entityReferenceRoutes);
app.use("/api/external-calls", externalCallRoutes); app.use("/api/external-calls", externalCallRoutes);
app.use("/api/external-call-configs", externalCallConfigRoutes); app.use("/api/external-call-configs", externalCallConfigRoutes);
app.use("/api/dataflow", dataflowExecutionRoutes);
// app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/collections", collectionRoutes); // 임시 주석
// app.use("/api/batch", batchRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석
// app.use('/api/users', userRoutes); // app.use('/api/users', userRoutes);

View File

@ -727,3 +727,35 @@ function processDataflowInBackground(
} }
}, 1000); // 1초 후 실행 시뮬레이션 }, 1000); // 1초 후 실행 시뮬레이션
} }
/**
* 🔥 ( )
*/
export async function getAllRelationships(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user?.companyCode || "*";
logger.info(`전체 관계 목록 조회 요청 - companyCode: ${companyCode}`);
// 모든 관계도에서 관계 목록을 가져옴
const allRelationships = await dataflowDiagramService.getAllRelationshipsForButtonControl(companyCode);
logger.info(`전체 관계 ${allRelationships.length}개 조회 완료`);
res.json({
success: true,
data: allRelationships,
message: `전체 관계 ${allRelationships.length}개 조회 완료`,
});
} catch (error) {
logger.error("전체 관계 목록 조회 실패:", error);
res.status(500).json({
success: false,
message: error instanceof Error ? error.message : "전체 관계 목록 조회 실패",
errorCode: "GET_ALL_RELATIONSHIPS_ERROR",
});
}
}

View File

@ -0,0 +1,235 @@
/**
* 🔥
*
*
*/
import { Request, Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { PrismaClient } from "@prisma/client";
import logger from "../utils/logger";
const prisma = new PrismaClient();
/**
*
*/
export async function executeDataAction(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, data, actionType, connection } = req.body;
const companyCode = req.user?.companyCode || "*";
logger.info(`데이터 액션 실행 시작: ${actionType} on ${tableName}`, {
tableName,
actionType,
dataKeys: Object.keys(data),
connection: connection?.name,
});
// 연결 정보에 따라 다른 데이터베이스에 저장
let result;
if (connection && connection.id !== 0) {
// 외부 데이터베이스 연결
result = await executeExternalDatabaseAction(tableName, data, actionType, connection);
} else {
// 메인 데이터베이스 (현재 시스템)
result = await executeMainDatabaseAction(tableName, data, actionType, companyCode);
}
logger.info(`데이터 액션 실행 완료: ${actionType} on ${tableName}`, result);
res.json({
success: true,
message: `데이터 액션 실행 완료: ${actionType}`,
data: result,
});
} catch (error: any) {
logger.error("데이터 액션 실행 실패:", error);
res.status(500).json({
success: false,
message: `데이터 액션 실행 실패: ${error.message}`,
errorCode: "DATA_ACTION_EXECUTION_ERROR",
});
}
}
/**
*
*/
async function executeMainDatabaseAction(
tableName: string,
data: Record<string, any>,
actionType: string,
companyCode: string
): Promise<any> {
try {
// 회사 코드 추가
const dataWithCompany = {
...data,
company_code: companyCode,
};
switch (actionType.toLowerCase()) {
case 'insert':
return await executeInsert(tableName, dataWithCompany);
case 'update':
return await executeUpdate(tableName, dataWithCompany);
case 'upsert':
return await executeUpsert(tableName, dataWithCompany);
case 'delete':
return await executeDelete(tableName, dataWithCompany);
default:
throw new Error(`지원하지 않는 액션 타입: ${actionType}`);
}
} catch (error) {
logger.error(`메인 DB 액션 실행 오류 (${actionType}):`, error);
throw error;
}
}
/**
*
*/
async function executeExternalDatabaseAction(
tableName: string,
data: Record<string, any>,
actionType: string,
connection: any
): Promise<any> {
try {
// TODO: 외부 데이터베이스 연결 및 실행 로직 구현
// 현재는 로그만 출력하고 성공으로 처리
logger.info(`외부 DB 액션 실행: ${connection.name} (${connection.host}:${connection.port})`);
logger.info(`테이블: ${tableName}, 액션: ${actionType}`, data);
// 임시 성공 응답
return {
success: true,
message: `외부 DB 액션 실행 완료: ${actionType} on ${tableName}`,
connection: connection.name,
affectedRows: 1,
};
} catch (error) {
logger.error(`외부 DB 액션 실행 오류 (${actionType}):`, error);
throw error;
}
}
/**
* INSERT
*/
async function executeInsert(tableName: string, data: Record<string, any>): Promise<any> {
try {
// 동적 테이블 접근을 위한 raw query 사용
const columns = Object.keys(data).join(', ');
const values = Object.values(data);
const placeholders = values.map((_, index) => `$${index + 1}`).join(', ');
const query = `INSERT INTO ${tableName} (${columns}) VALUES (${placeholders}) RETURNING *`;
logger.info(`INSERT 쿼리 실행:`, { query, values });
const result = await prisma.$queryRawUnsafe(query, ...values);
return {
success: true,
action: 'insert',
tableName,
data: result,
affectedRows: Array.isArray(result) ? result.length : 1,
};
} catch (error) {
logger.error(`INSERT 실행 오류:`, error);
throw error;
}
}
/**
* UPDATE
*/
async function executeUpdate(tableName: string, data: Record<string, any>): Promise<any> {
try {
// ID 또는 기본키를 기준으로 업데이트
const { id, ...updateData } = data;
if (!id) {
throw new Error('UPDATE를 위한 ID가 필요합니다');
}
const setClause = Object.keys(updateData)
.map((key, index) => `${key} = $${index + 1}`)
.join(', ');
const values = Object.values(updateData);
const query = `UPDATE ${tableName} SET ${setClause} WHERE id = $${values.length + 1} RETURNING *`;
logger.info(`UPDATE 쿼리 실행:`, { query, values: [...values, id] });
const result = await prisma.$queryRawUnsafe(query, ...values, id);
return {
success: true,
action: 'update',
tableName,
data: result,
affectedRows: Array.isArray(result) ? result.length : 1,
};
} catch (error) {
logger.error(`UPDATE 실행 오류:`, error);
throw error;
}
}
/**
* UPSERT
*/
async function executeUpsert(tableName: string, data: Record<string, any>): Promise<any> {
try {
// 먼저 INSERT를 시도하고, 실패하면 UPDATE
try {
return await executeInsert(tableName, data);
} catch (insertError) {
// INSERT 실패 시 UPDATE 시도
logger.info(`INSERT 실패, UPDATE 시도:`, insertError);
return await executeUpdate(tableName, data);
}
} catch (error) {
logger.error(`UPSERT 실행 오류:`, error);
throw error;
}
}
/**
* DELETE
*/
async function executeDelete(tableName: string, data: Record<string, any>): Promise<any> {
try {
const { id } = data;
if (!id) {
throw new Error('DELETE를 위한 ID가 필요합니다');
}
const query = `DELETE FROM ${tableName} WHERE id = $1 RETURNING *`;
logger.info(`DELETE 쿼리 실행:`, { query, values: [id] });
const result = await prisma.$queryRawUnsafe(query, id);
return {
success: true,
action: 'delete',
tableName,
data: result,
affectedRows: Array.isArray(result) ? result.length : 1,
};
} catch (error) {
logger.error(`DELETE 실행 오류:`, error);
throw error;
}
}

View File

@ -0,0 +1,19 @@
/**
* 🔥
*
* API
*/
import express from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import { executeDataAction } from "../controllers/dataflowExecutionController";
const router = express.Router();
// 🔥 모든 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
// 데이터 액션 실행
router.post("/execute-data-action", executeDataAction);
export default router;

View File

@ -249,4 +249,80 @@ router.post("/:id/test", async (req: Request, res: Response) => {
} }
}); });
/**
* 🔥 ( )
* POST /api/external-call-configs/:id/execute
*/
router.post("/:id/execute", async (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id);
if (isNaN(id)) {
return res.status(400).json({
success: false,
message: "유효하지 않은 설정 ID입니다.",
errorCode: "INVALID_CONFIG_ID",
});
}
const { requestData, contextData } = req.body;
// 사용자 정보 가져오기
const userInfo = (req as any).user;
const userId = userInfo?.userId || "SYSTEM";
const companyCode = userInfo?.companyCode || "*";
const executionResult = await externalCallConfigService.executeConfigWithDataMapping(
id,
requestData || {},
{
...contextData,
userId,
companyCode,
executedAt: new Date().toISOString(),
}
);
return res.json({
success: executionResult.success,
message: executionResult.message,
data: executionResult.data,
executionTime: executionResult.executionTime,
error: executionResult.error,
});
} catch (error) {
logger.error("외부호출 실행 API 오류:", error);
return res.status(500).json({
success: false,
message: error instanceof Error ? error.message : "외부호출 실행 실패",
errorCode: "EXTERNAL_CALL_EXECUTE_ERROR",
});
}
});
/**
* 🔥 ( )
* GET /api/external-call-configs/for-button-control
*/
router.get("/for-button-control", async (req: Request, res: Response) => {
try {
const userInfo = (req as any).user;
const companyCode = userInfo?.companyCode || "*";
const configs = await externalCallConfigService.getConfigsForButtonControl(companyCode);
return res.json({
success: true,
data: configs,
message: `버튼 제어용 외부호출 설정 ${configs.length}개 조회 완료`,
});
} catch (error) {
logger.error("버튼 제어용 외부호출 설정 조회 API 오류:", error);
return res.status(500).json({
success: false,
message: error instanceof Error ? error.message : "외부호출 설정 조회 실패",
errorCode: "EXTERNAL_CALL_BUTTON_CONTROL_LIST_ERROR",
});
}
});
export default router; export default router;

View File

@ -14,6 +14,7 @@ import {
executeOptimizedButton, executeOptimizedButton,
executeSimpleDataflow, executeSimpleDataflow,
getJobStatus, getJobStatus,
getAllRelationships,
} from "../controllers/buttonDataflowController"; } from "../controllers/buttonDataflowController";
import { AuthenticatedRequest } from "../types/auth"; import { AuthenticatedRequest } from "../types/auth";
import config from "../config/environment"; import config from "../config/environment";
@ -52,6 +53,9 @@ if (config.nodeEnv !== "production") {
// 특정 관계도의 관계 목록 조회 // 특정 관계도의 관계 목록 조회
router.get("/diagrams/:diagramId/relationships", getDiagramRelationships); router.get("/diagrams/:diagramId/relationships", getDiagramRelationships);
// 🔥 전체 관계 목록 조회 (버튼 제어용)
router.get("/relationships/all", getAllRelationships);
// 관계 미리보기 정보 조회 // 관계 미리보기 정보 조회
router.get( router.get(
"/diagrams/:diagramId/relationships/:relationshipId/preview", "/diagrams/:diagramId/relationships/:relationshipId/preview",

View File

@ -384,3 +384,66 @@ export const copyDataflowDiagram = async (
throw error; throw error;
} }
}; };
/**
* 🔥 ( )
* dataflow_diagrams ( )
*/
export const getAllRelationshipsForButtonControl = async (
companyCode: string
): Promise<Array<{
id: string;
name: string;
sourceTable: string;
targetTable: string;
category: string;
}>> => {
try {
logger.info(`전체 관계 목록 조회 시작 - companyCode: ${companyCode}`);
// dataflow_diagrams 테이블에서 관계도들을 조회
const diagrams = await prisma.dataflow_diagrams.findMany({
where: {
company_code: companyCode,
},
select: {
diagram_id: true,
diagram_name: true,
relationships: true,
},
orderBy: {
updated_at: "desc",
},
});
const allRelationships = diagrams.map((diagram) => {
// relationships 구조에서 테이블 정보 추출
const relationships = diagram.relationships as any || {};
// 테이블 정보 추출
let sourceTable = "";
let targetTable = "";
if (relationships.fromTable?.tableName) {
sourceTable = relationships.fromTable.tableName;
}
if (relationships.toTable?.tableName) {
targetTable = relationships.toTable.tableName;
}
return {
id: diagram.diagram_id.toString(),
name: diagram.diagram_name || `관계 ${diagram.diagram_id}`,
sourceTable: sourceTable,
targetTable: targetTable,
category: "데이터 흐름",
};
});
logger.info(`전체 관계 ${allRelationships.length}개 조회 완료`);
return allRelationships;
} catch (error) {
logger.error("전체 관계 목록 조회 서비스 오류:", error);
throw error;
}
};

View File

@ -308,6 +308,265 @@ export class ExternalCallConfigService {
}; };
} }
} }
/**
* 🔥
*/
async executeConfigWithDataMapping(
configId: number,
requestData: Record<string, any>,
contextData: Record<string, any>
): Promise<{
success: boolean;
message: string;
data?: any;
executionTime: number;
error?: string;
}> {
const startTime = performance.now();
try {
logger.info(`=== 외부호출 실행 시작 (ID: ${configId}) ===`);
// 1. 설정 조회
const config = await this.getConfigById(configId);
if (!config) {
throw new Error(`외부호출 설정을 찾을 수 없습니다: ${configId}`);
}
// 2. 데이터 매핑 처리 (있는 경우)
let processedData = requestData;
const configData = config.config_data as any;
if (configData?.dataMappingConfig?.outboundMapping) {
logger.info("Outbound 데이터 매핑 처리 중...");
processedData = await this.processOutboundMapping(
configData.dataMappingConfig.outboundMapping,
requestData
);
}
// 3. 외부 API 호출
const callResult = await this.executeExternalCall(config, processedData, contextData);
// 4. Inbound 데이터 매핑 처리 (있는 경우)
if (
callResult.success &&
configData?.dataMappingConfig?.inboundMapping
) {
logger.info("Inbound 데이터 매핑 처리 중...");
await this.processInboundMapping(
configData.dataMappingConfig.inboundMapping,
callResult.data
);
}
const executionTime = performance.now() - startTime;
logger.info(`외부호출 실행 완료: ${executionTime.toFixed(2)}ms`);
return {
success: callResult.success,
message: callResult.success
? `외부호출 '${config.config_name}' 실행 완료`
: `외부호출 '${config.config_name}' 실행 실패`,
data: callResult.data,
executionTime,
error: callResult.error,
};
} catch (error) {
const executionTime = performance.now() - startTime;
logger.error("외부호출 실행 실패:", error);
const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류";
return {
success: false,
message: `외부호출 실행 실패: ${errorMessage}`,
executionTime,
error: errorMessage,
};
}
}
/**
* 🔥 ( )
*/
async getConfigsForButtonControl(companyCode: string): Promise<Array<{
id: string;
name: string;
description?: string;
apiUrl: string;
method: string;
hasDataMapping: boolean;
}>> {
try {
const configs = await prisma.external_call_configs.findMany({
where: {
company_code: companyCode,
is_active: "Y",
},
select: {
id: true,
config_name: true,
description: true,
config_data: true,
},
orderBy: {
config_name: "asc",
},
});
return configs.map((config) => {
const configData = config.config_data as any;
return {
id: config.id.toString(),
name: config.config_name,
description: config.description || undefined,
apiUrl: configData?.restApiSettings?.apiUrl || "",
method: configData?.restApiSettings?.httpMethod || "GET",
hasDataMapping: !!(configData?.dataMappingConfig),
};
});
} catch (error) {
logger.error("버튼 제어용 외부호출 설정 조회 실패:", error);
throw error;
}
}
/**
* 🔥 API
*/
private async executeExternalCall(
config: ExternalCallConfig,
requestData: Record<string, any>,
contextData: Record<string, any>
): Promise<{ success: boolean; data?: any; error?: string }> {
try {
const configData = config.config_data as any;
const restApiSettings = configData?.restApiSettings;
if (!restApiSettings) {
throw new Error("REST API 설정이 없습니다.");
}
const { apiUrl, httpMethod, headers = {}, timeout = 30000 } = restApiSettings;
// 요청 헤더 준비
const requestHeaders = {
"Content-Type": "application/json",
...headers,
};
// 인증 처리
if (restApiSettings.authentication?.type === "basic") {
const { username, password } = restApiSettings.authentication;
const credentials = Buffer.from(`${username}:${password}`).toString("base64");
requestHeaders["Authorization"] = `Basic ${credentials}`;
} else if (restApiSettings.authentication?.type === "bearer") {
const { token } = restApiSettings.authentication;
requestHeaders["Authorization"] = `Bearer ${token}`;
}
// 요청 본문 준비
let requestBody = undefined;
if (["POST", "PUT", "PATCH"].includes(httpMethod.toUpperCase())) {
requestBody = JSON.stringify({
...requestData,
_context: contextData, // 컨텍스트 정보 추가
});
}
logger.info(`외부 API 호출: ${httpMethod} ${apiUrl}`);
// 실제 HTTP 요청 (여기서는 간단한 예시)
// 실제 구현에서는 axios나 fetch를 사용
const response = await fetch(apiUrl, {
method: httpMethod,
headers: requestHeaders,
body: requestBody,
signal: AbortSignal.timeout(timeout),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const responseData = await response.json();
return {
success: true,
data: responseData,
};
} catch (error) {
logger.error("외부 API 호출 실패:", error);
const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류";
return {
success: false,
error: errorMessage,
};
}
}
/**
* 🔥 Outbound
*/
private async processOutboundMapping(
mapping: any,
sourceData: Record<string, any>
): Promise<Record<string, any>> {
try {
// 간단한 매핑 로직 (실제로는 더 복잡한 변환 로직 필요)
const mappedData: Record<string, any> = {};
if (mapping.fieldMappings) {
for (const fieldMapping of mapping.fieldMappings) {
const { sourceField, targetField, transformation } = fieldMapping;
let value = sourceData[sourceField];
// 변환 로직 적용
if (transformation) {
switch (transformation.type) {
case "format":
// 포맷 변환 로직
break;
case "calculate":
// 계산 로직
break;
default:
// 기본값 그대로 사용
break;
}
}
mappedData[targetField] = value;
}
}
return mappedData;
} catch (error) {
logger.error("Outbound 데이터 매핑 처리 실패:", error);
return sourceData; // 실패 시 원본 데이터 반환
}
}
/**
* 🔥 Inbound
*/
private async processInboundMapping(
mapping: any,
responseData: any
): Promise<void> {
try {
// Inbound 매핑 로직 (응답 데이터를 내부 시스템에 저장)
logger.info("Inbound 데이터 매핑 처리:", mapping);
// 실제 구현에서는 응답 데이터를 파싱하여 내부 테이블에 저장하는 로직 필요
// 예: 외부 API에서 받은 사용자 정보를 내부 사용자 테이블에 업데이트
} catch (error) {
logger.error("Inbound 데이터 매핑 처리 실패:", error);
// Inbound 매핑 실패는 전체 플로우를 중단하지 않음
}
}
} }
export default new ExternalCallConfigService(); export default new ExternalCallConfigService();

View File

@ -105,7 +105,7 @@ export default function DataFlowPage() {
{/* 페이지 제목 */} {/* 페이지 제목 */}
<div className="flex items-center justify-between rounded-lg border bg-white p-4 shadow-sm"> <div className="flex items-center justify-between rounded-lg border bg-white p-4 shadow-sm">
<div> <div>
<h1 className="text-3xl font-bold text-gray-900"> </h1> <h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-gray-600"> </p> <p className="mt-2 text-gray-600"> </p>
</div> </div>
</div> </div>

View File

@ -89,7 +89,7 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
return; return;
}, []); }, []);
// 편집 모드일 때 관계 데이터 로드 // 편집 모드일 때 관계 데이터 로드
useEffect(() => { useEffect(() => {
const loadDiagramData = async () => { const loadDiagramData = async () => {
if (diagramId && diagramId > 0) { if (diagramId && diagramId > 0) {
@ -99,7 +99,7 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
const jsonDiagram = await DataFlowAPI.getJsonDataFlowDiagramById(diagramId, companyCode); const jsonDiagram = await DataFlowAPI.getJsonDataFlowDiagramById(diagramId, companyCode);
if (jsonDiagram) { if (jsonDiagram) {
// 관계 이름 설정 // 관계 이름 설정
if (jsonDiagram.diagram_name) { if (jsonDiagram.diagram_name) {
setCurrentDiagramName(jsonDiagram.diagram_name); setCurrentDiagramName(jsonDiagram.diagram_name);
} }

View File

@ -96,14 +96,14 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
setTotal(response.pagination.total || 0); setTotal(response.pagination.total || 0);
setTotalPages(Math.max(1, Math.ceil((response.pagination.total || 0) / 20))); setTotalPages(Math.max(1, Math.ceil((response.pagination.total || 0) / 20)));
} catch (error) { } catch (error) {
console.error("관계 목록 조회 실패", error); console.error("관계 목록 조회 실패", error);
toast.error("관계 목록을 불러오는데 실패했습니다."); toast.error("관계 목록을 불러오는데 실패했습니다.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [currentPage, searchTerm, companyCode]); }, [currentPage, searchTerm, companyCode]);
// 관계 목록 로드 // 관계 목록 로드
useEffect(() => { useEffect(() => {
loadDiagrams(); loadDiagrams();
}, [loadDiagrams]); }, [loadDiagrams]);
@ -130,13 +130,13 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
undefined, undefined,
user?.userId || "SYSTEM", user?.userId || "SYSTEM",
); );
toast.success(`관계가 성공적으로 복사되었습니다: ${copiedDiagram.diagram_name}`); toast.success(`관계가 성공적으로 복사되었습니다: ${copiedDiagram.diagram_name}`);
// 목록 새로고침 // 목록 새로고침
await loadDiagrams(); await loadDiagrams();
} catch (error) { } catch (error) {
console.error("관계 복사 실패:", error); console.error("관계 복사 실패:", error);
toast.error("관계 복사에 실패했습니다."); toast.error("관계 복사에 실패했습니다.");
} finally { } finally {
setLoading(false); setLoading(false);
setShowCopyModal(false); setShowCopyModal(false);
@ -151,13 +151,13 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
try { try {
setLoading(true); setLoading(true);
await DataFlowAPI.deleteJsonDataFlowDiagram(selectedDiagramForAction.diagramId, companyCode); await DataFlowAPI.deleteJsonDataFlowDiagram(selectedDiagramForAction.diagramId, companyCode);
toast.success(`관계가 삭제되었습니다: ${selectedDiagramForAction.diagramName}`); toast.success(`관계가 삭제되었습니다: ${selectedDiagramForAction.diagramName}`);
// 목록 새로고침 // 목록 새로고침
await loadDiagrams(); await loadDiagrams();
} catch (error) { } catch (error) {
console.error("관계 삭제 실패:", error); console.error("관계 삭제 실패:", error);
toast.error("관계 삭제에 실패했습니다."); toast.error("관계 삭제에 실패했습니다.");
} finally { } finally {
setLoading(false); setLoading(false);
setShowDeleteModal(false); setShowDeleteModal(false);
@ -181,7 +181,7 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
<div className="relative"> <div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" /> <Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
<Input <Input
placeholder="관계명, 테이블명으로 검색..." placeholder="관계명, 테이블명으로 검색..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="w-80 pl-10" className="w-80 pl-10"
@ -189,17 +189,17 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
</div> </div>
</div> </div>
<Button className="bg-blue-600 hover:bg-blue-700" onClick={() => onDesignDiagram(null)}> <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> </Button>
</div> </div>
{/* 관계 목록 테이블 */} {/* 관계 목록 테이블 */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center justify-between"> <CardTitle className="flex items-center justify-between">
<span className="flex items-center"> <span className="flex items-center">
<Network className="mr-2 h-5 w-5" /> <Network className="mr-2 h-5 w-5" />
({total}) ({total})
</span> </span>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@ -207,7 +207,7 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead> </TableHead> <TableHead> </TableHead>
<TableHead> </TableHead> <TableHead> </TableHead>
<TableHead> </TableHead> <TableHead> </TableHead>
@ -284,8 +284,8 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
{diagrams.length === 0 && ( {diagrams.length === 0 && (
<div className="py-8 text-center text-gray-500"> <div className="py-8 text-center text-gray-500">
<Network className="mx-auto mb-4 h-12 w-12 text-gray-300" /> <Network className="mx-auto mb-4 h-12 w-12 text-gray-300" />
<div className="mb-2 text-lg font-medium"></div> <div className="mb-2 text-lg font-medium"></div>
<div className="text-sm"> .</div> <div className="text-sm"> .</div>
</div> </div>
)} )}
</CardContent> </CardContent>
@ -320,11 +320,11 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
<Dialog open={showCopyModal} onOpenChange={setShowCopyModal}> <Dialog open={showCopyModal} onOpenChange={setShowCopyModal}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle> </DialogTitle> <DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
&ldquo;{selectedDiagramForAction?.diagramName}&rdquo; ? &ldquo;{selectedDiagramForAction?.diagramName}&rdquo; ?
<br /> <br />
(1), (2), (3)... . (1), (2), (3)... .
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
@ -342,9 +342,9 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
<Dialog open={showDeleteModal} onOpenChange={setShowDeleteModal}> <Dialog open={showDeleteModal} onOpenChange={setShowDeleteModal}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle className="text-red-600"> </DialogTitle> <DialogTitle className="text-red-600"> </DialogTitle>
<DialogDescription> <DialogDescription>
&ldquo;{selectedDiagramForAction?.diagramName}&rdquo; ? &ldquo;{selectedDiagramForAction?.diagramName}&rdquo; ?
<br /> <br />
<span className="font-medium text-red-600"> <span className="font-medium text-red-600">
, . , .

View File

@ -65,7 +65,7 @@ export const DataFlowSidebar: React.FC<DataFlowSidebarProps> = ({
hasUnsavedChanges ? "animate-pulse" : "" hasUnsavedChanges ? "animate-pulse" : ""
}`} }`}
> >
💾 {tempRelationships.length > 0 && `(${tempRelationships.length})`} 💾 {tempRelationships.length > 0 && `(${tempRelationships.length})`}
</button> </button>
</div> </div>

View File

@ -622,7 +622,57 @@ const DataConnectionDesigner: React.FC<DataConnectionDesignerProps> = ({
company_code: "*", // 기본값 company_code: "*", // 기본값
}; };
const configResult = await ExternalCallConfigAPI.createConfig(configData); 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) { if (!configResult.success) {
throw new Error(configResult.error || "외부호출 설정 저장 실패"); throw new Error(configResult.error || "외부호출 설정 저장 실패");

View File

@ -12,6 +12,7 @@ import { cn } from "@/lib/utils";
import { ComponentData } from "@/types/screen"; import { ComponentData } from "@/types/screen";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
import { ButtonDataflowConfigPanel } from "./ButtonDataflowConfigPanel"; import { ButtonDataflowConfigPanel } from "./ButtonDataflowConfigPanel";
import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel";
interface ButtonConfigPanelProps { interface ButtonConfigPanelProps {
component: ComponentData; component: ComponentData;
@ -526,7 +527,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
<p className="mt-1 text-sm text-gray-600"> </p> <p className="mt-1 text-sm text-gray-600"> </p>
</div> </div>
<ButtonDataflowConfigPanel component={component} onUpdateProperty={onUpdateProperty} /> <ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} />
</div> </div>
</div> </div>
); );

View File

@ -37,7 +37,7 @@ interface RelationshipOption {
* 🔥 (Phase 1: 간편 ) * 🔥 (Phase 1: 간편 )
* *
* : * :
* - * -
* - "after" * - "after"
* - Phase 2 * - Phase 2
*/ */
@ -57,14 +57,14 @@ export const ButtonDataflowConfigPanel: React.FC<ButtonDataflowConfigPanelProps>
const [relationshipOpen, setRelationshipOpen] = useState(false); const [relationshipOpen, setRelationshipOpen] = useState(false);
const [previewData, setPreviewData] = useState<any>(null); const [previewData, setPreviewData] = useState<any>(null);
// 🔥 관계 목록 로딩 // 🔥 관계 목록 로딩
useEffect(() => { useEffect(() => {
if (config.enableDataflowControl) { if (config.enableDataflowControl) {
loadDiagrams(); loadDiagrams();
} }
}, [config.enableDataflowControl]); }, [config.enableDataflowControl]);
// 🔥 관계 변경 시 관계 목록 로딩 // 🔥 관계 변경 시 관계 목록 로딩
useEffect(() => { useEffect(() => {
if (dataflowConfig.selectedDiagramId) { if (dataflowConfig.selectedDiagramId) {
loadRelationships(dataflowConfig.selectedDiagramId); loadRelationships(dataflowConfig.selectedDiagramId);
@ -72,12 +72,12 @@ export const ButtonDataflowConfigPanel: React.FC<ButtonDataflowConfigPanelProps>
}, [dataflowConfig.selectedDiagramId]); }, [dataflowConfig.selectedDiagramId]);
/** /**
* 🔥 ( ) * 🔥 ( )
*/ */
const loadDiagrams = async () => { const loadDiagrams = async () => {
try { try {
setDiagramsLoading(true); setDiagramsLoading(true);
console.log("🔍 데이터플로우 관계 목록 로딩..."); console.log("🔍 데이터플로우 관계 목록 로딩...");
const response = await apiClient.get("/test-button-dataflow/diagrams"); const response = await apiClient.get("/test-button-dataflow/diagrams");
@ -90,10 +90,10 @@ export const ButtonDataflowConfigPanel: React.FC<ButtonDataflowConfigPanelProps>
})); }));
setDiagrams(diagramList); setDiagrams(diagramList);
console.log(`✅ 관계 ${diagramList.length}개 로딩 완료`); console.log(`✅ 관계 ${diagramList.length}개 로딩 완료`);
} }
} catch (error) { } catch (error) {
console.error("❌ 관계 목록 로딩 실패:", error); console.error("❌ 관계 목록 로딩 실패:", error);
setDiagrams([]); setDiagrams([]);
} finally { } finally {
setDiagramsLoading(false); setDiagramsLoading(false);
@ -106,7 +106,7 @@ export const ButtonDataflowConfigPanel: React.FC<ButtonDataflowConfigPanelProps>
const loadRelationships = async (diagramId: number) => { const loadRelationships = async (diagramId: number) => {
try { try {
setRelationshipsLoading(true); setRelationshipsLoading(true);
console.log(`🔍 관계 ${diagramId} 관계 목록 로딩...`); console.log(`🔍 관계 ${diagramId} 관계 목록 로딩...`);
const response = await apiClient.get(`/test-button-dataflow/diagrams/${diagramId}/relationships`); const response = await apiClient.get(`/test-button-dataflow/diagrams/${diagramId}/relationships`);
@ -216,7 +216,7 @@ export const ButtonDataflowConfigPanel: React.FC<ButtonDataflowConfigPanelProps>
} }
}; };
// 선택된 관계 정보 // 선택된 관계 정보
const selectedDiagram = diagrams.find((d) => d.id === dataflowConfig.selectedDiagramId); const selectedDiagram = diagrams.find((d) => d.id === dataflowConfig.selectedDiagramId);
const selectedRelationship = relationships.find((r) => r.id === dataflowConfig.selectedRelationshipId); const selectedRelationship = relationships.find((r) => r.id === dataflowConfig.selectedRelationshipId);
@ -324,7 +324,7 @@ export const ButtonDataflowConfigPanel: React.FC<ButtonDataflowConfigPanelProps>
<SelectValue placeholder="제어 모드를 선택하세요" /> <SelectValue placeholder="제어 모드를 선택하세요" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="simple"> ( )</SelectItem> <SelectItem value="simple"> ( )</SelectItem>
<SelectItem value="advanced" disabled> <SelectItem value="advanced" disabled>
() ()
</SelectItem> </SelectItem>
@ -335,11 +335,11 @@ export const ButtonDataflowConfigPanel: React.FC<ButtonDataflowConfigPanelProps>
{/* 간편 모드 설정 */} {/* 간편 모드 설정 */}
{(dataflowConfig.controlMode === "simple" || !dataflowConfig.controlMode) && ( {(dataflowConfig.controlMode === "simple" || !dataflowConfig.controlMode) && (
<div className="space-y-3 rounded border bg-gray-50 p-3"> <div className="space-y-3 rounded border bg-gray-50 p-3">
<h4 className="text-sm font-medium text-gray-700"> </h4> <h4 className="text-sm font-medium text-gray-700"> </h4>
{/* 관계 선택 */} {/* 관계 선택 */}
<div> <div>
<Label className="text-xs"></Label> <Label className="text-xs"></Label>
<Popover open={diagramOpen} onOpenChange={setDiagramOpen}> <Popover open={diagramOpen} onOpenChange={setDiagramOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
@ -357,7 +357,7 @@ export const ButtonDataflowConfigPanel: React.FC<ButtonDataflowConfigPanelProps>
</Badge> </Badge>
</div> </div>
) : ( ) : (
"관계를 선택하세요" "관계를 선택하세요"
)} )}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
@ -365,9 +365,9 @@ export const ButtonDataflowConfigPanel: React.FC<ButtonDataflowConfigPanelProps>
<PopoverContent className="w-80 p-0"> <PopoverContent className="w-80 p-0">
<div className="p-2"> <div className="p-2">
{diagramsLoading ? ( {diagramsLoading ? (
<div className="p-4 text-center text-sm text-gray-500"> ...</div> <div className="p-4 text-center text-sm text-gray-500"> ...</div>
) : diagrams.length === 0 ? ( ) : diagrams.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>
) : ( ) : (
<div className="max-h-60 overflow-y-auto"> <div className="max-h-60 overflow-y-auto">
{diagrams.map((diagram) => ( {diagrams.map((diagram) => (
@ -377,7 +377,7 @@ export const ButtonDataflowConfigPanel: React.FC<ButtonDataflowConfigPanelProps>
className="h-auto w-full justify-start p-2" className="h-auto w-full justify-start p-2"
onClick={() => { onClick={() => {
onUpdateProperty("webTypeConfig.dataflowConfig.selectedDiagramId", diagram.id); onUpdateProperty("webTypeConfig.dataflowConfig.selectedDiagramId", diagram.id);
// 관계 변경 시 기존 관계 선택 초기화 // 관계 변경 시 기존 관계 선택 초기화
onUpdateProperty("webTypeConfig.dataflowConfig.selectedRelationshipId", null); onUpdateProperty("webTypeConfig.dataflowConfig.selectedRelationshipId", null);
setDiagramOpen(false); setDiagramOpen(false);
}} }}
@ -435,7 +435,7 @@ export const ButtonDataflowConfigPanel: React.FC<ButtonDataflowConfigPanelProps>
<div className="p-4 text-center text-sm text-gray-500"> ...</div> <div className="p-4 text-center text-sm text-gray-500"> ...</div>
) : relationships.length === 0 ? ( ) : relationships.length === 0 ? (
<div className="p-4 text-center text-sm text-gray-500"> <div className="p-4 text-center text-sm text-gray-500">
</div> </div>
) : ( ) : (
<div className="max-h-60 overflow-y-auto"> <div className="max-h-60 overflow-y-auto">

View File

@ -0,0 +1,281 @@
"use client";
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
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";
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,
onUpdateProperty,
}) => {
const config = component.webTypeConfig || {};
const dataflowConfig = config.dataflowConfig || {};
// 🔥 State 관리
const [relationships, setRelationships] = useState<RelationshipOption[]>([]);
const [loading, setLoading] = useState(false);
// 🔥 관계 목록 로딩
useEffect(() => {
if (config.enableDataflowControl) {
loadRelationships();
}
}, [config.enableDataflowControl]);
/**
* 🔥 ( )
*/
const loadRelationships = async () => {
try {
setLoading(true);
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}개 로딩 완료`);
}
} catch (error) {
console.error("❌ 관계 목록 로딩 실패:", error);
setRelationships([]);
} 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", // 기본값
contextData: {},
});
}
};
/**
* 🔥
*/
const handleControlTypeChange = (controlType: string) => {
// 기존 설정 초기화
onUpdateProperty("webTypeConfig.dataflowConfig", {
controlMode: controlType,
relationshipConfig: controlType === "relationship" ? undefined : null,
});
};
return (
<div className="space-y-6">
{/* 🔥 제어관리 활성화 스위치 */}
<div className="flex items-center justify-between rounded-lg border bg-blue-50 p-4">
<div className="flex items-center space-x-2">
<Settings className="h-4 w-4 text-blue-600" />
<div>
<Label className="text-sm font-medium">🎮 </Label>
<p className="mt-1 text-xs text-gray-600"> </p>
</div>
</div>
<Switch
checked={config.enableDataflowControl || false}
onCheckedChange={(checked) => onUpdateProperty("webTypeConfig.enableDataflowControl", checked)}
/>
</div>
{/* 🔥 제어관리가 활성화된 경우에만 설정 표시 */}
{config.enableDataflowControl && (
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<Tabs
value={dataflowConfig.controlMode || "none"}
onValueChange={handleControlTypeChange}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="none"> </TabsTrigger>
<TabsTrigger value="relationship"> </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" />
<p> .</p>
</div>
</TabsContent>
<TabsContent value="relationship" className="mt-4">
<RelationshipSelector
relationships={relationships}
selectedRelationshipId={dataflowConfig.relationshipConfig?.relationshipId}
onSelect={handleRelationshipSelect}
loading={loading}
/>
{dataflowConfig.relationshipConfig && (
<div className="mt-4 space-y-4">
<Separator />
<ExecutionTimingSelector
value={dataflowConfig.relationshipConfig.executionTiming}
onChange={(timing) =>
onUpdateProperty("webTypeConfig.dataflowConfig.relationshipConfig.executionTiming", timing)
}
/>
<div className="rounded bg-blue-50 p-3">
<div className="flex items-start space-x-2">
<Info className="h-4 w-4 text-blue-600 mt-0.5" />
<div className="text-xs text-blue-800">
<p className="font-medium"> :</p>
<p className="mt-1"> , .</p>
</div>
</div>
</div>
</div>
)}
</TabsContent>
</Tabs>
</CardContent>
</Card>
)}
</div>
);
};
/**
* 🔥
*/
const RelationshipSelector: React.FC<{
relationships: RelationshipOption[];
selectedRelationshipId?: string;
onSelect: (relationshipId: string) => void;
loading: boolean;
}> = ({ relationships, selectedRelationshipId, onSelect, loading }) => {
return (
<div className="space-y-4">
<div className="flex items-center space-x-2">
<GitBranch className="h-4 w-4 text-blue-600" />
<Label> </Label>
</div>
<Select value={selectedRelationshipId || ""} onValueChange={onSelect}>
<SelectTrigger>
<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>
) : (
relationships.map((rel) => (
<SelectItem key={rel.id} value={rel.id}>
<div className="flex flex-col">
<span className="font-medium">{rel.name}</span>
<span className="text-xs text-muted-foreground">
{rel.sourceTable} {rel.targetTable}
</span>
</div>
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
);
};
/**
* 🔥
*/
const ExecutionTimingSelector: React.FC<{
value: string;
onChange: (timing: "before" | "after" | "replace") => void;
}> = ({ value, onChange }) => {
return (
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Clock className="h-4 w-4 text-orange-600" />
<Label> </Label>
</div>
<Select value={value} onValueChange={onChange}>
<SelectTrigger>
<SelectValue placeholder="실행 타이밍을 선택하세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="before">
<div className="flex flex-col">
<span className="font-medium">Before ( )</span>
<span className="text-xs text-muted-foreground"> </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>
</div>
</SelectItem>
<SelectItem value="replace">
<div className="flex flex-col">
<span className="font-medium">Replace ( )</span>
<span className="text-xs text-muted-foreground"> </span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
);
};

View File

@ -3,7 +3,7 @@
import { toast } from "sonner"; import { toast } from "sonner";
import { screenApi } from "@/lib/api/screen"; import { screenApi } from "@/lib/api/screen";
import { DynamicFormApi } from "@/lib/api/dynamicForm"; import { DynamicFormApi } from "@/lib/api/dynamicForm";
import { OptimizedButtonDataflowService, ExtendedControlContext } from "@/lib/services/optimizedButtonDataflowService"; import { ImprovedButtonActionExecutor } from "@/lib/utils/improvedButtonActionExecutor";
/** /**
* *
@ -781,40 +781,56 @@ export class ButtonActionExecutor {
extendedContext, extendedContext,
}); });
// 🔥 실제 제어 조건 검증 수행 // 🔥 새로운 버튼 액션 실행 시스템 사용
const validationResult = await OptimizedButtonDataflowService.executeExtendedValidation( if (config.dataflowConfig?.controlMode === "relationship" && config.dataflowConfig?.relationshipConfig) {
config.dataflowConfig, console.log("🔗 관계 기반 제어 실행:", config.dataflowConfig.relationshipConfig);
extendedContext,
); // 새로운 ImprovedButtonActionExecutor 사용
const buttonConfig = {
actionType: config.type,
dataflowConfig: config.dataflowConfig,
enableDataflowControl: true, // 관계 기반 제어가 설정된 경우 활성화
};
if (validationResult.success) { const executionResult = await ImprovedButtonActionExecutor.executeButtonAction(
console.log("✅ 제어 조건 만족 - 액션 실행 시작:", { buttonConfig,
actions: validationResult.actions, context.formData || {},
context, {
}); buttonId: context.buttonId || "unknown",
screenId: context.screenId || "unknown",
userId: context.userId || "unknown",
companyCode: context.companyCode || "*",
startTime: Date.now(),
contextData: context,
}
);
// 🔥 조건을 만족했으므로 실제 액션 실행 if (executionResult.success) {
if (validationResult.actions && validationResult.actions.length > 0) { console.log("✅ 관계 실행 완료:", executionResult);
console.log("🚀 액션 실행 시작:", validationResult.actions); toast.success(config.successMessage || "관계 실행이 완료되었습니다.");
await this.executeRelationshipActions(validationResult.actions, context);
// 새로고침이 필요한 경우
if (context.onRefresh) {
context.onRefresh();
}
return true;
} else { } else {
console.warn("⚠️ 실행할 액션이 없습니다:", { console.error("❌ 관계 실행 실패:", executionResult);
hasActions: !!validationResult.actions, toast.error(config.errorMessage || "관계 실행 중 오류가 발생했습니다.");
actionsLength: validationResult.actions?.length, return false;
validationResult,
});
toast.success(config.successMessage || "제어 조건을 만족합니다. (실행할 액션 없음)");
} }
} else {
// 제어 없음 - 메인 액션만 실행
console.log("⚡ 제어 없음 - 메인 액션 실행");
await this.executeMainAction(config, context);
// 새로고침이 필요한 경우 // 새로고침이 필요한 경우
if (context.onRefresh) { if (context.onRefresh) {
context.onRefresh(); context.onRefresh();
} }
return true; return true;
} else {
toast.error(validationResult.message || "제어 조건을 만족하지 않습니다.");
return false;
} }
} catch (error) { } catch (error) {
console.error("제어 조건 검증 중 오류:", error); console.error("제어 조건 검증 중 오류:", error);

View File

@ -0,0 +1,781 @@
/**
* 🔥
*
* :
* 1. Before
* 2. (replace가 )
* 3. After
*/
import { toast } from "sonner";
import { apiClient } from "@/lib/api/client";
import { ExtendedButtonTypeConfig, ButtonDataflowConfig } from "@/types/control-management";
import { ButtonActionType } from "@/types/unified-core";
// ===== 인터페이스 정의 =====
export interface ButtonExecutionContext {
buttonId: string;
screenId: string;
userId: string;
companyCode: string;
startTime: number;
formData?: Record<string, any>;
selectedRows?: any[];
tableData?: any[];
}
export interface ExecutionResult {
success: boolean;
message: string;
executionTime: number;
data?: any;
error?: string;
}
export interface ButtonExecutionResult {
success: boolean;
results: ExecutionResult[];
executionTime: number;
error?: string;
}
interface ControlConfig {
type: "relationship";
relationshipConfig: {
relationshipId: string;
relationshipName: string;
executionTiming: "before" | "after" | "replace";
contextData?: Record<string, any>;
};
}
interface ExecutionPlan {
beforeControls: ControlConfig[];
afterControls: ControlConfig[];
hasReplaceControl: boolean;
}
// ===== 메인 실행기 클래스 =====
export class ImprovedButtonActionExecutor {
/**
* 🔥
*/
static async executeButtonAction(
buttonConfig: ExtendedButtonTypeConfig,
formData: Record<string, any>,
context: ButtonExecutionContext
): Promise<ButtonExecutionResult> {
console.log("🔥 ImprovedButtonActionExecutor 시작:", {
buttonConfig,
formData,
context,
});
const executionPlan = this.createExecutionPlan(buttonConfig);
const results: ExecutionResult[] = [];
console.log("📋 생성된 실행 계획:", {
beforeControls: executionPlan.beforeControls,
afterControls: executionPlan.afterControls,
hasReplaceControl: executionPlan.hasReplaceControl,
});
try {
console.log("🚀 버튼 액션 실행 시작:", {
actionType: buttonConfig.actionType,
hasControls: executionPlan.beforeControls.length + executionPlan.afterControls.length > 0,
hasReplace: executionPlan.hasReplaceControl,
});
// 1. Before 타이밍 제어 실행
if (executionPlan.beforeControls.length > 0) {
console.log("⏰ Before 제어 실행 시작");
const beforeResults = await this.executeControls(
executionPlan.beforeControls,
formData,
context
);
results.push(...beforeResults);
// Before 제어 중 실패가 있으면 중단
const hasFailure = beforeResults.some(r => !r.success);
if (hasFailure) {
throw new Error("Before 제어 실행 중 오류가 발생했습니다.");
}
}
// 2. 메인 액션 실행 (replace가 아닌 경우에만)
if (!executionPlan.hasReplaceControl) {
console.log("⚡ 메인 액션 실행:", buttonConfig.actionType);
const mainResult = await this.executeMainAction(
buttonConfig,
formData,
context
);
results.push(mainResult);
if (!mainResult.success) {
throw new Error("메인 액션 실행 중 오류가 발생했습니다.");
}
} else {
console.log("🔄 Replace 모드: 메인 액션 건너뜀");
}
// 3. After 타이밍 제어 실행
if (executionPlan.afterControls.length > 0) {
console.log("⏰ After 제어 실행 시작");
const afterResults = await this.executeControls(
executionPlan.afterControls,
formData,
context
);
results.push(...afterResults);
}
const totalExecutionTime = Date.now() - context.startTime;
console.log("✅ 버튼 액션 실행 완료:", `${totalExecutionTime}ms`);
return {
success: true,
results,
executionTime: totalExecutionTime,
};
} catch (error) {
console.error("❌ 버튼 액션 실행 실패:", error);
// 롤백 처리
await this.handleExecutionError(error, results, buttonConfig);
return {
success: false,
results,
executionTime: Date.now() - context.startTime,
error: error.message,
};
}
}
/**
* 🔥
*/
private static createExecutionPlan(buttonConfig: ExtendedButtonTypeConfig): ExecutionPlan {
const plan: ExecutionPlan = {
beforeControls: [],
afterControls: [],
hasReplaceControl: false,
};
const dataflowConfig = buttonConfig.dataflowConfig;
if (!dataflowConfig) {
console.log("⚠️ dataflowConfig가 없습니다");
return plan;
}
// enableDataflowControl 체크를 제거하고 dataflowConfig만 있으면 실행
console.log("📋 실행 계획 생성:", {
controlMode: dataflowConfig.controlMode,
hasRelationshipConfig: !!dataflowConfig.relationshipConfig,
enableDataflowControl: buttonConfig.enableDataflowControl,
});
// 관계 기반 제어만 지원
if (dataflowConfig.controlMode === "relationship" && dataflowConfig.relationshipConfig) {
const control: ControlConfig = {
type: "relationship",
relationshipConfig: dataflowConfig.relationshipConfig,
};
switch (dataflowConfig.relationshipConfig.executionTiming) {
case "before":
plan.beforeControls.push(control);
break;
case "after":
plan.afterControls.push(control);
break;
case "replace":
plan.afterControls.push(control); // Replace는 after로 처리하되 플래그 설정
plan.hasReplaceControl = true;
break;
}
}
return plan;
}
/**
* 🔥 ( )
*/
private static async executeControls(
controls: ControlConfig[],
formData: Record<string, any>,
context: ButtonExecutionContext
): Promise<ExecutionResult[]> {
const results: ExecutionResult[] = [];
for (const control of controls) {
try {
// 관계 실행만 지원
const result = await this.executeRelationship(
control.relationshipConfig,
formData,
context
);
results.push(result);
// 제어 실행 실패 시 중단
if (!result.success) {
throw new Error(result.message);
}
} catch (error) {
console.error(`제어 실행 실패 (${control.type}):`, error);
results.push({
success: false,
message: `${control.type} 제어 실행 실패: ${error.message}`,
executionTime: 0,
error: error.message,
});
throw error;
}
}
return results;
}
/**
* 🔥
*/
private static async executeRelationship(
config: {
relationshipId: string;
relationshipName: string;
executionTiming: "before" | "after" | "replace";
contextData?: Record<string, any>;
},
formData: Record<string, any>,
context: ButtonExecutionContext
): Promise<ExecutionResult> {
try {
console.log(`🔗 관계 실행 시작: ${config.relationshipName} (ID: ${config.relationshipId})`);
// 1. 관계 정보 조회
const relationshipData = await this.getRelationshipData(config.relationshipId);
if (!relationshipData) {
throw new Error(`관계 정보를 찾을 수 없습니다: ${config.relationshipId}`);
}
console.log(`📋 관계 데이터 로드 완료:`, relationshipData);
// 2. 관계 타입에 따른 실행
const relationships = relationshipData.relationships;
const connectionType = relationships.connectionType;
let result: ExecutionResult;
if (connectionType === "external_call") {
// 외부 호출 실행
result = await this.executeExternalCall(relationships, formData, context);
} else if (connectionType === "data_save") {
// 데이터 저장 실행
result = await this.executeDataSave(relationships, formData, context);
} else {
throw new Error(`지원하지 않는 연결 타입: ${connectionType}`);
}
console.log(`✅ 관계 실행 완료: ${config.relationshipName}`, result);
if (result.success) {
toast.success(`관계 '${config.relationshipName}' 실행 완료`);
} else {
toast.error(`관계 '${config.relationshipName}' 실행 실패: ${result.message}`);
}
return result;
} catch (error: any) {
console.error(`❌ 관계 실행 실패: ${config.relationshipName}`, error);
const errorResult = {
success: false,
message: `관계 '${config.relationshipName}' 실행 실패: ${error.message}`,
executionTime: 0,
error: error.message,
};
toast.error(errorResult.message);
return errorResult;
}
}
/**
*
*/
private static async getRelationshipData(relationshipId: string): Promise<any> {
try {
console.log(`🔍 관계 데이터 조회 시작: ${relationshipId}`);
const response = await apiClient.get(`/dataflow-diagrams/${relationshipId}`);
console.log(`✅ 관계 데이터 조회 성공:`, response.data);
if (!response.data.success) {
throw new Error(response.data.message || '관계 데이터 조회 실패');
}
return response.data.data;
} catch (error) {
console.error('관계 데이터 조회 오류:', error);
throw error;
}
}
/**
*
*/
private static async executeExternalCall(
relationships: any,
formData: Record<string, any>,
context: ButtonExecutionContext
): Promise<ExecutionResult> {
try {
const externalCallConfig = relationships.externalCallConfig;
if (!externalCallConfig) {
throw new Error('외부 호출 설정이 없습니다');
}
const restApiSettings = externalCallConfig.restApiSettings;
if (!restApiSettings) {
throw new Error('REST API 설정이 없습니다');
}
console.log(`🌐 외부 API 호출: ${restApiSettings.apiUrl}`);
// API 호출 준비
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...restApiSettings.headers,
};
// 인증 처리
if (restApiSettings.authentication?.type === 'api-key') {
headers['Authorization'] = `Bearer ${restApiSettings.authentication.apiKey}`;
}
// 요청 바디 준비 (템플릿 처리)
let requestBody = restApiSettings.bodyTemplate || '';
if (requestBody) {
// 간단한 템플릿 치환 ({{변수명}} 형태)
requestBody = requestBody.replace(/\{\{(\w+)\}\}/g, (match: string, key: string) => {
return formData[key] || (context as any).contextData?.[key] || new Date().toISOString();
});
}
// 백엔드 프록시를 통한 외부 API 호출 (CORS 문제 해결)
console.log(`🌐 백엔드 프록시를 통한 외부 API 호출 준비:`, {
originalUrl: restApiSettings.apiUrl,
method: restApiSettings.httpMethod || 'GET',
headers,
body: restApiSettings.httpMethod !== 'GET' ? requestBody : undefined,
});
// 백엔드 프록시 API 호출 - GenericApiSettings 형식에 맞게 전달
const requestPayload = {
diagramId: relationships.diagramId || 45, // 관계 ID 사용
relationshipId: relationships.relationshipId || "relationship-45",
settings: {
callType: "rest-api",
apiType: "generic",
url: restApiSettings.apiUrl,
method: restApiSettings.httpMethod || 'POST',
headers: restApiSettings.headers || {},
body: requestBody,
authentication: restApiSettings.authentication || { type: 'none' },
timeout: restApiSettings.timeout || 30000,
retryCount: restApiSettings.retryCount || 3,
},
templateData: restApiSettings.httpMethod !== 'GET' ? JSON.parse(requestBody) : {},
};
console.log(`📤 백엔드로 전송할 데이터:`, requestPayload);
const proxyResponse = await apiClient.post(`/external-calls/execute`, requestPayload);
console.log(`📡 백엔드 프록시 응답:`, proxyResponse.data);
if (!proxyResponse.data.success) {
throw new Error(`프록시 API 호출 실패: ${proxyResponse.data.error || proxyResponse.data.message}`);
}
const responseData = proxyResponse.data.result;
console.log(`✅ 외부 API 호출 성공 (프록시):`, responseData);
// 데이터 매핑 처리 (inbound mapping)
if (externalCallConfig.dataMappingConfig?.inboundMapping) {
await this.processInboundMapping(
externalCallConfig.dataMappingConfig.inboundMapping,
responseData,
context
);
}
return {
success: true,
message: '외부 호출 실행 완료',
executionTime: Date.now() - context.startTime,
data: responseData,
};
} catch (error: any) {
console.error('외부 호출 실행 오류:', error);
return {
success: false,
message: `외부 호출 실행 실패: ${error.message}`,
executionTime: Date.now() - context.startTime,
error: error.message,
};
}
}
/**
*
*/
private static async executeDataSave(
relationships: any,
formData: Record<string, any>,
context: ButtonExecutionContext
): Promise<ExecutionResult> {
try {
console.log(`💾 데이터 저장 실행 시작`);
// 제어 조건 확인
const controlConditions = relationships.controlConditions || [];
if (controlConditions.length > 0) {
const conditionsMet = this.evaluateConditions(controlConditions, formData, context);
if (!conditionsMet) {
return {
success: false,
message: '제어 조건을 만족하지 않아 데이터 저장을 건너뜁니다',
executionTime: Date.now() - context.startTime,
};
}
}
// 액션 그룹 실행
const actionGroups = relationships.actionGroups || [];
const results = [];
for (const actionGroup of actionGroups) {
if (!actionGroup.isEnabled) {
console.log(`⏭️ 비활성화된 액션 그룹 건너뜀: ${actionGroup.name}`);
continue;
}
console.log(`🎯 액션 그룹 실행: ${actionGroup.name}`);
for (const action of actionGroup.actions) {
if (!action.isEnabled) {
console.log(`⏭️ 비활성화된 액션 건너뜀: ${action.name}`);
continue;
}
const actionResult = await this.executeDataAction(
action,
relationships,
formData,
context
);
results.push(actionResult);
if (!actionResult.success) {
console.error(`❌ 액션 실행 실패: ${action.name}`, actionResult);
}
}
}
const successCount = results.filter(r => r.success).length;
const totalCount = results.length;
return {
success: successCount > 0,
message: `데이터 저장 완료: ${successCount}/${totalCount} 액션 성공`,
executionTime: Date.now() - context.startTime,
data: {
results,
successCount,
totalCount,
},
};
} catch (error: any) {
console.error('데이터 저장 실행 오류:', error);
return {
success: false,
message: `데이터 저장 실행 실패: ${error.message}`,
executionTime: Date.now() - context.startTime,
error: error.message,
};
}
}
/**
*
*/
private static async executeDataAction(
action: any,
relationships: any,
formData: Record<string, any>,
context: ButtonExecutionContext
): Promise<ExecutionResult> {
try {
console.log(`🔧 데이터 액션 실행: ${action.name} (${action.actionType})`);
// 필드 매핑 처리
const mappedData: Record<string, any> = {};
for (const mapping of action.fieldMappings) {
if (mapping.valueType === 'static') {
// 정적 값 처리
let value = mapping.value;
if (value === '#NOW') {
value = new Date().toISOString();
}
mappedData[mapping.targetField] = value;
} else {
// 필드 매핑 처리
const sourceField = mapping.fromField?.columnName;
if (sourceField && formData[sourceField] !== undefined) {
mappedData[mapping.toField.columnName] = formData[sourceField];
}
}
}
console.log(`📋 매핑된 데이터:`, mappedData);
// 대상 연결 정보
const toConnection = relationships.toConnection;
const targetTable = relationships.toTable?.tableName;
if (!targetTable) {
throw new Error('대상 테이블이 지정되지 않았습니다');
}
// 데이터 저장 API 호출
const saveResult = await this.saveDataToTable(
targetTable,
mappedData,
action.actionType,
toConnection
);
return {
success: true,
message: `데이터 액션 "${action.name}" 실행 완료`,
executionTime: Date.now() - context.startTime,
data: saveResult,
};
} catch (error: any) {
console.error(`데이터 액션 실행 오류: ${action.name}`, error);
return {
success: false,
message: `데이터 액션 실행 실패: ${error.message}`,
executionTime: Date.now() - context.startTime,
error: error.message,
};
}
}
/**
*
*/
private static async saveDataToTable(
tableName: string,
data: Record<string, any>,
actionType: string,
connection?: any
): Promise<any> {
try {
// 데이터 저장 API 호출
const response = await fetch('/api/dataflow/execute-data-action', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
body: JSON.stringify({
tableName,
data,
actionType,
connection,
}),
});
if (!response.ok) {
throw new Error(`데이터 저장 API 호출 실패: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('데이터 저장 오류:', error);
throw error;
}
}
/**
*
*/
private static evaluateConditions(
conditions: any[],
formData: Record<string, any>,
context: ButtonExecutionContext
): boolean {
for (const condition of conditions) {
const fieldValue = formData[condition.field];
const conditionValue = condition.value;
const operator = condition.operator;
let conditionMet = false;
switch (operator) {
case '=':
conditionMet = fieldValue === conditionValue;
break;
case '!=':
conditionMet = fieldValue !== conditionValue;
break;
case '>':
conditionMet = Number(fieldValue) > Number(conditionValue);
break;
case '<':
conditionMet = Number(fieldValue) < Number(conditionValue);
break;
case '>=':
conditionMet = Number(fieldValue) >= Number(conditionValue);
break;
case '<=':
conditionMet = Number(fieldValue) <= Number(conditionValue);
break;
default:
console.warn(`지원하지 않는 연산자: ${operator}`);
conditionMet = true;
}
if (!conditionMet) {
console.log(`❌ 조건 불만족: ${condition.field} ${operator} ${conditionValue} (실제값: ${fieldValue})`);
return false;
}
}
console.log(`✅ 모든 조건 만족`);
return true;
}
/**
*
*/
private static async processInboundMapping(
inboundMapping: any,
responseData: any,
context: ButtonExecutionContext
): Promise<void> {
try {
console.log(`📥 인바운드 데이터 매핑 처리 시작`);
const targetTable = inboundMapping.targetTable;
const fieldMappings = inboundMapping.fieldMappings || [];
const insertMode = inboundMapping.insertMode || 'insert';
// 응답 데이터가 배열인 경우 각 항목 처리
const dataArray = Array.isArray(responseData) ? responseData : [responseData];
for (const item of dataArray) {
const mappedData: Record<string, any> = {};
// 필드 매핑 적용
for (const mapping of fieldMappings) {
const sourceValue = item[mapping.sourceField];
if (sourceValue !== undefined) {
mappedData[mapping.targetField] = sourceValue;
}
}
console.log(`📋 매핑된 데이터:`, mappedData);
// 데이터 저장
await this.saveDataToTable(targetTable, mappedData, insertMode);
}
console.log(`✅ 인바운드 데이터 매핑 완료`);
} catch (error) {
console.error('인바운드 데이터 매핑 오류:', error);
throw error;
}
}
/**
* 🔥
*/
private static async executeMainAction(
buttonConfig: ExtendedButtonTypeConfig,
formData: Record<string, any>,
context: ButtonExecutionContext
): Promise<ExecutionResult> {
try {
// 기존 ButtonActionExecutor 로직을 여기서 호출하거나
// 간단한 액션들을 직접 구현
const startTime = performance.now();
// 임시 구현 - 실제로는 기존 ButtonActionExecutor를 호출해야 함
const result = {
success: true,
message: `${buttonConfig.actionType} 액션 실행 완료`,
executionTime: performance.now() - startTime,
data: { actionType: buttonConfig.actionType, formData },
};
console.log("✅ 메인 액션 실행 완료:", result.message);
return result;
} catch (error) {
console.error("메인 액션 실행 오류:", error);
return {
success: false,
message: `${buttonConfig.actionType} 액션 실행 실패: ${error.message}`,
executionTime: 0,
error: error.message,
};
}
}
/**
* 🔥
*/
private static async handleExecutionError(
error: Error,
results: ExecutionResult[],
buttonConfig: ExtendedButtonTypeConfig
): Promise<void> {
console.error("🔄 실행 오류 처리 시작:", error.message);
// 롤백이 필요한 경우 처리
const rollbackNeeded = buttonConfig.dataflowConfig?.executionOptions?.rollbackOnError;
if (rollbackNeeded) {
console.log("🔄 롤백 처리 시작...");
// 성공한 결과들을 역순으로 롤백
const successfulResults = results.filter(r => r.success).reverse();
for (const result of successfulResults) {
try {
// 롤백 로직 구현 (필요시)
console.log("🔄 롤백:", result.message);
} catch (rollbackError) {
console.error("롤백 실패:", rollbackError);
}
}
}
// 오류 토스트 표시
toast.error(error.message || "작업 중 오류가 발생했습니다.");
}
}

View File

@ -56,24 +56,34 @@ export interface ExtendedButtonTypeConfig {
} }
/** /**
* * 🔥
*/ */
export interface ButtonDataflowConfig { export interface ButtonDataflowConfig {
// 제어 방식 선택 // 제어 방식 선택 (관계 실행만)
controlMode: "simple" | "advanced"; controlMode: "relationship" | "none";
// 관계도 방식 (diagram 기반) // 관계 기반 제어
selectedDiagramId?: number; relationshipConfig?: {
selectedRelationshipId?: number; relationshipId: string; // 관계 직접 선택
relationshipName: string; // 관계명 표시
executionTiming: "before" | "after" | "replace";
contextData?: Record<string, any>; // 실행 시 전달할 컨텍스트
};
// 직접 설정 방식 // 제어 데이터 소스 (기존 호환성 유지)
directControl?: DirectControlConfig;
// 제어 데이터 소스
controlDataSource?: ControlDataSource; controlDataSource?: ControlDataSource;
// 실행 옵션 // 실행 옵션
executionOptions?: ExecutionOptions; executionOptions?: ExecutionOptions;
// 🔧 기존 호환성을 위한 필드들 (deprecated)
selectedDiagramId?: number;
selectedRelationshipId?: number;
directControl?: DirectControlConfig;
// 🔧 제거된 필드들 (하위 호환성을 위해 optional로 유지)
externalCallConfig?: any; // deprecated
customConfig?: any; // deprecated
} }
/** /**