이알솔루션 rest api 연결 #225

Merged
hyeonsu merged 15 commits from common/feat/dashboard-map into main 2025-11-28 10:48:09 +09:00
29 changed files with 2679 additions and 2345 deletions

42
PLAN.MD
View File

@ -1,28 +1,36 @@
# 프로젝트: Digital Twin 에디터 안정화
# 프로젝트: 외부 REST API 커넥션 관리 확장 (POST/Body 지원)
## 개요
Digital Twin 에디터(`DigitalTwinEditor.tsx`)에서 발생한 런타임 에러(`TypeError: Cannot read properties of undefined`)를 수정하고, 전반적인 안정성을 확보합니다.
현재 GET 방식 위주로 구현된 외부 REST API 커넥션 관리 기능을 확장하여, POST, PUT, DELETE 등 다양한 HTTP 메서드와 JSON Request Body를 설정하고 테스트할 수 있도록 개선합니다. 이를 통해 토큰 발급 API나 데이터 전송 API 등 다양한 외부 시스템과의 연동을 지원합니다.
## 핵심 기능
1. `DigitalTwinEditor` 버그 수정
2. 비동기 함수 입력값 유효성 검증 강화
3. 외부 DB 연결 상태에 따른 방어 코드 추가
1. **DB 스키마 확장**: `external_rest_api_connections` 테이블에 `default_method`, `default_body` 컬럼 추가
2. **백엔드 로직 개선**:
- 커넥션 생성/수정 시 메서드와 바디 정보 저장
- 연결 테스트 시 설정된 메서드와 바디를 사용하여 요청 수행
- SSL 인증서 검증 우회 옵션 적용 (내부망/테스트망 지원)
3. **프론트엔드 UI 개선**:
- 커넥션 설정 모달에 HTTP 메서드 선택(Select) 및 Body 입력(Textarea/JSON Editor) 필드 추가
- 테스트 기능에서 Body 데이터 포함하여 요청 전송
## 테스트 계획
### 1단계: 기본 기능 및 DB 마이그레이션
- [x] DB 마이그레이션 스크립트 작성 및 실행
- [x] 백엔드 타입 정의 수정 (`default_method`, `default_body` 추가)
### 1단계: 긴급 버그 수정
### 2단계: 백엔드 로직 구현
- [x] 커넥션 생성/수정 API 수정 (필드 추가)
- [x] 커넥션 상세 조회 API 확인
- [x] 연결 테스트 API 수정 (Method, Body 반영하여 요청 전송)
- [x] `loadMaterialCountsForLocations` 함수에서 `locaKeys` undefined 체크 추가 (완료)
- [ ] 에디터 로드 및 객체 조작 시 에러 발생 여부 확인
### 3단계: 프론트엔드 구현
- [x] 커넥션 관리 리스트/모달 UI 수정
- [x] 연결 테스트 UI 수정 및 기능 확인
### 2단계: 잠재적 문제 점검
- [ ] `loadLayout` 등 주요 로딩 함수의 데이터 유효성 검사
- [ ] `handleToolDragStart`, `handleCanvasDrop` 등 인터랙션 함수의 예외 처리
## 에러 처리 계획
- **JSON 파싱 에러**: Body 입력값이 유효한 JSON이 아닐 경우 에러 처리
- **API 호출 에러**: 외부 API 호출 실패 시 상세 로그 기록 및 클라이언트에 에러 메시지 전달
- **SSL 인증 에러**: `rejectUnauthorized: false` 옵션으로 처리 (기존 `RestApiConnector` 활용)
## 진행 상태
- [진행중] 1단계 긴급 버그 수정 완료 후 사용자 피드백 대기 중
- [완료] 모든 단계 구현 완료

View File

@ -169,22 +169,18 @@ export class BatchController {
static async getBatchConfigById(req: AuthenticatedRequest, res: Response) {
try {
const { id } = req.params;
const userCompanyCode = req.user?.companyCode;
const batchConfig = await BatchService.getBatchConfigById(
Number(id),
userCompanyCode
);
const result = await BatchService.getBatchConfigById(Number(id));
if (!batchConfig) {
if (!result.success || !result.data) {
return res.status(404).json({
success: false,
message: "배치 설정을 찾을 수 없습니다.",
message: result.message || "배치 설정을 찾을 수 없습니다.",
});
}
return res.json({
success: true,
data: batchConfig,
data: result.data,
});
} catch (error) {
console.error("배치 설정 조회 오류:", error);

View File

@ -62,6 +62,11 @@ export class BatchExecutionLogController {
try {
const data: CreateBatchExecutionLogRequest = req.body;
// 멀티테넌시: company_code가 없으면 현재 사용자 회사 코드로 설정
if (!data.company_code) {
data.company_code = req.user?.companyCode || "*";
}
const result = await BatchExecutionLogService.createExecutionLog(data);
if (result.success) {

View File

@ -265,8 +265,12 @@ export class BatchManagementController {
try {
// 실행 로그 생성
executionLog = await BatchService.createExecutionLog({
const { BatchExecutionLogService } = await import(
"../services/batchExecutionLogService"
);
const logResult = await BatchExecutionLogService.createExecutionLog({
batch_config_id: Number(id),
company_code: batchConfig.company_code,
execution_status: "RUNNING",
start_time: startTime,
total_records: 0,
@ -274,6 +278,14 @@ export class BatchManagementController {
failed_records: 0,
});
if (!logResult.success || !logResult.data) {
throw new Error(
logResult.message || "배치 실행 로그를 생성할 수 없습니다."
);
}
executionLog = logResult.data;
// BatchSchedulerService의 executeBatchConfig 메서드 사용 (중복 로직 제거)
const { BatchSchedulerService } = await import(
"../services/batchSchedulerService"
@ -290,7 +302,7 @@ export class BatchManagementController {
const duration = endTime.getTime() - startTime.getTime();
// 실행 로그 업데이트 (성공)
await BatchService.updateExecutionLog(executionLog.id, {
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
execution_status: "SUCCESS",
end_time: endTime,
duration_ms: duration,
@ -406,22 +418,34 @@ export class BatchManagementController {
paramName,
paramValue,
paramSource,
requestBody,
} = req.body;
if (!apiUrl || !apiKey || !endpoint) {
// apiUrl, endpoint는 항상 필수
if (!apiUrl || !endpoint) {
return res.status(400).json({
success: false,
message: "API URL, API Key, 엔드포인트는 필수입니다.",
message: "API URL과 엔드포인트는 필수입니다.",
});
}
// GET 요청일 때만 API Key 필수 (POST/PUT/DELETE는 선택)
if ((!method || method === "GET") && !apiKey) {
return res.status(400).json({
success: false,
message: "GET 메서드에서는 API Key가 필요합니다.",
});
}
console.log("🔍 REST API 미리보기 요청:", {
apiUrl,
endpoint,
method,
paramType,
paramName,
paramValue,
paramSource,
requestBody: requestBody ? "Included" : "None",
});
// RestApiConnector 사용하여 데이터 조회
@ -429,7 +453,7 @@ export class BatchManagementController {
const connector = new RestApiConnector({
baseUrl: apiUrl,
apiKey: apiKey,
apiKey: apiKey || "",
timeout: 30000,
});
@ -456,9 +480,28 @@ export class BatchManagementController {
console.log("🔗 최종 엔드포인트:", finalEndpoint);
// 데이터 조회 (최대 5개만) - GET 메서드만 지원
const result = await connector.executeQuery(finalEndpoint, method);
console.log(`[previewRestApiData] executeQuery 결과:`, {
// Request Body 파싱
let parsedBody = undefined;
if (requestBody && typeof requestBody === "string") {
try {
parsedBody = JSON.parse(requestBody);
} catch (e) {
console.warn("Request Body JSON 파싱 실패:", e);
// 파싱 실패 시 원본 문자열 사용하거나 무시 (상황에 따라 결정, 여기선 undefined로 처리하거나 에러 반환 가능)
// 여기서는 경고 로그 남기고 진행
}
} else if (requestBody) {
parsedBody = requestBody;
}
// 데이터 조회 - executeRequest 사용 (POST/PUT/DELETE 지원)
const result = await connector.executeRequest(
finalEndpoint,
method as "GET" | "POST" | "PUT" | "DELETE",
parsedBody
);
console.log(`[previewRestApiData] executeRequest 결과:`, {
rowCount: result.rowCount,
rowsLength: result.rows ? result.rows.length : "undefined",
firstRow:
@ -532,15 +575,21 @@ export class BatchManagementController {
apiMappings,
});
// 🔐 멀티테넌시: 현재 사용자 회사 코드 사용 (프론트에서 받지 않음)
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId;
// BatchService를 사용하여 배치 설정 저장
const batchConfig: CreateBatchConfigRequest = {
batchName: batchName,
description: description || "",
cronSchedule: cronSchedule,
isActive: "Y",
companyCode,
mappings: apiMappings,
};
const result = await BatchService.createBatchConfig(batchConfig);
const result = await BatchService.createBatchConfig(batchConfig, userId);
if (result.success && result.data) {
// 스케줄러에 자동 등록 ✅

View File

@ -161,3 +161,4 @@ export const createMappingTemplate = async (

View File

@ -1,4 +1,5 @@
import axios, { AxiosInstance, AxiosResponse } from "axios";
import https from "https";
import {
DatabaseConnector,
ConnectionConfig,
@ -24,16 +25,26 @@ export class RestApiConnector implements DatabaseConnector {
constructor(config: RestApiConfig) {
this.config = config;
// Axios 인스턴스 생성
// 🔐 apiKey가 없을 수도 있으므로 Authorization 헤더는 선택적으로만 추가
const defaultHeaders: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json",
};
if (config.apiKey) {
defaultHeaders["Authorization"] = `Bearer ${config.apiKey}`;
}
this.httpClient = axios.create({
baseURL: config.baseUrl,
timeout: config.timeout || 30000,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.apiKey}`,
Accept: "application/json",
},
headers: defaultHeaders,
// ⚠️ 외부 API 중 자체 서명 인증서를 사용하는 경우가 있어서
// 인증서 검증을 끈 HTTPS 에이전트를 사용한다.
// 내부망/신뢰된 시스템 전용으로 사용해야 하며,
// 공개 인터넷용 API에는 적용하면 안 된다.
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
});
// 요청/응답 인터셉터 설정
@ -75,26 +86,16 @@ export class RestApiConnector implements DatabaseConnector {
}
async connect(): Promise<void> {
try {
// 연결 테스트 - 기본 엔드포인트 호출
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}`
);
return;
}
console.error(
`[RestApiConnector] 연결 실패: ${this.config.baseUrl}`,
error
);
throw new Error(
`REST API 연결 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`
);
}
// 기존에는 /health 엔드포인트를 호출해서 미리 연결을 검사했지만,
// 일반 외부 API들은 /health가 없거나 401/500을 반환하는 경우가 많아
// 불필요하게 예외가 나면서 미리보기/배치 실행이 막히는 문제가 있었다.
//
// 따라서 여기서는 "연결 준비 완료" 정도만 로그로 남기고
// 실제 호출 실패 여부는 executeRequest 단계에서만 판단하도록 한다.
console.log(
`[RestApiConnector] 연결 준비 완료 (사전 헬스체크 생략): ${this.config.baseUrl}`
);
return;
}
async disconnect(): Promise<void> {

View File

@ -213,7 +213,10 @@ router.post(
}
const result =
await ExternalRestApiConnectionService.testConnection(testRequest);
await ExternalRestApiConnectionService.testConnection(
testRequest,
req.user?.companyCode
);
return res.status(200).json(result);
} catch (error) {

View File

@ -170,3 +170,4 @@ export class DigitalTwinTemplateService {

View File

@ -130,13 +130,14 @@ export class BatchExecutionLogService {
try {
const log = await queryOne<BatchExecutionLog>(
`INSERT INTO batch_execution_logs (
batch_config_id, execution_status, start_time, end_time,
batch_config_id, company_code, execution_status, start_time, end_time,
duration_ms, total_records, success_records, failed_records,
error_message, error_details, server_name, process_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING *`,
[
data.batch_config_id,
data.company_code,
data.execution_status,
data.start_time || new Date(),
data.end_time,

File diff suppressed because it is too large Load Diff

View File

@ -1,258 +1,114 @@
// 배치 스케줄러 서비스
// 작성일: 2024-12-24
import * as cron from "node-cron";
import { query, queryOne } from "../database/db";
import cron from "node-cron";
import { BatchService } from "./batchService";
import { BatchExecutionLogService } from "./batchExecutionLogService";
import { logger } from "../utils/logger";
export class BatchSchedulerService {
private static scheduledTasks: Map<number, cron.ScheduledTask> = new Map();
private static isInitialized = false;
private static executingBatches: Set<number> = new Set(); // 실행 중인 배치 추적
/**
*
*
*/
static async initialize() {
static async initializeScheduler() {
try {
logger.info("배치 스케줄러 초기화 시작...");
logger.info("배치 스케줄러 초기화 시작");
// 기존 모든 스케줄 정리 (중복 방지)
this.clearAllSchedules();
const batchConfigsResponse = await BatchService.getBatchConfigs({
is_active: "Y",
});
// 활성화된 배치 설정들을 로드하여 스케줄 등록
await this.loadActiveBatchConfigs();
this.isInitialized = true;
logger.info("배치 스케줄러 초기화 완료");
} catch (error) {
logger.error("배치 스케줄러 초기화 실패:", error);
throw error;
}
}
/**
*
*/
private static clearAllSchedules() {
logger.info(`기존 스케줄 ${this.scheduledTasks.size}개 정리 중...`);
for (const [id, task] of this.scheduledTasks) {
try {
task.stop();
task.destroy();
logger.info(`스케줄 정리 완료: ID ${id}`);
} catch (error) {
logger.error(`스케줄 정리 실패: ID ${id}`, error);
}
}
this.scheduledTasks.clear();
this.isInitialized = false;
logger.info("모든 스케줄 정리 완료");
}
/**
*
*/
private static async loadActiveBatchConfigs() {
try {
const activeConfigs = await query<any>(
`SELECT
bc.*,
json_agg(
json_build_object(
'id', bm.id,
'batch_config_id', bm.batch_config_id,
'from_connection_type', bm.from_connection_type,
'from_connection_id', bm.from_connection_id,
'from_table_name', bm.from_table_name,
'from_column_name', bm.from_column_name,
'from_column_type', bm.from_column_type,
'to_connection_type', bm.to_connection_type,
'to_connection_id', bm.to_connection_id,
'to_table_name', bm.to_table_name,
'to_column_name', bm.to_column_name,
'to_column_type', bm.to_column_type,
'mapping_order', bm.mapping_order,
'from_api_url', bm.from_api_url,
'from_api_key', bm.from_api_key,
'from_api_method', bm.from_api_method,
'from_api_param_type', bm.from_api_param_type,
'from_api_param_name', bm.from_api_param_name,
'from_api_param_value', bm.from_api_param_value,
'from_api_param_source', bm.from_api_param_source,
'to_api_url', bm.to_api_url,
'to_api_key', bm.to_api_key,
'to_api_method', bm.to_api_method,
'to_api_body', bm.to_api_body
)
) FILTER (WHERE bm.id IS NOT NULL) as batch_mappings
FROM batch_configs bc
LEFT JOIN batch_mappings bm ON bc.id = bm.batch_config_id
WHERE bc.is_active = 'Y'
GROUP BY bc.id`,
[]
);
logger.info(`활성화된 배치 설정 ${activeConfigs.length}개 발견`);
for (const config of activeConfigs) {
await this.scheduleBatchConfig(config);
}
} catch (error) {
logger.error("활성화된 배치 설정 로드 실패:", error);
throw error;
}
}
/**
*
*/
static async scheduleBatchConfig(config: any) {
try {
const { id, batch_name, cron_schedule } = config;
// 기존 스케줄이 있다면 제거
if (this.scheduledTasks.has(id)) {
this.scheduledTasks.get(id)?.stop();
this.scheduledTasks.delete(id);
}
// cron 스케줄 유효성 검사
if (!cron.validate(cron_schedule)) {
logger.error(`잘못된 cron 스케줄: ${cron_schedule} (배치 ID: ${id})`);
if (!batchConfigsResponse.success || !batchConfigsResponse.data) {
logger.warn("스케줄링할 활성 배치 설정이 없습니다.");
return;
}
// 새로운 스케줄 등록
const task = cron.schedule(cron_schedule, async () => {
// 중복 실행 방지 체크
if (this.executingBatches.has(id)) {
logger.warn(
`⚠️ 배치가 이미 실행 중입니다. 건너뜀: ${batch_name} (ID: ${id})`
);
return;
}
const batchConfigs = batchConfigsResponse.data;
logger.info(`${batchConfigs.length}개의 배치 설정 스케줄링 등록`);
logger.info(`🔄 스케줄 배치 실행 시작: ${batch_name} (ID: ${id})`);
for (const config of batchConfigs) {
await this.scheduleBatch(config);
}
// 실행 중 플래그 설정
this.executingBatches.add(id);
logger.info("배치 스케줄러 초기화 완료");
} catch (error) {
logger.error("배치 스케줄러 초기화 중 오류 발생:", error);
}
}
try {
await this.executeBatchConfig(config);
} finally {
// 실행 완료 후 플래그 제거
this.executingBatches.delete(id);
}
/**
*
*/
static async scheduleBatch(config: any) {
try {
// 기존 스케줄이 있으면 제거
if (this.scheduledTasks.has(config.id)) {
this.scheduledTasks.get(config.id)?.stop();
this.scheduledTasks.delete(config.id);
}
if (config.is_active !== "Y") {
logger.info(
`배치 스케줄링 건너뜀 (비활성 상태): ${config.batch_name} (ID: ${config.id})`
);
return;
}
if (!cron.validate(config.cron_schedule)) {
logger.error(
`유효하지 않은 Cron 표현식: ${config.cron_schedule} (Batch ID: ${config.id})`
);
return;
}
logger.info(
`배치 스케줄 등록: ${config.batch_name} (ID: ${config.id}, Cron: ${config.cron_schedule})`
);
const task = cron.schedule(config.cron_schedule, async () => {
logger.info(
`스케줄에 의한 배치 실행 시작: ${config.batch_name} (ID: ${config.id})`
);
await this.executeBatchConfig(config);
});
// 스케줄 시작 (기본적으로 시작되지만 명시적으로 호출)
task.start();
this.scheduledTasks.set(id, task);
logger.info(
`배치 스케줄 등록 완료: ${batch_name} (ID: ${id}, Schedule: ${cron_schedule}) - 스케줄 시작됨`
);
this.scheduledTasks.set(config.id, task);
} catch (error) {
logger.error(`배치 스케줄 등록 실패 (ID: ${config.id}):`, error);
logger.error(`배치 스케줄링 중 오류 발생 (ID: ${config.id}):`, error);
}
}
/**
*
*/
static async unscheduleBatchConfig(batchConfigId: number) {
try {
if (this.scheduledTasks.has(batchConfigId)) {
this.scheduledTasks.get(batchConfigId)?.stop();
this.scheduledTasks.delete(batchConfigId);
logger.info(`배치 스케줄 제거 완료 (ID: ${batchConfigId})`);
}
} catch (error) {
logger.error(`배치 스케줄 제거 실패 (ID: ${batchConfigId}):`, error);
}
}
/**
*
* ( )
*/
static async updateBatchSchedule(
configId: number,
executeImmediately: boolean = true
) {
try {
// 기존 스케줄 제거
await this.unscheduleBatchConfig(configId);
// 업데이트된 배치 설정 조회
const configResult = await query<any>(
`SELECT
bc.*,
json_agg(
json_build_object(
'id', bm.id,
'batch_config_id', bm.batch_config_id,
'from_connection_type', bm.from_connection_type,
'from_connection_id', bm.from_connection_id,
'from_table_name', bm.from_table_name,
'from_column_name', bm.from_column_name,
'from_column_type', bm.from_column_type,
'to_connection_type', bm.to_connection_type,
'to_connection_id', bm.to_connection_id,
'to_table_name', bm.to_table_name,
'to_column_name', bm.to_column_name,
'to_column_type', bm.to_column_type,
'mapping_order', bm.mapping_order,
'from_api_url', bm.from_api_url,
'from_api_key', bm.from_api_key,
'from_api_method', bm.from_api_method,
'from_api_param_type', bm.from_api_param_type,
'from_api_param_name', bm.from_api_param_name,
'from_api_param_value', bm.from_api_param_value,
'from_api_param_source', bm.from_api_param_source,
'to_api_url', bm.to_api_url,
'to_api_key', bm.to_api_key,
'to_api_method', bm.to_api_method,
'to_api_body', bm.to_api_body
)
) FILTER (WHERE bm.id IS NOT NULL) as batch_mappings
FROM batch_configs bc
LEFT JOIN batch_mappings bm ON bc.id = bm.batch_config_id
WHERE bc.id = $1
GROUP BY bc.id`,
[configId]
);
const config = configResult[0] || null;
if (!config) {
logger.warn(`배치 설정을 찾을 수 없습니다: ID ${configId}`);
const result = await BatchService.getBatchConfigById(configId);
if (!result.success || !result.data) {
// 설정이 없으면 스케줄 제거
if (this.scheduledTasks.has(configId)) {
this.scheduledTasks.get(configId)?.stop();
this.scheduledTasks.delete(configId);
}
return;
}
// 활성화된 배치만 다시 스케줄 등록
if (config.is_active === "Y") {
await this.scheduleBatchConfig(config);
logger.info(
`배치 스케줄 업데이트 완료: ${config.batch_name} (ID: ${configId})`
);
const config = result.data;
// 활성화 시 즉시 실행 (옵션)
if (executeImmediately) {
logger.info(
`🚀 배치 활성화 즉시 실행: ${config.batch_name} (ID: ${configId})`
);
await this.executeBatchConfig(config);
}
} else {
logger.info(
`비활성화된 배치 스케줄 제거: ${config.batch_name} (ID: ${configId})`
// 스케줄 재등록
await this.scheduleBatch(config);
// 즉시 실행 옵션이 있으면 실행
/*
if (executeImmediately && config.is_active === "Y") {
logger.info(`배치 설정 변경 후 즉시 실행: ${config.batch_name}`);
this.executeBatchConfig(config).catch((err) =>
logger.error(`즉시 실행 중 오류 발생:`, err)
);
}
*/
} catch (error) {
logger.error(`배치 스케줄 업데이트 실패: ID ${configId}`, error);
}
@ -272,6 +128,7 @@ export class BatchSchedulerService {
const executionLogResponse =
await BatchExecutionLogService.createExecutionLog({
batch_config_id: config.id,
company_code: config.company_code,
execution_status: "RUNNING",
start_time: startTime,
total_records: 0,
@ -313,21 +170,20 @@ export class BatchSchedulerService {
// 성공 결과 반환
return result;
} catch (error) {
logger.error(`배치 실행 실패: ${config.batch_name}`, error);
logger.error(`배치 실행 중 오류 발생: ${config.batch_name}`, error);
// 실행 로그 업데이트 (실패)
if (executionLog) {
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
execution_status: "FAILED",
execution_status: "FAILURE",
end_time: new Date(),
duration_ms: Date.now() - startTime.getTime(),
error_message:
error instanceof Error ? error.message : "알 수 없는 오류",
error_details: error instanceof Error ? error.stack : String(error),
});
}
// 실패 시에도 결과 반환
// 실패 결과 반환
return {
totalRecords: 0,
successRecords: 0,
@ -379,6 +235,8 @@ export class BatchSchedulerService {
const { BatchExternalDbService } = await import(
"./batchExternalDbService"
);
// 👇 Body 파라미터 추가 (POST 요청 시)
const apiResult = await BatchExternalDbService.getDataFromRestApi(
firstMapping.from_api_url!,
firstMapping.from_api_key!,
@ -394,7 +252,9 @@ export class BatchSchedulerService {
firstMapping.from_api_param_type,
firstMapping.from_api_param_name,
firstMapping.from_api_param_value,
firstMapping.from_api_param_source
firstMapping.from_api_param_source,
// 👇 Body 전달 (FROM - REST API - POST 요청)
firstMapping.from_api_body
);
if (apiResult.success && apiResult.data) {
@ -416,6 +276,17 @@ export class BatchSchedulerService {
totalRecords += fromData.length;
// 컬럼 매핑 적용하여 TO 테이블 형식으로 변환
// 유틸리티 함수: 점 표기법을 사용하여 중첩된 객체 값 가져오기
const getValueByPath = (obj: any, path: string) => {
if (!path) return undefined;
// path가 'response.access_token' 처럼 점을 포함하는 경우
if (path.includes(".")) {
return path.split(".").reduce((acc, part) => acc && acc[part], obj);
}
// 단순 키인 경우
return obj[path];
};
const mappedData = fromData.map((row) => {
const mappedRow: any = {};
for (const mapping of mappings) {
@ -428,10 +299,25 @@ export class BatchSchedulerService {
mappedRow[mapping.from_column_name] =
row[mapping.from_column_name];
} else {
// 기존 로직: to_column_name을 키로 사용
mappedRow[mapping.to_column_name] = row[mapping.from_column_name];
// REST API -> DB (POST 요청 포함) 또는 DB -> DB
// row[mapping.from_column_name] 대신 getValueByPath 사용
const value = getValueByPath(row, mapping.from_column_name);
mappedRow[mapping.to_column_name] = value;
}
}
// 멀티테넌시: TO가 DB일 때 company_code 자동 주입
// - 배치 설정에 company_code가 있고
// - 매핑에서 company_code를 명시적으로 다루지 않은 경우만
if (
firstMapping.to_connection_type !== "restapi" &&
config.company_code &&
mappedRow.company_code === undefined
) {
mappedRow.company_code = config.company_code;
}
return mappedRow;
});
@ -482,22 +368,12 @@ export class BatchSchedulerService {
);
}
} else {
// 기존 REST API 전송 (REST API → DB 배치)
const apiResult = await BatchExternalDbService.sendDataToRestApi(
firstMapping.to_api_url!,
firstMapping.to_api_key!,
firstMapping.to_table_name,
(firstMapping.to_api_method as "POST" | "PUT") || "POST",
mappedData
// 기존 REST API 전송 (REST API → DB 배치) - 사실 이 경우는 거의 없음 (REST to REST)
// 지원하지 않음
logger.warn(
"REST API -> REST API (단순 매핑)은 아직 지원하지 않습니다."
);
if (apiResult.success && apiResult.data) {
insertResult = apiResult.data;
} else {
throw new Error(
`REST API 데이터 전송 실패: ${apiResult.message}`
);
}
insertResult = { successCount: 0, failedCount: 0 };
}
} else {
// DB에 데이터 삽입
@ -511,167 +387,13 @@ export class BatchSchedulerService {
successRecords += insertResult.successCount;
failedRecords += insertResult.failedCount;
logger.info(
`테이블 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패`
);
} catch (error) {
logger.error(`테이블 처리 실패: ${tableKey}`, error);
failedRecords += 1;
logger.error(`테이블 처리 중 오류 발생: ${tableKey}`, error);
// 해당 테이블 처리 실패는 전체 실패로 간주하지 않고, 실패 카운트만 증가?
// 여기서는 일단 실패 로그만 남기고 계속 진행 (필요시 정책 변경)
}
}
return { totalRecords, successRecords, failedRecords };
}
/**
* ( - )
*/
private static async processBatchMappings(config: any) {
const { batch_mappings } = config;
let totalRecords = 0;
let successRecords = 0;
let failedRecords = 0;
if (!batch_mappings || batch_mappings.length === 0) {
logger.warn(`배치 매핑이 없습니다: ${config.batch_name}`);
return { totalRecords, successRecords, failedRecords };
}
for (const mapping of batch_mappings) {
try {
logger.info(
`매핑 처리 시작: ${mapping.from_table_name} -> ${mapping.to_table_name}`
);
// FROM 테이블에서 데이터 조회
const fromData = await this.getDataFromSource(mapping);
totalRecords += fromData.length;
// TO 테이블에 데이터 삽입
const insertResult = await this.insertDataToTarget(mapping, fromData);
successRecords += insertResult.successCount;
failedRecords += insertResult.failedCount;
logger.info(
`매핑 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패`
);
} catch (error) {
logger.error(
`매핑 처리 실패: ${mapping.from_table_name} -> ${mapping.to_table_name}`,
error
);
failedRecords += 1;
}
}
return { totalRecords, successRecords, failedRecords };
}
/**
* FROM
*/
private static async getDataFromSource(mapping: any) {
try {
if (mapping.from_connection_type === "internal") {
// 내부 DB에서 조회
const result = await query<any>(
`SELECT * FROM ${mapping.from_table_name}`,
[]
);
return result;
} else {
// 외부 DB에서 조회 (구현 필요)
logger.warn("외부 DB 조회는 아직 구현되지 않았습니다.");
return [];
}
} catch (error) {
logger.error(
`FROM 테이블 데이터 조회 실패: ${mapping.from_table_name}`,
error
);
throw error;
}
}
/**
* TO
*/
private static async insertDataToTarget(mapping: any, data: any[]) {
let successCount = 0;
let failedCount = 0;
try {
if (mapping.to_connection_type === "internal") {
// 내부 DB에 삽입
for (const record of data) {
try {
// 매핑된 컬럼만 추출
const mappedData = this.mapColumns(record, mapping);
const columns = Object.keys(mappedData);
const values = Object.values(mappedData);
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
await query(
`INSERT INTO ${mapping.to_table_name} (${columns.join(", ")}) VALUES (${placeholders})`,
values
);
successCount++;
} catch (error) {
logger.error(`레코드 삽입 실패:`, error);
failedCount++;
}
}
} else {
// 외부 DB에 삽입 (구현 필요)
logger.warn("외부 DB 삽입은 아직 구현되지 않았습니다.");
failedCount = data.length;
}
} catch (error) {
logger.error(
`TO 테이블 데이터 삽입 실패: ${mapping.to_table_name}`,
error
);
throw error;
}
return { successCount, failedCount };
}
/**
*
*/
private static mapColumns(record: any, mapping: any) {
const mappedData: any = {};
// 단순한 컬럼 매핑 (실제로는 더 복잡한 로직 필요)
mappedData[mapping.to_column_name] = record[mapping.from_column_name];
return mappedData;
}
/**
*
*/
static async stopAllSchedules() {
try {
for (const [id, task] of this.scheduledTasks) {
task.stop();
logger.info(`배치 스케줄 중지: ID ${id}`);
}
this.scheduledTasks.clear();
this.isInitialized = false;
logger.info("모든 배치 스케줄이 중지되었습니다.");
} catch (error) {
logger.error("배치 스케줄 중지 실패:", error);
}
}
/**
*
*/
static getScheduledTasks() {
return Array.from(this.scheduledTasks.keys());
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,6 @@
import { Pool, QueryResult } from "pg";
import axios, { AxiosResponse } from "axios";
import https from "https";
import { getPool } from "../database/db";
import logger from "../utils/logger";
import {
@ -30,6 +32,10 @@ export class ExternalRestApiConnectionService {
let query = `
SELECT
id, connection_name, description, base_url, endpoint_path, default_headers,
default_method,
-- DB default_request_body
-- default_body alias
default_request_body AS default_body,
auth_type, auth_config, timeout, retry_count, retry_delay,
company_code, is_active, created_date, created_by,
updated_date, updated_by, last_test_date, last_test_result, last_test_message
@ -129,6 +135,8 @@ export class ExternalRestApiConnectionService {
let query = `
SELECT
id, connection_name, description, base_url, endpoint_path, default_headers,
default_method,
default_request_body AS default_body,
auth_type, auth_config, timeout, retry_count, retry_delay,
company_code, is_active, created_date, created_by,
updated_date, updated_by, last_test_date, last_test_result, last_test_message
@ -194,9 +202,10 @@ export class ExternalRestApiConnectionService {
const query = `
INSERT INTO external_rest_api_connections (
connection_name, description, base_url, endpoint_path, default_headers,
default_method, default_request_body,
auth_type, auth_config, timeout, retry_count, retry_delay,
company_code, is_active, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
RETURNING *
`;
@ -206,6 +215,8 @@ export class ExternalRestApiConnectionService {
data.base_url,
data.endpoint_path || null,
JSON.stringify(data.default_headers || {}),
data.default_method || "GET",
data.default_body || null,
data.auth_type,
encryptedAuthConfig ? JSON.stringify(encryptedAuthConfig) : null,
data.timeout || 30000,
@ -301,6 +312,18 @@ export class ExternalRestApiConnectionService {
paramIndex++;
}
if (data.default_method !== undefined) {
updateFields.push(`default_method = $${paramIndex}`);
params.push(data.default_method);
paramIndex++;
}
if (data.default_body !== undefined) {
updateFields.push(`default_request_body = $${paramIndex}`);
params.push(data.default_body);
paramIndex++;
}
if (data.auth_type !== undefined) {
updateFields.push(`auth_type = $${paramIndex}`);
params.push(data.auth_type);
@ -441,7 +464,8 @@ export class ExternalRestApiConnectionService {
* REST API ( )
*/
static async testConnection(
testRequest: RestApiTestRequest
testRequest: RestApiTestRequest,
userCompanyCode?: string
): Promise<RestApiTestResult> {
const startTime = Date.now();
@ -450,7 +474,78 @@ export class ExternalRestApiConnectionService {
const headers = { ...testRequest.headers };
// 인증 헤더 추가
if (
if (testRequest.auth_type === "db-token") {
const cfg = testRequest.auth_config || {};
const {
dbTableName,
dbValueColumn,
dbWhereColumn,
dbWhereValue,
dbHeaderName,
dbHeaderTemplate,
} = cfg;
if (!dbTableName || !dbValueColumn) {
throw new Error("DB 토큰 설정이 올바르지 않습니다.");
}
if (!userCompanyCode) {
throw new Error("DB 토큰 모드에서는 회사 코드가 필요합니다.");
}
const hasWhereColumn = !!dbWhereColumn;
const hasWhereValue =
dbWhereValue !== undefined && dbWhereValue !== null && dbWhereValue !== "";
// where 컬럼/값은 둘 다 비우거나 둘 다 채워야 함
if (hasWhereColumn !== hasWhereValue) {
throw new Error(
"DB 토큰 설정에서 조건 컬럼과 조건 값은 둘 다 비우거나 둘 다 입력해야 합니다."
);
}
// 식별자 검증 (간단한 화이트리스트)
const identifierRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
if (
!identifierRegex.test(dbTableName) ||
!identifierRegex.test(dbValueColumn) ||
(hasWhereColumn && !identifierRegex.test(dbWhereColumn as string))
) {
throw new Error(
"DB 토큰 설정에 유효하지 않은 테이블 또는 컬럼명이 포함되어 있습니다."
);
}
let sql = `
SELECT ${dbValueColumn} AS token_value
FROM ${dbTableName}
WHERE company_code = $1
`;
const params: any[] = [userCompanyCode];
if (hasWhereColumn && hasWhereValue) {
sql += ` AND ${dbWhereColumn} = $2`;
params.push(dbWhereValue);
}
sql += `
ORDER BY updated_date DESC
LIMIT 1
`;
const tokenResult: QueryResult<any> = await pool.query(sql, params);
if (tokenResult.rowCount === 0) {
throw new Error("DB에서 토큰을 찾을 수 없습니다.");
}
const tokenValue = tokenResult.rows[0]["token_value"];
const headerName = dbHeaderName || "Authorization";
const template = dbHeaderTemplate || "Bearer {{value}}";
headers[headerName] = template.replace("{{value}}", tokenValue);
} else if (
testRequest.auth_type === "bearer" &&
testRequest.auth_config?.token
) {
@ -493,25 +588,84 @@ export class ExternalRestApiConnectionService {
`REST API 연결 테스트: ${testRequest.method || "GET"} ${url}`
);
// HTTP 요청 실행
const response = await fetch(url, {
method: testRequest.method || "GET",
headers,
signal: AbortSignal.timeout(testRequest.timeout || 30000),
});
// Body 처리
let body: any = undefined;
if (testRequest.body) {
// 이미 문자열이면 그대로, 객체면 JSON 문자열로 변환
if (typeof testRequest.body === "string") {
body = testRequest.body;
} else {
body = JSON.stringify(testRequest.body);
}
const responseTime = Date.now() - startTime;
let responseData = null;
try {
responseData = await response.json();
} catch {
// JSON 파싱 실패는 무시 (텍스트 응답일 수 있음)
// Content-Type 헤더가 없으면 기본적으로 application/json 추가
const hasContentType = Object.keys(headers).some(
(k) => k.toLowerCase() === "content-type"
);
if (!hasContentType) {
headers["Content-Type"] = "application/json";
}
}
// HTTP 요청 실행
// [인수인계 중요] 2024-11-27 추가
// 특정 레거시/내부망 API(예: thiratis.com)의 경우 SSL 인증서 체인 문제로 인해
// Node.js 레벨에서 검증 실패(UNABLE_TO_VERIFY_LEAF_SIGNATURE)가 발생합니다.
//
// 원래는 인프라(OS/Docker)에 루트 CA를 등록하는 것이 정석이나,
// 유지보수 및 설정 편의성을 위해 코드 레벨에서 '특정 도메인'에 한해서만
// SSL 검증을 우회하도록 예외 처리를 해두었습니다.
//
// ※ 보안 주의: 여기에 모르는 도메인을 함부로 추가하면 중간자 공격(MITM)에 취약해질 수 있습니다.
// 꼭 필요한 신뢰할 수 있는 도메인만 추가하세요.
const bypassDomains = ["thiratis.com"];
const shouldBypassTls = bypassDomains.some((domain) =>
url.includes(domain)
);
const httpsAgent = new https.Agent({
// bypassDomains에 포함된 URL이면 검증을 무시(false), 아니면 정상 검증(true)
rejectUnauthorized: !shouldBypassTls,
});
const requestConfig = {
url,
method: (testRequest.method || "GET") as any,
headers,
data: body,
httpsAgent,
timeout: testRequest.timeout || 30000,
// 4xx/5xx 도 예외가 아니라 응답 객체로 처리
validateStatus: () => true,
};
// 요청 상세 로그 (민감 정보는 최소화)
logger.info(
`REST API 연결 테스트 요청 상세: ${JSON.stringify({
method: requestConfig.method,
url: requestConfig.url,
headers: {
...requestConfig.headers,
// Authorization 헤더는 마스킹
Authorization: requestConfig.headers?.Authorization
? "***masked***"
: undefined,
},
hasBody: !!body,
})}`
);
const response: AxiosResponse = await axios.request(requestConfig);
const responseTime = Date.now() - startTime;
// axios는 response.data에 이미 파싱된 응답 본문을 담아준다.
// JSON이 아니어도 그대로 내려보내서 프론트에서 확인할 수 있게 한다.
const responseData = response.data ?? null;
return {
success: response.ok,
message: response.ok
success: response.status >= 200 && response.status < 300,
message:
response.status >= 200 && response.status < 300
? "연결 성공"
: `연결 실패 (${response.status} ${response.statusText})`,
response_time: responseTime,
@ -552,17 +706,27 @@ export class ExternalRestApiConnectionService {
const connection = connectionResult.data;
// 리스트에서 endpoint를 넘기지 않으면,
// 저장된 endpoint_path를 기본 엔드포인트로 사용
const effectiveEndpoint =
endpoint || connection.endpoint_path || undefined;
const testRequest: RestApiTestRequest = {
id: connection.id,
base_url: connection.base_url,
endpoint,
endpoint: effectiveEndpoint,
method: (connection.default_method as any) || "GET", // 기본 메서드 적용
headers: connection.default_headers,
body: connection.default_body, // 기본 바디 적용
auth_type: connection.auth_type,
auth_config: connection.auth_config,
timeout: connection.timeout,
};
const result = await this.testConnection(testRequest);
const result = await this.testConnection(
testRequest,
connection.company_code
);
// 테스트 결과 저장
await pool.query(
@ -580,11 +744,34 @@ export class ExternalRestApiConnectionService {
return result;
} catch (error) {
logger.error("REST API 연결 테스트 (ID) 오류:", error);
const errorMessage =
error instanceof Error ? error.message : "알 수 없는 오류";
// 예외가 발생한 경우에도 마지막 테스트 결과를 실패로 기록
try {
await pool.query(
`
UPDATE external_rest_api_connections
SET
last_test_date = NOW(),
last_test_result = $1,
last_test_message = $2
WHERE id = $3
`,
["N", errorMessage, id]
);
} catch (updateError) {
logger.error(
"REST API 연결 테스트 (ID) 오류 기록 실패:",
updateError
);
}
return {
success: false,
message: "연결 테스트에 실패했습니다.",
error_details:
error instanceof Error ? error.message : "알 수 없는 오류",
error_details: errorMessage,
};
}
}
@ -709,6 +896,7 @@ export class ExternalRestApiConnectionService {
"bearer",
"basic",
"oauth2",
"db-token",
];
if (!validAuthTypes.includes(data.auth_type)) {
throw new Error("올바르지 않은 인증 타입입니다.");

View File

@ -4,6 +4,7 @@
export interface BatchExecutionLog {
id?: number;
batch_config_id: number;
company_code?: string;
execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED';
start_time: Date;
end_time?: Date | null;
@ -19,6 +20,7 @@ export interface BatchExecutionLog {
export interface CreateBatchExecutionLogRequest {
batch_config_id: number;
company_code?: string;
execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED';
start_time?: Date;
end_time?: Date | null;

View File

@ -1,86 +1,13 @@
// 배치관리 타입 정의
// 작성일: 2024-12-24
import { ApiResponse, ColumnInfo } from './batchTypes';
// 배치 타입 정의
export type BatchType = 'db-to-db' | 'db-to-restapi' | 'restapi-to-db' | 'restapi-to-restapi';
export interface BatchTypeOption {
value: BatchType;
label: string;
description: string;
}
export interface BatchConfig {
id?: number;
batch_name: string;
description?: string;
cron_schedule: string;
is_active?: string;
company_code?: string;
created_date?: Date;
created_by?: string;
updated_date?: Date;
updated_by?: string;
batch_mappings?: BatchMapping[];
}
export interface BatchMapping {
id?: number;
batch_config_id?: number;
// FROM 정보
from_connection_type: 'internal' | 'external' | 'restapi';
from_connection_id?: number;
from_table_name: string; // DB: 테이블명, REST API: 엔드포인트
from_column_name: string; // DB: 컬럼명, REST API: JSON 필드명
from_column_type?: string;
from_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; // REST API 전용
from_api_url?: string; // REST API 서버 URL
from_api_key?: string; // REST API 키
from_api_param_type?: 'url' | 'query'; // API 파라미터 타입
from_api_param_name?: string; // API 파라미터명
from_api_param_value?: string; // API 파라미터 값 또는 템플릿
from_api_param_source?: 'static' | 'dynamic'; // 파라미터 소스 타입
// TO 정보
to_connection_type: 'internal' | 'external' | 'restapi';
to_connection_id?: number;
to_table_name: string; // DB: 테이블명, REST API: 엔드포인트
to_column_name: string; // DB: 컬럼명, REST API: JSON 필드명
to_column_type?: string;
to_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; // REST API 전용
to_api_url?: string; // REST API 서버 URL
to_api_key?: string; // REST API 키
to_api_body?: string; // Request Body 템플릿 (DB → REST API 배치용)
mapping_order?: number;
created_date?: Date;
created_by?: string;
}
export interface BatchConfigFilter {
page?: number;
limit?: number;
batch_name?: string;
is_active?: string;
company_code?: string;
search?: string;
}
export interface ConnectionInfo {
export interface BatchConnectionInfo {
type: 'internal' | 'external';
id?: number;
name: string;
db_type?: string;
}
export interface TableInfo {
table_name: string;
columns: ColumnInfo[];
description?: string | null;
}
export interface ColumnInfo {
export interface BatchColumnInfo {
column_name: string;
data_type: string;
is_nullable?: string;
@ -100,6 +27,8 @@ export interface BatchMappingRequest {
from_api_param_name?: string; // API 파라미터명
from_api_param_value?: string; // API 파라미터 값 또는 템플릿
from_api_param_source?: 'static' | 'dynamic'; // 파라미터 소스 타입
// 👇 REST API Body 추가 (FROM - REST API에서 POST 요청 시 필요)
from_api_body?: string;
to_connection_type: 'internal' | 'external' | 'restapi';
to_connection_id?: number;
to_table_name: string;
@ -116,6 +45,8 @@ export interface CreateBatchConfigRequest {
batchName: string;
description?: string;
cronSchedule: string;
isActive: 'Y' | 'N';
companyCode: string;
mappings: BatchMappingRequest[];
}
@ -123,25 +54,11 @@ export interface UpdateBatchConfigRequest {
batchName?: string;
description?: string;
cronSchedule?: string;
isActive?: 'Y' | 'N';
mappings?: BatchMappingRequest[];
isActive?: string;
}
export interface BatchValidationResult {
isValid: boolean;
errors: string[];
warnings?: string[];
}
export interface ApiResponse<T> {
success: boolean;
data?: T;
message?: string;
error?: string;
pagination?: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}

View File

@ -1,6 +1,12 @@
// 외부 REST API 연결 관리 타입 정의
export type AuthType = "none" | "api-key" | "bearer" | "basic" | "oauth2";
export type AuthType =
| "none"
| "api-key"
| "bearer"
| "basic"
| "oauth2"
| "db-token";
export interface ExternalRestApiConnection {
id?: number;
@ -9,6 +15,11 @@ export interface ExternalRestApiConnection {
base_url: string;
endpoint_path?: string;
default_headers: Record<string, string>;
// 기본 메서드 및 바디 추가
default_method?: string;
default_body?: string;
auth_type: AuthType;
auth_config?: {
// API Key
@ -28,6 +39,14 @@ export interface ExternalRestApiConnection {
clientSecret?: string;
tokenUrl?: string;
accessToken?: string;
// DB 기반 토큰 모드
dbTableName?: string;
dbValueColumn?: string;
dbWhereColumn?: string;
dbWhereValue?: string;
dbHeaderName?: string;
dbHeaderTemplate?: string;
};
timeout?: number;
retry_count?: number;
@ -54,8 +73,9 @@ export interface RestApiTestRequest {
id?: number;
base_url: string;
endpoint?: string;
method?: "GET" | "POST" | "PUT" | "DELETE";
method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
headers?: Record<string, string>;
body?: any; // 테스트 요청 바디 추가
auth_type?: AuthType;
auth_config?: any;
timeout?: number;
@ -76,4 +96,5 @@ export const AUTH_TYPE_OPTIONS = [
{ value: "bearer", label: "Bearer Token" },
{ value: "basic", label: "Basic Auth" },
{ value: "oauth2", label: "OAuth 2.0" },
{ value: "db-token", label: "DB 토큰" },
];

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useMemo, memo } from "react";
import { useRouter } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@ -33,6 +33,31 @@ interface BatchColumnInfo {
is_nullable: string;
}
interface RestApiToDbMappingCardProps {
fromApiFields: string[];
toColumns: BatchColumnInfo[];
fromApiData: any[];
apiFieldMappings: Record<string, string>;
setApiFieldMappings: React.Dispatch<
React.SetStateAction<Record<string, string>>
>;
apiFieldPathOverrides: Record<string, string>;
setApiFieldPathOverrides: React.Dispatch<
React.SetStateAction<Record<string, string>>
>;
}
interface DbToRestApiMappingCardProps {
fromColumns: BatchColumnInfo[];
selectedColumns: string[];
toApiFields: string[];
dbToApiFieldMapping: Record<string, string>;
setDbToApiFieldMapping: React.Dispatch<
React.SetStateAction<Record<string, string>>
>;
setToApiBody: (body: string) => void;
}
export default function BatchManagementNewPage() {
const router = useRouter();
@ -52,7 +77,8 @@ export default function BatchManagementNewPage() {
const [fromApiUrl, setFromApiUrl] = useState("");
const [fromApiKey, setFromApiKey] = useState("");
const [fromEndpoint, setFromEndpoint] = useState("");
const [fromApiMethod, setFromApiMethod] = useState<'GET'>('GET'); // GET만 지원
const [fromApiMethod, setFromApiMethod] = useState<'GET' | 'POST' | 'PUT' | 'DELETE'>('GET');
const [fromApiBody, setFromApiBody] = useState(""); // Request Body (JSON)
// REST API 파라미터 설정
const [apiParamType, setApiParamType] = useState<'none' | 'url' | 'query'>('none');
@ -83,6 +109,8 @@ export default function BatchManagementNewPage() {
// API 필드 → DB 컬럼 매핑
const [apiFieldMappings, setApiFieldMappings] = useState<Record<string, string>>({});
// API 필드별 JSON 경로 오버라이드 (예: "response.access_token")
const [apiFieldPathOverrides, setApiFieldPathOverrides] = useState<Record<string, string>>({});
// 배치 타입 상태
const [batchType, setBatchType] = useState<BatchType>('restapi-to-db');
@ -182,24 +210,17 @@ export default function BatchManagementNewPage() {
// TO 테이블 변경 핸들러
const handleToTableChange = async (tableName: string) => {
console.log("🔍 테이블 변경:", { tableName, toConnection });
setToTable(tableName);
setToColumns([]);
if (toConnection && tableName) {
try {
const connectionType = toConnection.type === 'internal' ? 'internal' : 'external';
console.log("🔍 컬럼 조회 시작:", { connectionType, connectionId: toConnection.id, tableName });
const result = await BatchManagementAPI.getTableColumns(connectionType, tableName, toConnection.id);
console.log("🔍 컬럼 조회 결과:", result);
if (result && result.length > 0) {
setToColumns(result);
console.log("✅ 컬럼 설정 완료:", result.length, "개");
} else {
setToColumns([]);
console.log("⚠️ 컬럼이 없음");
}
} catch (error) {
console.error("❌ 컬럼 목록 로드 오류:", error);
@ -239,7 +260,6 @@ export default function BatchManagementNewPage() {
// FROM 테이블 변경 핸들러 (DB → REST API용)
const handleFromTableChange = async (tableName: string) => {
console.log("🔍 FROM 테이블 변경:", { tableName, fromConnection });
setFromTable(tableName);
setFromColumns([]);
setSelectedColumns([]); // 선택된 컬럼도 초기화
@ -248,17 +268,11 @@ export default function BatchManagementNewPage() {
if (fromConnection && tableName) {
try {
const connectionType = fromConnection.type === 'internal' ? 'internal' : 'external';
console.log("🔍 FROM 컬럼 조회 시작:", { connectionType, connectionId: fromConnection.id, tableName });
const result = await BatchManagementAPI.getTableColumns(connectionType, tableName, fromConnection.id);
console.log("🔍 FROM 컬럼 조회 결과:", result);
if (result && result.length > 0) {
setFromColumns(result);
console.log("✅ FROM 컬럼 설정 완료:", result.length, "개");
} else {
setFromColumns([]);
console.log("⚠️ FROM 컬럼이 없음");
}
} catch (error) {
console.error("❌ FROM 컬럼 목록 로드 오류:", error);
@ -276,8 +290,6 @@ export default function BatchManagementNewPage() {
}
try {
console.log("🔍 TO API 미리보기 시작:", { toApiUrl, toApiKey, toEndpoint, toApiMethod });
const result = await BatchManagementAPI.previewRestApiData(
toApiUrl,
toApiKey,
@ -285,8 +297,6 @@ export default function BatchManagementNewPage() {
'GET' // 미리보기는 항상 GET으로
);
console.log("🔍 TO API 미리보기 결과:", result);
if (result.fields && result.fields.length > 0) {
setToApiFields(result.fields);
toast.success(`TO API 필드 ${result.fields.length}개를 조회했습니다.`);
@ -303,17 +313,22 @@ export default function BatchManagementNewPage() {
// REST API 데이터 미리보기
const previewRestApiData = async () => {
if (!fromApiUrl || !fromApiKey || !fromEndpoint) {
toast.error("API URL, API Key, 엔드포인트를 모두 입력해주세요.");
// API URL, 엔드포인트는 항상 필수
if (!fromApiUrl || !fromEndpoint) {
toast.error("API URL과 엔드포인트를 모두 입력해주세요.");
return;
}
// GET 메서드일 때만 API 키 필수
if (fromApiMethod === "GET" && !fromApiKey) {
toast.error("GET 메서드에서는 API 키를 입력해주세요.");
return;
}
try {
console.log("REST API 데이터 미리보기 시작...");
const result = await BatchManagementAPI.previewRestApiData(
fromApiUrl,
fromApiKey,
fromApiKey || "",
fromEndpoint,
fromApiMethod,
// 파라미터 정보 추가
@ -322,33 +337,23 @@ export default function BatchManagementNewPage() {
paramName: apiParamName,
paramValue: apiParamValue,
paramSource: apiParamSource
} : undefined
} : undefined,
// Request Body 추가 (POST/PUT/DELETE)
(fromApiMethod === 'POST' || fromApiMethod === 'PUT' || fromApiMethod === 'DELETE') ? fromApiBody : undefined
);
console.log("API 미리보기 결과:", result);
console.log("result.fields:", result.fields);
console.log("result.samples:", result.samples);
console.log("result.totalCount:", result.totalCount);
if (result.fields && result.fields.length > 0) {
console.log("✅ 백엔드에서 fields 제공됨:", result.fields);
setFromApiFields(result.fields);
setFromApiData(result.samples);
console.log("추출된 필드:", result.fields);
toast.success(`API 데이터 미리보기 완료! ${result.fields.length}개 필드, ${result.totalCount}개 레코드`);
} else if (result.samples && result.samples.length > 0) {
// 백엔드에서 fields를 제대로 보내지 않은 경우, 프론트엔드에서 직접 추출
console.log("⚠️ 백엔드에서 fields가 없어서 프론트엔드에서 추출");
const extractedFields = Object.keys(result.samples[0]);
console.log("프론트엔드에서 추출한 필드:", extractedFields);
setFromApiFields(extractedFields);
setFromApiData(result.samples);
toast.success(`API 데이터 미리보기 완료! ${extractedFields.length}개 필드, ${result.samples.length}개 레코드`);
} else {
console.log("❌ 데이터가 없음");
setFromApiFields([]);
setFromApiData([]);
toast.warning("API에서 데이터를 가져올 수 없습니다.");
@ -370,38 +375,53 @@ export default function BatchManagementNewPage() {
// 배치 타입별 검증 및 저장
if (batchType === 'restapi-to-db') {
const mappedFields = Object.keys(apiFieldMappings).filter(field => apiFieldMappings[field]);
const mappedFields = Object.keys(apiFieldMappings).filter(
(field) => apiFieldMappings[field]
);
if (mappedFields.length === 0) {
toast.error("최소 하나의 API 필드를 DB 컬럼에 매핑해주세요.");
return;
}
// API 필드 매핑을 배치 매핑 형태로 변환
const apiMappings = mappedFields.map(apiField => ({
from_connection_type: 'restapi' as const,
from_table_name: fromEndpoint, // API 엔드포인트
from_column_name: apiField, // API 필드명
from_api_url: fromApiUrl,
from_api_key: fromApiKey,
from_api_method: fromApiMethod,
// API 파라미터 정보 추가
from_api_param_type: apiParamType !== 'none' ? apiParamType : undefined,
from_api_param_name: apiParamType !== 'none' ? apiParamName : undefined,
from_api_param_value: apiParamType !== 'none' ? apiParamValue : undefined,
from_api_param_source: apiParamType !== 'none' ? apiParamSource : undefined,
to_connection_type: toConnection?.type === 'internal' ? 'internal' : 'external',
to_connection_id: toConnection?.type === 'internal' ? undefined : toConnection?.id,
to_table_name: toTable,
to_column_name: apiFieldMappings[apiField], // 매핑된 DB 컬럼
mapping_type: 'direct' as const
}));
const apiMappings = mappedFields.map((apiField) => {
const toColumnName = apiFieldMappings[apiField]; // 매핑된 DB 컬럼 (예: access_token)
console.log("REST API 배치 설정 저장:", {
batchName,
batchType,
cronSchedule,
description,
apiMappings
// 기본은 상위 필드 그대로 사용하되,
// 사용자가 JSON 경로를 직접 입력한 경우 해당 경로를 우선 사용
let fromColumnName = apiField;
const overridePath = apiFieldPathOverrides[apiField];
if (overridePath && overridePath.trim().length > 0) {
fromColumnName = overridePath.trim();
}
return {
from_connection_type: "restapi" as const,
from_table_name: fromEndpoint, // API 엔드포인트
from_column_name: fromColumnName, // API 필드명 또는 중첩 경로
from_api_url: fromApiUrl,
from_api_key: fromApiKey,
from_api_method: fromApiMethod,
from_api_body:
fromApiMethod === "POST" ||
fromApiMethod === "PUT" ||
fromApiMethod === "DELETE"
? fromApiBody
: undefined,
// API 파라미터 정보 추가
from_api_param_type: apiParamType !== "none" ? apiParamType : undefined,
from_api_param_name: apiParamType !== "none" ? apiParamName : undefined,
from_api_param_value: apiParamType !== "none" ? apiParamValue : undefined,
from_api_param_source:
apiParamType !== "none" ? apiParamSource : undefined,
to_connection_type:
toConnection?.type === "internal" ? "internal" : "external",
to_connection_id:
toConnection?.type === "internal" ? undefined : toConnection?.id,
to_table_name: toTable,
to_column_name: toColumnName, // 매핑된 DB 컬럼
mapping_type: "direct" as const,
};
});
// 실제 API 호출
@ -492,14 +512,6 @@ export default function BatchManagementNewPage() {
}
}
console.log("DB → REST API 배치 설정 저장:", {
batchName,
batchType,
cronSchedule,
description,
dbMappings
});
// 실제 API 호출 (기존 saveRestApiBatch 재사용)
try {
const result = await BatchManagementAPI.saveRestApiBatch({
@ -645,13 +657,19 @@ export default function BatchManagementNewPage() {
/>
</div>
<div>
<Label htmlFor="fromApiKey">API *</Label>
<Label htmlFor="fromApiKey">
API
{fromApiMethod === "GET" && <span className="text-red-500 ml-0.5">*</span>}
</Label>
<Input
id="fromApiKey"
value={fromApiKey}
onChange={(e) => setFromApiKey(e.target.value)}
placeholder="ak_your_api_key_here"
/>
<p className="text-xs text-gray-500 mt-1">
GET , POST/PUT/DELETE일 .
</p>
</div>
</div>
@ -673,12 +691,33 @@ export default function BatchManagementNewPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET ( )</SelectItem>
<SelectItem value="POST">POST ( /)</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Request Body (POST/PUT/DELETE용) */}
{(fromApiMethod === 'POST' || fromApiMethod === 'PUT' || fromApiMethod === 'DELETE') && (
<div>
<Label htmlFor="fromApiBody">Request Body (JSON)</Label>
<Textarea
id="fromApiBody"
value={fromApiBody}
onChange={(e) => setFromApiBody(e.target.value)}
placeholder='{"username": "myuser", "token": "abc"}'
className="min-h-[100px]"
rows={5}
/>
<p className="text-xs text-gray-500 mt-1">
API JSON .
</p>
</div>
)}
{/* API 파라미터 설정 */}
<div className="space-y-4">
<div className="border-t pt-4">
@ -771,7 +810,10 @@ export default function BatchManagementNewPage() {
)}
</div>
{fromApiUrl && fromApiKey && fromEndpoint && (
{/* API URL + 엔드포인트는 필수, GET일 때만 API 키 필수 */}
{fromApiUrl &&
fromEndpoint &&
(fromApiMethod !== "GET" || fromApiKey) && (
<div className="space-y-3">
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700">API </div>
@ -786,7 +828,11 @@ export default function BatchManagementNewPage() {
: ''
}
</div>
<div className="text-xs text-gray-500 mt-1">Headers: X-API-Key: {fromApiKey.substring(0, 10)}...</div>
{fromApiKey && (
<div className="text-xs text-gray-500 mt-1">
Headers: X-API-Key: {fromApiKey.substring(0, 10)}...
</div>
)}
{apiParamType !== 'none' && apiParamName && apiParamValue && (
<div className="text-xs text-blue-600 mt-1">
: {apiParamName} = {apiParamValue} ({apiParamSource === 'static' ? '고정값' : '동적값'})
@ -980,172 +1026,33 @@ export default function BatchManagementNewPage() {
{/* 매핑 UI - 배치 타입별 동적 렌더링 */}
{/* REST API → DB 매핑 */}
{batchType === 'restapi-to-db' && fromApiFields.length > 0 && toColumns.length > 0 && (
<Card>
<CardHeader>
<CardTitle>API DB </CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3 max-h-96 overflow-y-auto border rounded-lg p-4">
{fromApiFields.map((apiField) => (
<div key={apiField} className="flex items-center space-x-4 p-3 bg-gray-50 rounded-lg">
{/* API 필드 정보 */}
<div className="flex-1">
<div className="font-medium text-sm">{apiField}</div>
<div className="text-xs text-gray-500">
{fromApiData.length > 0 && fromApiData[0][apiField] !== undefined
? `예: ${String(fromApiData[0][apiField]).substring(0, 30)}${String(fromApiData[0][apiField]).length > 30 ? '...' : ''}`
: 'API 필드'
}
</div>
</div>
{/* 화살표 */}
<div className="text-gray-400">
<ArrowRight className="w-4 h-4" />
</div>
{/* DB 컬럼 선택 */}
<div className="flex-1">
<Select
value={apiFieldMappings[apiField] || "none"}
onValueChange={(value) => {
setApiFieldMappings(prev => ({
...prev,
[apiField]: value === "none" ? "" : value
}));
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="DB 컬럼 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{toColumns.map((column) => (
<SelectItem key={column.column_name} value={column.column_name}>
<div className="flex flex-col">
<span className="font-medium">{column.column_name.toUpperCase()}</span>
<span className="text-xs text-gray-500">{column.data_type}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
))}
</div>
{fromApiData.length > 0 && (
<div className="mt-3 p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-2"> ( 3)</div>
<div className="space-y-2 max-h-40 overflow-y-auto">
{fromApiData.slice(0, 3).map((item, index) => (
<div key={index} className="text-xs bg-white p-2 rounded border">
<pre className="whitespace-pre-wrap">{JSON.stringify(item, null, 2)}</pre>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
)}
{batchType === "restapi-to-db" &&
fromApiFields.length > 0 &&
toColumns.length > 0 && (
<RestApiToDbMappingCard
fromApiFields={fromApiFields}
toColumns={toColumns}
fromApiData={fromApiData}
apiFieldMappings={apiFieldMappings}
setApiFieldMappings={setApiFieldMappings}
apiFieldPathOverrides={apiFieldPathOverrides}
setApiFieldPathOverrides={setApiFieldPathOverrides}
/>
)}
{/* DB → REST API 매핑 */}
{batchType === 'db-to-restapi' && selectedColumns.length > 0 && toApiFields.length > 0 && (
<Card>
<CardHeader>
<CardTitle>DB API </CardTitle>
<CardDescription>
DB REST API Request Body에 . Request Body 릿 {`{{컬럼명}}`} .
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3 max-h-96 overflow-y-auto border rounded-lg p-4">
{fromColumns.filter(column => selectedColumns.includes(column.column_name)).map((column) => (
<div key={column.column_name} className="flex items-center space-x-4 p-3 bg-gray-50 rounded-lg">
{/* DB 컬럼 정보 */}
<div className="flex-1">
<div className="font-medium text-sm">{column.column_name}</div>
<div className="text-xs text-gray-500">
: {column.data_type} | NULL: {column.is_nullable ? 'Y' : 'N'}
</div>
</div>
{/* 화살표 */}
<div className="text-gray-400"></div>
{/* API 필드 선택 드롭다운 */}
<div className="flex-1">
<Select
value={dbToApiFieldMapping[column.column_name] || ''}
onValueChange={(value) => {
setDbToApiFieldMapping(prev => ({
...prev,
[column.column_name]: value === 'none' ? '' : value
}));
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="API 필드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{toApiFields.map((apiField) => (
<SelectItem key={apiField} value={apiField}>
{apiField}
</SelectItem>
))}
<SelectItem value="custom"> ...</SelectItem>
</SelectContent>
</Select>
{/* 직접 입력 모드 */}
{dbToApiFieldMapping[column.column_name] === 'custom' && (
<input
type="text"
placeholder="API 필드명을 직접 입력하세요"
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 mt-2"
onChange={(e) => {
setDbToApiFieldMapping(prev => ({
...prev,
[column.column_name]: e.target.value
}));
}}
/>
)}
<div className="text-xs text-gray-500 mt-1">
{dbToApiFieldMapping[column.column_name]
? `매핑: ${column.column_name}${dbToApiFieldMapping[column.column_name]}`
: `기본값: ${column.column_name} (DB 컬럼명 사용)`
}
</div>
</div>
{/* 템플릿 미리보기 */}
<div className="flex-1">
<div className="text-sm font-mono bg-white p-2 rounded border">
{`{{${column.column_name}}}`}
</div>
<div className="text-xs text-gray-500 mt-1">
DB
</div>
</div>
</div>
))}
</div>
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<div className="text-sm font-medium text-blue-800"> </div>
<div className="text-xs text-blue-600 mt-1 font-mono">
{`{"id": "{{id}}", "name": "{{user_name}}", "email": "{{email}}"}`}
</div>
</div>
</CardContent>
</Card>
)}
{batchType === "db-to-restapi" &&
selectedColumns.length > 0 &&
toApiFields.length > 0 && (
<DbToRestApiMappingCard
fromColumns={fromColumns}
selectedColumns={selectedColumns}
toApiFields={toApiFields}
dbToApiFieldMapping={dbToApiFieldMapping}
setDbToApiFieldMapping={setDbToApiFieldMapping}
setToApiBody={setToApiBody}
/>
)}
{/* TO 설정 */}
<Card>
@ -1348,4 +1255,278 @@ export default function BatchManagementNewPage() {
</Card>
</div>
);
}
}
const RestApiToDbMappingCard = memo(function RestApiToDbMappingCard({
fromApiFields,
toColumns,
fromApiData,
apiFieldMappings,
setApiFieldMappings,
apiFieldPathOverrides,
setApiFieldPathOverrides,
}: RestApiToDbMappingCardProps) {
// 샘플 JSON 문자열은 의존 데이터가 바뀔 때만 계산
const sampleJsonList = useMemo(
() =>
fromApiData.slice(0, 3).map((item) => JSON.stringify(item, null, 2)),
[fromApiData]
);
const firstSample = fromApiData[0] || null;
return (
<Card>
<CardHeader>
<CardTitle>API DB </CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3 max-h-96 overflow-y-auto border rounded-lg p-4">
{fromApiFields.map((apiField) => (
<div
key={apiField}
className="flex items-center space-x-4 p-3 bg-gray-50 rounded-lg"
>
{/* API 필드 정보 */}
<div className="flex-1">
<div className="font-medium text-sm">{apiField}</div>
<div className="text-xs text-gray-500">
{firstSample && firstSample[apiField] !== undefined
? `예: ${String(firstSample[apiField]).substring(0, 30)}${
String(firstSample[apiField]).length > 30 ? "..." : ""
}`
: "API 필드"}
</div>
{/* JSON 경로 오버라이드 입력 */}
<div className="mt-1.5">
<Input
value={apiFieldPathOverrides[apiField] || ""}
onChange={(e) =>
setApiFieldPathOverrides((prev) => ({
...prev,
[apiField]: e.target.value,
}))
}
placeholder="JSON 경로 (예: response.access_token)"
className="h-7 text-xs"
/>
<p className="text-[11px] text-gray-500 mt-0.5">
"{apiField}" ,
.
</p>
</div>
</div>
{/* 화살표 */}
<div className="text-gray-400">
<ArrowRight className="w-4 h-4" />
</div>
{/* DB 컬럼 선택 */}
<div className="flex-1">
<Select
value={apiFieldMappings[apiField] || "none"}
onValueChange={(value) => {
setApiFieldMappings((prev) => ({
...prev,
[apiField]: value === "none" ? "" : value,
}));
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="DB 컬럼 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{toColumns.map((column) => (
<SelectItem
key={column.column_name}
value={column.column_name}
>
<div className="flex flex-col">
<span className="font-medium">
{column.column_name.toUpperCase()}
</span>
<span className="text-xs text-gray-500">
{column.data_type}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
))}
</div>
{sampleJsonList.length > 0 && (
<div className="mt-3 p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-2">
( 3)
</div>
<div className="space-y-2 max-h-40 overflow-y-auto">
{sampleJsonList.map((json, index) => (
<div
key={index}
className="text-xs bg-white p-2 rounded border"
>
<pre className="whitespace-pre-wrap">{json}</pre>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
);
});
const DbToRestApiMappingCard = memo(function DbToRestApiMappingCard({
fromColumns,
selectedColumns,
toApiFields,
dbToApiFieldMapping,
setDbToApiFieldMapping,
setToApiBody,
}: DbToRestApiMappingCardProps) {
const selectedColumnObjects = useMemo(
() =>
fromColumns.filter((column) =>
selectedColumns.includes(column.column_name)
),
[fromColumns, selectedColumns]
);
const autoJsonPreview = useMemo(() => {
if (selectedColumns.length === 0) {
return "";
}
const obj = selectedColumns.reduce((acc, col) => {
const apiField = dbToApiFieldMapping[col] || col;
acc[apiField] = `{{${col}}}`;
return acc;
}, {} as Record<string, string>);
return JSON.stringify(obj, null, 2);
}, [selectedColumns, dbToApiFieldMapping]);
return (
<Card>
<CardHeader>
<CardTitle>DB API </CardTitle>
<CardDescription>
DB REST API Request Body에 . Request Body
릿 {`{{컬럼명}}`} .
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3 max-h-96 overflow-y-auto border rounded-lg p-4">
{selectedColumnObjects.map((column) => (
<div
key={column.column_name}
className="flex items-center space-x-4 p-3 bg-gray-50 rounded-lg"
>
{/* DB 컬럼 정보 */}
<div className="flex-1">
<div className="font-medium text-sm">{column.column_name}</div>
<div className="text-xs text-gray-500">
: {column.data_type} | NULL: {column.is_nullable ? "Y" : "N"}
</div>
</div>
{/* 화살표 */}
<div className="text-gray-400"></div>
{/* API 필드 선택 드롭다운 */}
<div className="flex-1">
<Select
value={dbToApiFieldMapping[column.column_name] || ""}
onValueChange={(value) => {
setDbToApiFieldMapping((prev) => ({
...prev,
[column.column_name]: value === "none" ? "" : value,
}));
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="API 필드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{toApiFields.map((apiField) => (
<SelectItem key={apiField} value={apiField}>
{apiField}
</SelectItem>
))}
<SelectItem value="custom"> ...</SelectItem>
</SelectContent>
</Select>
{/* 직접 입력 모드 */}
{dbToApiFieldMapping[column.column_name] === "custom" && (
<input
type="text"
placeholder="API 필드명을 직접 입력하세요"
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 mt-2"
onChange={(e) => {
setDbToApiFieldMapping((prev) => ({
...prev,
[column.column_name]: e.target.value,
}));
}}
/>
)}
<div className="text-xs text-gray-500 mt-1">
{dbToApiFieldMapping[column.column_name]
? `매핑: ${column.column_name}${dbToApiFieldMapping[column.column_name]}`
: `기본값: ${column.column_name} (DB 컬럼명 사용)`}
</div>
</div>
{/* 템플릿 미리보기 */}
<div className="flex-1">
<div className="text-sm font-mono bg-white p-2 rounded border">
{`{{${column.column_name}}}`}
</div>
<div className="text-xs text-gray-500 mt-1">
DB
</div>
</div>
</div>
))}
</div>
{selectedColumns.length > 0 && (
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<div className="text-sm font-medium text-blue-800">
JSON
</div>
<pre className="mt-1 text-xs text-blue-600 font-mono overflow-x-auto">
{autoJsonPreview}
</pre>
<button
type="button"
onClick={() => {
setToApiBody(autoJsonPreview);
toast.success(
"Request Body에 자동 생성된 JSON이 적용되었습니다."
);
}}
className="mt-2 px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700"
>
Request Body에
</button>
</div>
)}
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<div className="text-sm font-medium text-blue-800"> </div>
<div className="text-xs text-blue-600 mt-1 font-mono">
{`{"id": "{{id}}", "name": "{{user_name}}", "email": "{{email}}"}`}
</div>
</div>
</CardContent>
</Card>
);
});

View File

@ -7,12 +7,24 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { RefreshCw, Save, ArrowLeft, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { BatchAPI, BatchConfig, BatchMapping, ConnectionInfo } from "@/lib/api/batch";
import {
BatchAPI,
BatchConfig,
BatchMapping,
ConnectionInfo,
} from "@/lib/api/batch";
import { BatchManagementAPI } from "@/lib/api/batchManagement";
interface BatchColumnInfo {
column_name: string;
@ -66,6 +78,9 @@ export default function BatchEditPage() {
// 배치 타입 감지
const [batchType, setBatchType] = useState<'db-to-db' | 'restapi-to-db' | 'db-to-restapi' | null>(null);
// REST API 미리보기 상태
const [apiPreviewData, setApiPreviewData] = useState<any[]>([]);
// 페이지 로드 시 배치 정보 조회
useEffect(() => {
@ -335,6 +350,86 @@ export default function BatchEditPage() {
setMappings([...mappings, newMapping]);
};
// REST API → DB 매핑 추가
const addRestapiToDbMapping = () => {
if (!batchConfig || !batchConfig.batch_mappings || batchConfig.batch_mappings.length === 0) {
return;
}
const first = batchConfig.batch_mappings[0] as any;
const newMapping: BatchMapping = {
// FROM: REST API (기존 설정 그대로 복사)
from_connection_type: "restapi" as any,
from_connection_id: first.from_connection_id,
from_table_name: first.from_table_name,
from_column_name: "",
from_column_type: "",
// TO: DB (기존 설정 그대로 복사)
to_connection_type: first.to_connection_type as any,
to_connection_id: first.to_connection_id,
to_table_name: first.to_table_name,
to_column_name: "",
to_column_type: "",
mapping_type: (first.mapping_type as any) || "direct",
mapping_order: mappings.length + 1,
};
setMappings((prev) => [...prev, newMapping]);
};
// REST API 데이터 미리보기 (수정 화면용)
const previewRestApiData = async () => {
if (!mappings || mappings.length === 0) {
toast.error("미리보기할 REST API 매핑이 없습니다.");
return;
}
const first: any = mappings[0];
if (!first.from_api_url || !first.from_table_name) {
toast.error("API URL과 엔드포인트 정보가 없습니다.");
return;
}
try {
const method =
(first.from_api_method as "GET" | "POST" | "PUT" | "DELETE") || "GET";
const paramInfo =
first.from_api_param_type &&
first.from_api_param_name &&
first.from_api_param_value
? {
paramType: first.from_api_param_type as "url" | "query",
paramName: first.from_api_param_name as string,
paramValue: first.from_api_param_value as string,
paramSource:
(first.from_api_param_source as "static" | "dynamic") ||
"static",
}
: undefined;
const result = await BatchManagementAPI.previewRestApiData(
first.from_api_url,
first.from_api_key || "",
first.from_table_name,
method,
paramInfo,
first.from_api_body || undefined
);
setApiPreviewData(result.samples || []);
toast.success(
`API 데이터 미리보기 완료! ${result.fields.length}개 필드, ${result.samples.length}개 레코드`
);
} catch (error) {
console.error("REST API 미리보기 오류:", error);
toast.error("API 데이터 미리보기에 실패했습니다.");
}
};
// 매핑 삭제
const removeMapping = (index: number) => {
const updatedMappings = mappings.filter((_, i) => i !== index);
@ -404,14 +499,16 @@ export default function BatchEditPage() {
<h1 className="text-3xl font-bold"> </h1>
</div>
<div className="flex space-x-2">
<Button onClick={loadBatchConfig} variant="outline" disabled={loading}>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
<Button
onClick={loadBatchConfig}
variant="outline"
disabled={loading}
>
<RefreshCw
className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`}
/>
</Button>
<Button onClick={saveBatchConfig} disabled={loading}>
<Save className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
@ -580,22 +677,91 @@ export default function BatchEditPage() {
</div>
{mappings.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label>API URL</Label>
<Input value={mappings[0]?.from_api_url || ''} readOnly />
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label>API URL</Label>
<Input value={mappings[0]?.from_api_url || ""} readOnly />
</div>
<div>
<Label>API </Label>
<Input
value={mappings[0]?.from_table_name || ""}
readOnly
/>
</div>
<div>
<Label>HTTP </Label>
<Input
value={mappings[0]?.from_api_method || "GET"}
readOnly
/>
</div>
<div>
<Label> </Label>
<Input
value={mappings[0]?.to_table_name || ""}
readOnly
/>
</div>
</div>
{/* Request Body (JSON) 편집 UI */}
<div>
<Label>API </Label>
<Input value={mappings[0]?.from_table_name || ''} readOnly />
<Label>Request Body (JSON)</Label>
<Textarea
rows={5}
className="font-mono text-sm"
placeholder='{"id": "wace", "pwd": "wace!$%Pwdmo^^"}'
value={mappings[0]?.from_api_body || ""}
onChange={(e) => {
const value = e.target.value;
setMappings((prev) => {
if (prev.length === 0) return prev;
const updated = [...prev];
updated[0] = {
...updated[0],
from_api_body: value,
} as any;
return updated;
});
}}
/>
<p className="text-xs text-muted-foreground mt-1.5">
POST JSON Request Body를 .
.
</p>
</div>
<div>
<Label>HTTP </Label>
<Input value={mappings[0]?.from_api_method || 'GET'} readOnly />
</div>
<div>
<Label> </Label>
<Input value={mappings[0]?.to_table_name || ''} readOnly />
{/* API 데이터 미리보기 */}
<div className="space-y-3">
<Button
variant="outline"
size="sm"
onClick={previewRestApiData}
className="mt-2"
>
<RefreshCw className="w-4 h-4 mr-2" />
API
</Button>
{apiPreviewData.length > 0 && (
<div className="mt-2 rounded-lg border bg-muted p-3">
<p className="text-sm font-medium text-muted-foreground">
( 3)
</p>
<div className="mt-2 space-y-2 max-h-60 overflow-y-auto">
{apiPreviewData.slice(0, 3).map((item, index) => (
<pre
key={index}
className="whitespace-pre-wrap rounded border bg-background p-2 text-xs font-mono"
>
{JSON.stringify(item, null, 2)}
</pre>
))}
</div>
</div>
)}
</div>
</div>
)}
@ -647,6 +813,12 @@ export default function BatchEditPage() {
</Button>
)}
{batchType === 'restapi-to-db' && (
<Button onClick={addRestapiToDbMapping} size="sm">
<Plus className="w-4 h-4 mr-2" />
</Button>
)}
</CardTitle>
</CardHeader>
<CardContent>
@ -751,20 +923,73 @@ export default function BatchEditPage() {
<div className="flex items-center justify-between mb-4">
<div>
<h4 className="font-medium"> #{index + 1}</h4>
<p className="text-sm text-gray-600">
API : {mapping.from_column_name} DB : {mapping.to_column_name}
</p>
{mapping.from_column_name && mapping.to_column_name && (
<p className="text-sm text-gray-600">
API : {mapping.from_column_name} DB : {mapping.to_column_name}
</p>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={() => removeMapping(index)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label>API </Label>
<Input value={mapping.from_column_name || ''} readOnly />
<Label>API (JSON )</Label>
<Input
value={mapping.from_column_name || ""}
onChange={(e) =>
updateMapping(
index,
"from_column_name",
e.target.value
)
}
placeholder="response.access_token"
/>
</div>
<div>
<Label>DB </Label>
<Input value={mapping.to_column_name || ''} readOnly />
<Select
value={mapping.to_column_name || ""}
onValueChange={(value) => {
updateMapping(index, "to_column_name", value);
const selectedColumn = toColumns.find(
(col) => col.column_name === value
);
if (selectedColumn) {
updateMapping(
index,
"to_column_type",
selectedColumn.data_type
);
}
}}
>
<SelectTrigger>
<SelectValue placeholder="대상 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{toColumns.map((column) => (
<SelectItem
key={column.column_name}
value={column.column_name}
>
{column.column_name} ({column.data_type})
</SelectItem>
))}
</SelectContent>
</Select>
{toColumns.length === 0 && (
<p className="text-xs text-gray-500 mt-1">
.
</p>
)}
</div>
</div>
</div>

View File

@ -0,0 +1,423 @@
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import { BatchAPI, BatchJob, BatchConfig } from "@/lib/api/batch";
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
// BatchJobModal에서 사용하던 config_json 구조 확장
interface RestApiConfigJson {
sourceConnectionId?: number;
targetConnectionId?: number;
targetTable?: string;
// REST API 관련 설정
apiUrl?: string;
apiKey?: string;
endpoint?: string;
httpMethod?: string;
apiBody?: string; // POST 요청용 Body
// 매핑 정보 등
mappings?: any[];
}
interface AdvancedBatchModalProps {
isOpen: boolean;
onClose: () => void;
onSave: () => void;
job?: BatchJob | null;
initialType?: "rest_to_db" | "db_to_rest"; // 초기 진입 시 타입 지정
}
export default function AdvancedBatchModal({
isOpen,
onClose,
onSave,
job,
initialType = "rest_to_db",
}: AdvancedBatchModalProps) {
// 기본 BatchJob 정보 관리
const [formData, setFormData] = useState<Partial<BatchJob>>({
job_name: "",
description: "",
job_type: initialType === "rest_to_db" ? "rest_to_db" : "db_to_rest",
schedule_cron: "",
is_active: "Y",
config_json: {},
});
// 상세 설정 (config_json 내부 값) 관리
const [configData, setConfigData] = useState<RestApiConfigJson>({
httpMethod: "GET", // 기본값
apiBody: "",
});
const [isLoading, setIsLoading] = useState(false);
const [connections, setConnections] = useState<any[]>([]); // 내부/외부 DB 연결 목록
const [targetTables, setTargetTables] = useState<string[]>([]); // 대상 테이블 목록 (DB가 타겟일 때)
const [schedulePresets, setSchedulePresets] = useState<Array<{ value: string; label: string }>>([]);
// 모달 열릴 때 초기화
useEffect(() => {
if (isOpen) {
loadConnections();
loadSchedulePresets();
if (job) {
// 수정 모드
setFormData({
...job,
config_json: job.config_json || {},
});
// 기존 config_json 내용을 상태로 복원
const savedConfig = job.config_json as RestApiConfigJson;
setConfigData({
...savedConfig,
httpMethod: savedConfig.httpMethod || "GET",
apiBody: savedConfig.apiBody || "",
});
// 타겟 연결이 있으면 테이블 목록 로드
if (savedConfig.targetConnectionId) {
loadTables(savedConfig.targetConnectionId);
}
} else {
// 생성 모드
setFormData({
job_name: "",
description: "",
job_type: initialType === "rest_to_db" ? "rest_to_db" : "db_to_rest", // props로 받은 타입 우선
schedule_cron: "",
is_active: "Y",
config_json: {},
});
setConfigData({
httpMethod: "GET",
apiBody: "",
});
}
}
}, [isOpen, job, initialType]);
const loadConnections = async () => {
try {
// 외부 DB 연결 목록 조회 (내부 DB 포함)
const list = await ExternalDbConnectionAPI.getConnections({ is_active: "Y" });
setConnections(list);
} catch (error) {
console.error("연결 목록 조회 오류:", error);
toast.error("연결 목록을 불러오는데 실패했습니다.");
}
};
const loadTables = async (connectionId: number) => {
try {
const result = await ExternalDbConnectionAPI.getTables(connectionId);
if (result.success && result.data) {
setTargetTables(result.data);
}
} catch (error) {
console.error("테이블 목록 조회 오류:", error);
}
};
const loadSchedulePresets = async () => {
try {
const presets = await BatchAPI.getSchedulePresets();
setSchedulePresets(presets);
} catch (error) {
console.error("스케줄 프리셋 조회 오류:", error);
}
};
// 폼 제출 핸들러
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.job_name) {
toast.error("배치명을 입력해주세요.");
return;
}
// REST API URL 필수 체크
if (!configData.apiUrl) {
toast.error("API 서버 URL을 입력해주세요.");
return;
}
// 타겟 DB 연결 필수 체크 (REST -> DB 인 경우)
if (formData.job_type === "rest_to_db" && !configData.targetConnectionId) {
toast.error("데이터를 저장할 대상 DB 연결을 선택해주세요.");
return;
}
setIsLoading(true);
try {
// 최종 저장할 데이터 조립
const finalJobData = {
...formData,
config_json: {
...configData,
// 추가적인 메타데이터가 필요하다면 여기에 포함
},
};
if (job?.id) {
await BatchAPI.updateBatchJob(job.id, finalJobData);
toast.success("배치 작업이 수정되었습니다.");
} else {
await BatchAPI.createBatchJob(finalJobData as BatchJob);
toast.success("배치 작업이 생성되었습니다.");
}
onSave();
onClose();
} catch (error) {
console.error("배치 저장 오류:", error);
toast.error(error instanceof Error ? error.message : "저장에 실패했습니다.");
} finally {
setIsLoading(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6 py-2">
{/* 1. 기본 정보 섹션 */}
<div className="space-y-4 border rounded-md p-4 bg-slate-50">
<h3 className="text-sm font-semibold text-slate-900"> </h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label className="text-xs"> *</Label>
<div className="mt-1 p-2 bg-white border rounded text-sm font-medium text-slate-600">
{formData.job_type === "rest_to_db" ? "🌐 REST API → 💾 DB" : "💾 DB → 🌐 REST API"}
</div>
<p className="text-[10px] text-slate-400 mt-1">
{formData.job_type === "rest_to_db"
? "REST API에서 데이터를 가져와 데이터베이스에 저장합니다."
: "데이터베이스의 데이터를 REST API로 전송합니다."}
</p>
</div>
<div>
<Label htmlFor="schedule_cron" className="text-xs"> *</Label>
<div className="flex gap-2 mt-1">
<Input
id="schedule_cron"
value={formData.schedule_cron || ""}
onChange={(e) => setFormData(prev => ({ ...prev, schedule_cron: e.target.value }))}
placeholder="예: 0 12 * * *"
className="text-sm"
/>
<Select onValueChange={(val) => setFormData(prev => ({ ...prev, schedule_cron: val }))}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="프리셋" />
</SelectTrigger>
<SelectContent>
{schedulePresets.map(p => (
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="sm:col-span-2">
<Label htmlFor="job_name" className="text-xs"> *</Label>
<Input
id="job_name"
value={formData.job_name || ""}
onChange={(e) => setFormData(prev => ({ ...prev, job_name: e.target.value }))}
placeholder="배치명을 입력하세요"
className="mt-1"
/>
</div>
<div className="sm:col-span-2">
<Label htmlFor="description" className="text-xs"></Label>
<Textarea
id="description"
value={formData.description || ""}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
placeholder="배치에 대한 설명을 입력하세요"
className="mt-1 min-h-[60px]"
/>
</div>
</div>
</div>
{/* 2. REST API 설정 섹션 (Source) */}
<div className="space-y-4 border rounded-md p-4 bg-white">
<div className="flex items-center gap-2">
<span className="text-lg">🌐</span>
<h3 className="text-sm font-semibold text-slate-900">
{formData.job_type === "rest_to_db" ? "FROM: REST API (소스)" : "TO: REST API (대상)"}
</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="sm:col-span-2">
<Label htmlFor="api_url" className="text-xs">API URL *</Label>
<Input
id="api_url"
value={configData.apiUrl || ""}
onChange={(e) => setConfigData(prev => ({ ...prev, apiUrl: e.target.value }))}
placeholder="https://api.example.com"
className="mt-1"
/>
</div>
<div className="sm:col-span-2">
<Label htmlFor="api_key" className="text-xs">API ()</Label>
<Input
id="api_key"
type="password"
value={configData.apiKey || ""}
onChange={(e) => setConfigData(prev => ({ ...prev, apiKey: e.target.value }))}
placeholder="인증에 필요한 API Key가 있다면 입력하세요"
className="mt-1"
/>
</div>
<div>
<Label htmlFor="endpoint" className="text-xs"> *</Label>
<Input
id="endpoint"
value={configData.endpoint || ""}
onChange={(e) => setConfigData(prev => ({ ...prev, endpoint: e.target.value }))}
placeholder="/api/token"
className="mt-1"
/>
</div>
<div>
<Label htmlFor="http_method" className="text-xs">HTTP </Label>
<Select
value={configData.httpMethod || "GET"}
onValueChange={(val) => setConfigData(prev => ({ ...prev, httpMethod: val }))}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET ( )</SelectItem>
<SelectItem value="POST">POST ( /)</SelectItem>
<SelectItem value="PUT">PUT ( )</SelectItem>
<SelectItem value="DELETE">DELETE ( )</SelectItem>
</SelectContent>
</Select>
</div>
{/* POST/PUT 일 때 Body 입력창 노출 */}
{(configData.httpMethod === "POST" || configData.httpMethod === "PUT") && (
<div className="sm:col-span-2 animate-in fade-in slide-in-from-top-2 duration-200">
<Label htmlFor="api_body" className="text-xs">Request Body (JSON)</Label>
<Textarea
id="api_body"
value={configData.apiBody || ""}
onChange={(e) => setConfigData(prev => ({ ...prev, apiBody: e.target.value }))}
placeholder='{"username": "myuser", "password": "mypassword"}'
className="mt-1 font-mono text-xs min-h-[100px]"
/>
<p className="text-[10px] text-slate-500 mt-1">
* JSON .
</p>
</div>
)}
</div>
</div>
{/* 3. 데이터베이스 설정 섹션 (Target) */}
<div className="space-y-4 border rounded-md p-4 bg-white">
<div className="flex items-center gap-2">
<span className="text-lg">💾</span>
<h3 className="text-sm font-semibold text-slate-900">
{formData.job_type === "rest_to_db" ? "TO: 데이터베이스 (대상)" : "FROM: 데이터베이스 (소스)"}
</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label className="text-xs"> </Label>
<Select
value={configData.targetConnectionId?.toString() || ""}
onValueChange={(val) => {
const connId = parseInt(val);
setConfigData(prev => ({ ...prev, targetConnectionId: connId }));
loadTables(connId); // 테이블 목록 로드
}}
>
<SelectTrigger className="mt-1">
<SelectValue placeholder="커넥션을 선택하세요" />
</SelectTrigger>
<SelectContent>
{connections.map(conn => (
<SelectItem key={conn.id} value={conn.id.toString()}>
{conn.connection_name || conn.name} ({conn.db_type})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"> </Label>
<Select
value={configData.targetTable || ""}
onValueChange={(val) => setConfigData(prev => ({ ...prev, targetTable: val }))}
disabled={!configData.targetConnectionId}
>
<SelectTrigger className="mt-1">
<SelectValue placeholder="테이블을 선택하세요" />
</SelectTrigger>
<SelectContent>
{targetTables.length > 0 ? (
targetTables.map(table => (
<SelectItem key={table} value={table}>{table}</SelectItem>
))
) : (
<div className="p-2 text-xs text-center text-slate-400"> </div>
)}
</SelectContent>
</Select>
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? "저장 중..." : "저장"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -42,6 +42,7 @@ export function AuthenticationConfig({
<SelectItem value="bearer">Bearer Token</SelectItem>
<SelectItem value="basic">Basic Auth</SelectItem>
<SelectItem value="oauth2">OAuth 2.0</SelectItem>
<SelectItem value="db-token">DB </SelectItem>
</SelectContent>
</Select>
</div>
@ -192,6 +193,94 @@ export function AuthenticationConfig({
</div>
)}
{authType === "db-token" && (
<div className="space-y-4 rounded-md border bg-gray-50 p-4">
<h4 className="text-sm font-medium">DB </h4>
<div className="space-y-2">
<Label htmlFor="db-table-name"></Label>
<Input
id="db-table-name"
type="text"
value={authConfig.dbTableName || ""}
onChange={(e) => updateAuthConfig("dbTableName", e.target.value)}
placeholder="예: auth_tokens"
/>
</div>
<div className="space-y-2">
<Label htmlFor="db-value-column"> </Label>
<Input
id="db-value-column"
type="text"
value={authConfig.dbValueColumn || ""}
onChange={(e) =>
updateAuthConfig("dbValueColumn", e.target.value)
}
placeholder="예: access_token"
/>
</div>
<div className="space-y-2">
<Label htmlFor="db-where-column"> </Label>
<Input
id="db-where-column"
type="text"
value={authConfig.dbWhereColumn || ""}
onChange={(e) =>
updateAuthConfig("dbWhereColumn", e.target.value)
}
placeholder="예: service_name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="db-where-value"> </Label>
<Input
id="db-where-value"
type="text"
value={authConfig.dbWhereValue || ""}
onChange={(e) =>
updateAuthConfig("dbWhereValue", e.target.value)
}
placeholder="예: kakao"
/>
</div>
<div className="space-y-2">
<Label htmlFor="db-header-name"> ()</Label>
<Input
id="db-header-name"
type="text"
value={authConfig.dbHeaderName || ""}
onChange={(e) =>
updateAuthConfig("dbHeaderName", e.target.value)
}
placeholder="기본값: Authorization"
/>
</div>
<div className="space-y-2">
<Label htmlFor="db-header-template">
릿 (, &#123;&#123;value&#125;&#125; )
</Label>
<Input
id="db-header-template"
type="text"
value={authConfig.dbHeaderTemplate || ""}
onChange={(e) =>
updateAuthConfig("dbHeaderTemplate", e.target.value)
}
placeholder='기본값: "Bearer {{value}}"'
/>
</div>
<p className="text-xs text-gray-500">
company_code는 .
</p>
</div>
)}
{authType === "none" && (
<div className="rounded-md border border-dashed p-4 text-center text-sm text-gray-500">
API입니다.

View File

@ -33,6 +33,7 @@ const AUTH_TYPE_LABELS: Record<string, string> = {
bearer: "Bearer",
basic: "Basic Auth",
oauth2: "OAuth 2.0",
"db-token": "DB 토큰",
};
// 활성 상태 옵션
@ -158,6 +159,22 @@ export function RestApiConnectionList() {
setTestResults((prev) => new Map(prev).set(connection.id!, result.success));
// 현재 행의 "마지막 테스트" 정보만 낙관적으로 업데이트하여
// 전체 목록 리로딩 없이도 UI를 즉시 반영한다.
const nowIso = new Date().toISOString();
setConnections((prev) =>
prev.map((c) =>
c.id === connection.id
? {
...c,
last_test_date: nowIso as any,
last_test_result: result.success ? "Y" : "N",
last_test_message: result.message,
}
: c
)
);
if (result.success) {
toast({
title: "연결 성공",

View File

@ -21,10 +21,13 @@ import {
ExternalRestApiConnection,
AuthType,
RestApiTestResult,
RestApiTestRequest,
} from "@/lib/api/externalRestApiConnection";
import { HeadersManager } from "./HeadersManager";
import { AuthenticationConfig } from "./AuthenticationConfig";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
interface RestApiConnectionModalProps {
isOpen: boolean;
@ -42,6 +45,8 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
const [baseUrl, setBaseUrl] = useState("");
const [endpointPath, setEndpointPath] = useState("");
const [defaultHeaders, setDefaultHeaders] = useState<Record<string, string>>({});
const [defaultMethod, setDefaultMethod] = useState("GET");
const [defaultBody, setDefaultBody] = useState("");
const [authType, setAuthType] = useState<AuthType>("none");
const [authConfig, setAuthConfig] = useState<any>({});
const [timeout, setTimeout] = useState(30000);
@ -52,6 +57,8 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
// UI 상태
const [showAdvanced, setShowAdvanced] = useState(false);
const [testEndpoint, setTestEndpoint] = useState("");
const [testMethod, setTestMethod] = useState("GET");
const [testBody, setTestBody] = useState("");
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<RestApiTestResult | null>(null);
const [testRequestUrl, setTestRequestUrl] = useState<string>("");
@ -65,12 +72,19 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
setBaseUrl(connection.base_url);
setEndpointPath(connection.endpoint_path || "");
setDefaultHeaders(connection.default_headers || {});
setDefaultMethod(connection.default_method || "GET");
setDefaultBody(connection.default_body || "");
setAuthType(connection.auth_type);
setAuthConfig(connection.auth_config || {});
setTimeout(connection.timeout || 30000);
setRetryCount(connection.retry_count || 0);
setRetryDelay(connection.retry_delay || 1000);
setIsActive(connection.is_active === "Y");
// 테스트 초기값 설정
setTestEndpoint("");
setTestMethod(connection.default_method || "GET");
setTestBody(connection.default_body || "");
} else {
// 초기화
setConnectionName("");
@ -78,16 +92,22 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
setBaseUrl("");
setEndpointPath("");
setDefaultHeaders({ "Content-Type": "application/json" });
setDefaultMethod("GET");
setDefaultBody("");
setAuthType("none");
setAuthConfig({});
setTimeout(30000);
setRetryCount(0);
setRetryDelay(1000);
setIsActive(true);
// 테스트 초기값 설정
setTestEndpoint("");
setTestMethod("GET");
setTestBody("");
}
setTestResult(null);
setTestEndpoint("");
setTestRequestUrl("");
}, [connection, isOpen]);
@ -111,14 +131,18 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
setTestRequestUrl(fullUrl);
try {
const result = await ExternalRestApiConnectionAPI.testConnection({
const testRequest: RestApiTestRequest = {
base_url: baseUrl,
endpoint: testEndpoint || undefined,
method: testMethod as any,
headers: defaultHeaders,
body: testBody ? JSON.parse(testBody) : undefined,
auth_type: authType,
auth_config: authConfig,
timeout,
});
};
const result = await ExternalRestApiConnectionAPI.testConnection(testRequest);
setTestResult(result);
@ -178,6 +202,20 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
return;
}
// JSON 유효성 검증
if (defaultBody && defaultMethod !== "GET" && defaultMethod !== "DELETE") {
try {
JSON.parse(defaultBody);
} catch {
toast({
title: "입력 오류",
description: "기본 Body가 올바른 JSON 형식이 아닙니다.",
variant: "destructive",
});
return;
}
}
setSaving(true);
try {
@ -187,6 +225,8 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
base_url: baseUrl,
endpoint_path: endpointPath || undefined,
default_headers: defaultHeaders,
default_method: defaultMethod,
default_body: defaultBody || undefined,
auth_type: authType,
auth_config: authType === "none" ? undefined : authConfig,
timeout,
@ -262,12 +302,28 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
<Label htmlFor="base-url">
URL <span className="text-destructive">*</span>
</Label>
<Input
id="base-url"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="https://api.example.com"
/>
<div className="flex gap-2">
<Select value={defaultMethod} onValueChange={setDefaultMethod}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="Method" />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
<SelectItem value="PATCH">PATCH</SelectItem>
</SelectContent>
</Select>
<div className="flex-1">
<Input
id="base-url"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="https://api.example.com"
/>
</div>
</div>
<p className="text-muted-foreground text-xs">
(: https://apihub.kma.go.kr)
</p>
@ -286,6 +342,21 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
</p>
</div>
{/* 기본 Body (POST, PUT, PATCH일 때만 표시) */}
{(defaultMethod === "POST" || defaultMethod === "PUT" || defaultMethod === "PATCH") && (
<div className="space-y-2">
<Label htmlFor="default-body"> Request Body (JSON)</Label>
<Textarea
id="default-body"
value={defaultBody}
onChange={(e) => setDefaultBody(e.target.value)}
placeholder='{"key": "value"}'
className="font-mono text-xs"
rows={5}
/>
</div>
)}
<div className="flex items-center space-x-2">
<Switch id="is-active" checked={isActive} onCheckedChange={setIsActive} />
<Label htmlFor="is-active" className="cursor-pointer">
@ -370,13 +441,45 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
<h3 className="text-sm font-semibold"> </h3>
<div className="space-y-2">
<Label htmlFor="test-endpoint"> ()</Label>
<Input
id="test-endpoint"
value={testEndpoint}
onChange={(e) => setTestEndpoint(e.target.value)}
placeholder="엔드포인트 또는 빈칸(기본 URL만 테스트)"
/>
<Label htmlFor="test-endpoint"> </Label>
<div className="flex gap-2 mb-2">
<Select value={testMethod} onValueChange={setTestMethod}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="Method" />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
<SelectItem value="PATCH">PATCH</SelectItem>
</SelectContent>
</Select>
<div className="flex-1">
<Input
id="test-endpoint"
value={testEndpoint}
onChange={(e) => setTestEndpoint(e.target.value)}
placeholder="엔드포인트 (예: /users/1)"
/>
</div>
</div>
{(testMethod === "POST" || testMethod === "PUT" || testMethod === "PATCH") && (
<div className="mt-2">
<Label htmlFor="test-body" className="text-xs text-muted-foreground mb-1 block">
Test Request Body (JSON)
</Label>
<Textarea
id="test-body"
value={testBody}
onChange={(e) => setTestBody(e.target.value)}
placeholder='{"test": "data"}'
className="font-mono text-xs"
rows={3}
/>
</div>
)}
</div>
<Button type="button" variant="outline" onClick={handleTest} disabled={testing}>
@ -388,10 +491,22 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
{testRequestUrl && (
<div className="bg-muted/30 space-y-3 rounded-md border p-3">
<div>
<div className="text-muted-foreground mb-1 text-xs font-medium"> URL</div>
<code className="text-foreground block text-xs break-all">GET {testRequestUrl}</code>
<div className="text-muted-foreground mb-1 text-xs font-medium"> </div>
<div className="flex items-center gap-2 mb-1">
<Badge variant="outline">{testMethod}</Badge>
<code className="text-foreground text-xs break-all">{testRequestUrl}</code>
</div>
</div>
{testBody && (testMethod === "POST" || testMethod === "PUT" || testMethod === "PATCH") && (
<div>
<div className="text-muted-foreground mb-1 text-xs font-medium">Request Body</div>
<pre className="bg-muted p-2 rounded text-xs overflow-auto max-h-[100px]">
{testBody}
</pre>
</div>
)}
{Object.keys(defaultHeaders).length > 0 && (
<div>
<div className="text-muted-foreground mb-1 text-xs font-medium"> </div>

View File

@ -2,7 +2,7 @@
import { useState, useEffect, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { ArrowLeft, Save, Loader2, Grid3x3, Move, Box, Package, Truck, Check } from "lucide-react";
import { ArrowLeft, Save, Loader2, Grid3x3, Move, Box, Package, Truck, Check, ParkingCircle } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -39,6 +39,77 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
// 성능 최적화를 위한 디바운스/Blur 처리된 Input 컴포넌트
const DebouncedInput = ({
value,
onChange,
onCommit,
type = "text",
debounce = 0,
...props
}: React.InputHTMLAttributes<HTMLInputElement> & {
onCommit?: (value: any) => void;
debounce?: number;
}) => {
const [localValue, setLocalValue] = useState(value);
const [isEditing, setIsEditing] = useState(false);
useEffect(() => {
if (!isEditing) {
setLocalValue(value);
}
}, [value, isEditing]);
// 색상 입력 등을 위한 디바운스 커밋
useEffect(() => {
if (debounce > 0 && isEditing && onCommit) {
const timer = setTimeout(() => {
onCommit(type === "number" ? parseFloat(localValue as string) : localValue);
}, debounce);
return () => clearTimeout(timer);
}
}, [localValue, debounce, isEditing, onCommit, type]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setLocalValue(e.target.value);
if (onChange) onChange(e);
};
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
setIsEditing(false);
if (onCommit && debounce === 0) {
// 값이 변경되었을 때만 커밋하도록 하면 좋겠지만,
// 부모 상태와 비교하기 어려우므로 항상 커밋 (handleObjectUpdate 내부에서 처리됨)
onCommit(type === "number" ? parseFloat(localValue as string) : localValue);
}
if (props.onBlur) props.onBlur(e);
};
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
setIsEditing(true);
if (props.onFocus) props.onFocus(e);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.currentTarget.blur();
}
if (props.onKeyDown) props.onKeyDown(e);
};
return (
<Input
{...props}
type={type}
value={localValue}
onChange={handleChange}
onBlur={handleBlur}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
/>
);
};
// 백엔드 DB 객체 타입 (snake_case)
interface DbObject {
id: number;
@ -550,10 +621,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
areaKey: obj.area_key,
locaKey: obj.loca_key,
locType: obj.loc_type,
materialCount: obj.material_count,
materialPreview: obj.material_preview_height
? { height: parseFloat(obj.material_preview_height) }
: undefined,
materialCount: obj.loc_type === "STP" ? undefined : obj.material_count,
materialPreview:
obj.loc_type === "STP" || !obj.material_preview_height
? undefined
: { height: parseFloat(obj.material_preview_height) },
parentId: obj.parent_id,
displayOrder: obj.display_order,
locked: obj.locked,
@ -761,12 +833,9 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
// 기본 크기 설정
let objectSize = defaults.size || { x: 5, y: 5, z: 5 };
// Location 배치 시 자재 개수에 따라 높이 자동 설정
// Location 배치 시 자재 개수에 따라 높이 자동 설정 (BED/TMP/DES만 대상, STP는 자재 미적재)
if (
(draggedTool === "location-bed" ||
draggedTool === "location-stp" ||
draggedTool === "location-temp" ||
draggedTool === "location-dest") &&
(draggedTool === "location-bed" || draggedTool === "location-temp" || draggedTool === "location-dest") &&
locaKey &&
selectedDbConnection &&
hierarchyConfig?.material
@ -877,12 +946,9 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
setDraggedAreaData(null);
setDraggedLocationData(null);
// Location 배치 시 자재 개수 로드
// Location 배치 시 자재 개수 로드 (BED/TMP/DES만 대상, STP는 자재 미적재)
if (
(draggedTool === "location-bed" ||
draggedTool === "location-stp" ||
draggedTool === "location-temp" ||
draggedTool === "location-dest") &&
(draggedTool === "location-bed" || draggedTool === "location-temp" || draggedTool === "location-dest") &&
locaKey
) {
// 새 객체 추가 후 자재 개수 로드 (약간의 딜레이를 두어 state 업데이트 완료 후 실행)
@ -965,13 +1031,10 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
loadLocationsForArea(obj.areaKey);
setShowMaterialPanel(false);
}
// Location을 클릭한 경우, 해당 Location의 자재 목록 로드
// Location을 클릭한 경우, 해당 Location의 자재 목록 로드 (STP는 자재 미적재이므로 제외)
else if (
obj &&
(obj.type === "location-bed" ||
obj.type === "location-stp" ||
obj.type === "location-temp" ||
obj.type === "location-dest") &&
(obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") &&
obj.locaKey &&
selectedDbConnection
) {
@ -988,9 +1051,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
try {
const response = await getMaterialCounts(selectedDbConnection, selectedTables.material, locaKeys);
if (response.success && response.data) {
// 각 Location 객체에 자재 개수 업데이트
// 각 Location 객체에 자재 개수 업데이트 (STP는 자재 미적재이므로 제외)
setPlacedObjects((prev) =>
prev.map((obj) => {
if (
!obj.locaKey ||
obj.type === "location-stp" // STP는 자재 없음
) {
return obj;
}
const materialCount = response.data?.find((mc) => mc.LOCAKEY === obj.locaKey);
if (materialCount) {
return {
@ -1278,7 +1347,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
const oldSize = actualObject.size;
const newSize = { ...oldSize, ...updates.size };
// W, D를 5 단위로 스냅
// W, D를 5 단위로 스냅 (STP 포함)
newSize.x = Math.max(5, Math.round(newSize.x / 5) * 5);
newSize.z = Math.max(5, Math.round(newSize.z / 5) * 5);
@ -1391,10 +1460,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
areaKey: obj.area_key,
locaKey: obj.loca_key,
locType: obj.loc_type,
materialCount: obj.material_count,
materialPreview: obj.material_preview_height
? { height: parseFloat(obj.material_preview_height) }
: undefined,
materialCount: obj.loc_type === "STP" ? undefined : obj.material_count,
materialPreview:
obj.loc_type === "STP" || !obj.material_preview_height
? undefined
: { height: parseFloat(obj.material_preview_height) },
parentId: obj.parent_id,
displayOrder: obj.display_order,
locked: obj.locked,
@ -1798,6 +1868,8 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
</div>
{isLocationPlaced ? (
<Check className="h-4 w-4 text-green-500" />
) : locationType === "location-stp" ? (
<ParkingCircle className="text-muted-foreground h-4 w-4" />
) : (
<Package className="text-muted-foreground h-4 w-4" />
)}
@ -2069,10 +2141,10 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
<Label htmlFor="object-name" className="text-sm">
</Label>
<Input
<DebouncedInput
id="object-name"
value={selectedObject.name || ""}
onChange={(e) => handleObjectUpdate({ name: e.target.value })}
onCommit={(val) => handleObjectUpdate({ name: val })}
className="mt-1.5 h-9 text-sm"
/>
</div>
@ -2085,15 +2157,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
<Label htmlFor="pos-x" className="text-muted-foreground text-xs">
X
</Label>
<Input
<DebouncedInput
id="pos-x"
type="number"
value={(selectedObject.position?.x || 0).toFixed(1)}
onChange={(e) =>
onCommit={(val) =>
handleObjectUpdate({
position: {
...selectedObject.position,
x: parseFloat(e.target.value),
x: val,
},
})
}
@ -2104,15 +2176,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
<Label htmlFor="pos-z" className="text-muted-foreground text-xs">
Z
</Label>
<Input
<DebouncedInput
id="pos-z"
type="number"
value={(selectedObject.position?.z || 0).toFixed(1)}
onChange={(e) =>
onCommit={(val) =>
handleObjectUpdate({
position: {
...selectedObject.position,
z: parseFloat(e.target.value),
z: val,
},
})
}
@ -2130,17 +2202,17 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
<Label htmlFor="size-x" className="text-muted-foreground text-xs">
W (5 )
</Label>
<Input
<DebouncedInput
id="size-x"
type="number"
step="5"
min="5"
value={selectedObject.size?.x || 5}
onChange={(e) =>
onCommit={(val) =>
handleObjectUpdate({
size: {
...selectedObject.size,
x: parseFloat(e.target.value),
x: val,
},
})
}
@ -2151,15 +2223,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
<Label htmlFor="size-y" className="text-muted-foreground text-xs">
H
</Label>
<Input
<DebouncedInput
id="size-y"
type="number"
value={selectedObject.size?.y || 5}
onChange={(e) =>
onCommit={(val) =>
handleObjectUpdate({
size: {
...selectedObject.size,
y: parseFloat(e.target.value),
y: val,
},
})
}
@ -2170,17 +2242,17 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
<Label htmlFor="size-z" className="text-muted-foreground text-xs">
D (5 )
</Label>
<Input
<DebouncedInput
id="size-z"
type="number"
step="5"
min="5"
value={selectedObject.size?.z || 5}
onChange={(e) =>
onCommit={(val) =>
handleObjectUpdate({
size: {
...selectedObject.size,
z: parseFloat(e.target.value),
z: val,
},
})
}
@ -2195,11 +2267,12 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
<Label htmlFor="object-color" className="text-sm">
</Label>
<Input
<DebouncedInput
id="object-color"
type="color"
debounce={100}
value={selectedObject.color || "#3b82f6"}
onChange={(e) => handleObjectUpdate({ color: e.target.value })}
onCommit={(val) => handleObjectUpdate({ color: val })}
className="mt-1.5 h-9"
/>
</div>

View File

@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { Loader2, Search, X, Grid3x3, Package } from "lucide-react";
import { Loader2, Search, X, Grid3x3, Package, ParkingCircle } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
@ -87,10 +87,11 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
areaKey: obj.area_key,
locaKey: obj.loca_key,
locType: obj.loc_type,
materialCount: obj.material_count,
materialPreview: obj.material_preview_height
? { height: parseFloat(obj.material_preview_height) }
: undefined,
materialCount: obj.loc_type === "STP" ? undefined : obj.material_count,
materialPreview:
obj.loc_type === "STP" || !obj.material_preview_height
? undefined
: { height: parseFloat(obj.material_preview_height) },
parentId: obj.parent_id,
displayOrder: obj.display_order,
locked: obj.locked,
@ -166,13 +167,10 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
const obj = placedObjects.find((o) => o.id === objectId);
setSelectedObject(obj || null);
// Location을 클릭한 경우, 자재 정보 표시
// Location을 클릭한 경우, 자재 정보 표시 (STP는 자재 미적재이므로 제외)
if (
obj &&
(obj.type === "location-bed" ||
obj.type === "location-stp" ||
obj.type === "location-temp" ||
obj.type === "location-dest") &&
(obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") &&
obj.locaKey &&
externalDbConnectionId
) {
@ -363,59 +361,59 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
// Area가 없으면 기존 평면 리스트 유지
if (areaObjects.length === 0) {
return (
<div className="space-y-2">
{filteredObjects.map((obj) => {
let typeLabel = obj.type;
if (obj.type === "location-bed") typeLabel = "베드(BED)";
else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)";
else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)";
else if (obj.type === "location-dest") typeLabel = "지정착지(DES)";
else if (obj.type === "crane-mobile") typeLabel = "크레인";
else if (obj.type === "area") typeLabel = "Area";
else if (obj.type === "rack") typeLabel = "랙";
<div className="space-y-2">
{filteredObjects.map((obj) => {
let typeLabel = obj.type;
if (obj.type === "location-bed") typeLabel = "베드(BED)";
else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)";
else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)";
else if (obj.type === "location-dest") typeLabel = "지정착지(DES)";
else if (obj.type === "crane-mobile") typeLabel = "크레인";
else if (obj.type === "area") typeLabel = "Area";
else if (obj.type === "rack") typeLabel = "랙";
return (
<div
key={obj.id}
onClick={() => handleObjectClick(obj.id)}
className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${
selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm"
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm font-medium">{obj.name}</p>
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: obj.color }}
/>
<span>{typeLabel}</span>
</div>
</div>
</div>
<div className="mt-2 space-y-1">
{obj.areaKey && (
<p className="text-muted-foreground text-xs">
Area: <span className="font-medium">{obj.areaKey}</span>
</p>
)}
{obj.locaKey && (
<p className="text-muted-foreground text-xs">
Location: <span className="font-medium">{obj.locaKey}</span>
</p>
)}
{obj.materialCount !== undefined && obj.materialCount > 0 && (
<p className="text-xs text-yellow-600">
: <span className="font-semibold">{obj.materialCount}</span>
</p>
)}
</div>
return (
<div
key={obj.id}
onClick={() => handleObjectClick(obj.id)}
className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${
selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm"
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm font-medium">{obj.name}</p>
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: obj.color }}
/>
<span>{typeLabel}</span>
</div>
);
})}
</div>
</div>
<div className="mt-2 space-y-1">
{obj.areaKey && (
<p className="text-muted-foreground text-xs">
Area: <span className="font-medium">{obj.areaKey}</span>
</p>
)}
{obj.locaKey && (
<p className="text-muted-foreground text-xs">
Location: <span className="font-medium">{obj.locaKey}</span>
</p>
)}
{obj.materialCount !== undefined && obj.materialCount > 0 && (
<p className="text-xs text-yellow-600">
: <span className="font-semibold">{obj.materialCount}</span>
</p>
)}
</div>
</div>
);
})}
</div>
);
}
// Area가 있는 경우: Area → Location 계층 아코디언
@ -471,7 +469,11 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Package className="h-3 w-3" />
{locationObj.type === "location-stp" ? (
<ParkingCircle className="h-3 w-3" />
) : (
<Package className="h-3 w-3" />
)}
<span className="text-xs font-medium">{locationObj.name}</span>
</div>
<span

View File

@ -131,13 +131,13 @@ export default function HierarchyConfigPanel({
try {
await Promise.all(
tablesToFetch.map(async (tableName) => {
try {
const columns = await onLoadColumns(tableName);
const normalized = normalizeColumns(columns);
setColumnsCache((prev) => ({ ...prev, [tableName]: normalized }));
} catch (error) {
console.error(`컬럼 로드 실패 (${tableName}):`, error);
}
try {
const columns = await onLoadColumns(tableName);
const normalized = normalizeColumns(columns);
setColumnsCache((prev) => ({ ...prev, [tableName]: normalized }));
} catch (error) {
console.error(`컬럼 로드 실패 (${tableName}):`, error);
}
}),
);
} finally {

View File

@ -593,52 +593,58 @@ function MaterialBox({
);
case "location-stp":
// 정차포인트(STP): 주황색 낮은 플랫폼
return (
<>
<Box args={[boxWidth, boxHeight, boxDepth]}>
<meshStandardMaterial
color={placement.color}
roughness={0.6}
metalness={0.2}
emissive={isSelected ? placement.color : "#000000"}
emissiveIntensity={isSelected ? glowIntensity * 0.8 : 0}
/>
</Box>
// 정차포인트(STP): 회색 타원형 플랫폼 + 'P' 마크 (자재 미적재 영역)
{
const baseRadius = 0.5; // 스케일로 실제 W/D를 반영 (타원형)
const labelFontSize = Math.min(boxWidth, boxDepth) * 0.15;
const iconFontSize = Math.min(boxWidth, boxDepth) * 0.3;
{/* Location 이름 */}
{placement.name && (
return (
<>
{/* 타원형 플랫폼: 단위 실린더를 W/D로 스케일 */}
<mesh scale={[boxWidth, 1, boxDepth]}>
<cylinderGeometry args={[baseRadius, baseRadius, boxHeight, 32]} />
<meshStandardMaterial
color={placement.color}
roughness={0.6}
metalness={0.2}
emissive={isSelected ? placement.color : "#000000"}
emissiveIntensity={isSelected ? glowIntensity * 0.8 : 0}
/>
</mesh>
{/* 상단 'P' 마크 (주차 아이콘 역할) */}
<Text
position={[0, boxHeight / 2 + 0.3, 0]}
position={[0, boxHeight / 2 + 0.05, 0]}
rotation={[-Math.PI / 2, 0, 0]}
fontSize={Math.min(boxWidth, boxDepth) * 0.15}
fontSize={iconFontSize}
color="#ffffff"
anchorX="center"
anchorY="middle"
outlineWidth={0.03}
outlineWidth={0.08}
outlineColor="#000000"
>
{placement.name}
P
</Text>
)}
{/* 자재 개수 (STP는 정차포인트라 자재가 없을 수 있음) */}
{placement.material_count !== undefined && placement.material_count > 0 && (
<Text
position={[0, boxHeight / 2 + 0.6, 0]}
rotation={[-Math.PI / 2, 0, 0]}
fontSize={Math.min(boxWidth, boxDepth) * 0.12}
color="#fbbf24"
anchorX="center"
anchorY="middle"
outlineWidth={0.03}
outlineColor="#000000"
>
{`자재: ${placement.material_count}`}
</Text>
)}
</>
);
{/* Location 이름 */}
{placement.name && (
<Text
position={[0, boxHeight / 2 + 0.4, 0]}
rotation={[-Math.PI / 2, 0, 0]}
fontSize={labelFontSize}
color="#ffffff"
anchorX="center"
anchorY="middle"
outlineWidth={0.03}
outlineColor="#000000"
>
{placement.name}
</Text>
)}
</>
);
}
// case "gantry-crane":
// // 겐트리 크레인: 기둥 2개 + 상단 빔
@ -1098,10 +1104,12 @@ function Scene({
orbitControlsRef={orbitControlsRef}
/>
{/* 조명 */}
<ambientLight intensity={0.5} />
<directionalLight position={[10, 10, 5]} intensity={1} />
<directionalLight position={[-10, -10, -5]} intensity={0.3} />
{/* 조명 - 전체적으로 밝게 조정 */}
<ambientLight intensity={0.9} />
<directionalLight position={[10, 20, 10]} intensity={1.2} />
<directionalLight position={[-10, 20, -10]} intensity={0.8} />
<directionalLight position={[0, 20, 0]} intensity={0.5} />
<hemisphereLight args={["#ffffff", "#bbbbbb", 0.8]} />
{/* 배경색 */}
<color attach="background" args={["#f3f4f6"]} />

View File

@ -164,3 +164,4 @@ export function getAllDescendants(

View File

@ -120,13 +120,14 @@ class BatchManagementAPIClass {
apiUrl: string,
apiKey: string,
endpoint: string,
method: 'GET' = 'GET',
method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET',
paramInfo?: {
paramType: 'url' | 'query';
paramName: string;
paramValue: string;
paramSource: 'static' | 'dynamic';
}
},
requestBody?: string
): Promise<{
fields: string[];
samples: any[];
@ -137,7 +138,8 @@ class BatchManagementAPIClass {
apiUrl,
apiKey,
endpoint,
method
method,
requestBody
};
// 파라미터 정보가 있으면 추가

View File

@ -2,7 +2,7 @@
import { apiClient } from "./client";
export type AuthType = "none" | "api-key" | "bearer" | "basic" | "oauth2";
export type AuthType = "none" | "api-key" | "bearer" | "basic" | "oauth2" | "db-token";
export interface ExternalRestApiConnection {
id?: number;
@ -11,18 +11,34 @@ export interface ExternalRestApiConnection {
base_url: string;
endpoint_path?: string;
default_headers: Record<string, string>;
// 기본 메서드 및 바디 추가
default_method?: string;
default_body?: string;
auth_type: AuthType;
auth_config?: {
// API Key
keyLocation?: "header" | "query";
keyName?: string;
keyValue?: string;
// Bearer Token
token?: string;
// Basic Auth
username?: string;
password?: string;
// OAuth2
clientId?: string;
clientSecret?: string;
tokenUrl?: string;
accessToken?: string;
// DB 기반 토큰 모드
dbTableName?: string;
dbValueColumn?: string;
dbWhereColumn?: string;
dbWhereValue?: string;
dbHeaderName?: string;
dbHeaderTemplate?: string;
};
timeout?: number;
retry_count?: number;
@ -49,9 +65,11 @@ export interface RestApiTestRequest {
id?: number;
base_url: string;
endpoint?: string;
method?: "GET" | "POST" | "PUT" | "DELETE";
method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
headers?: Record<string, string>;
body?: unknown; // 테스트 요청 바디 추가
auth_type?: AuthType;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
auth_config?: any;
timeout?: number;
}
@ -61,7 +79,7 @@ export interface RestApiTestResult {
message: string;
response_time?: number;
status_code?: number;
response_data?: any;
response_data?: unknown;
error_details?: string;
}
@ -71,7 +89,7 @@ export interface ApiResponse<T> {
message?: string;
error?: {
code: string;
details?: any;
details?: unknown;
};
}
@ -184,6 +202,7 @@ export class ExternalRestApiConnectionAPI {
{ value: "bearer", label: "Bearer Token" },
{ value: "basic", label: "Basic Auth" },
{ value: "oauth2", label: "OAuth 2.0" },
{ value: "db-token", label: "DB 토큰" },
];
}
}