Merge pull request 'lhj' (#73) from lhj into dev
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/73
This commit is contained in:
commit
5a5af8d258
|
|
@ -0,0 +1,311 @@
|
|||
# 🎨 제어관리 - 데이터 연결 설정 UI 재설계 계획서
|
||||
|
||||
## 📋 프로젝트 개요
|
||||
|
||||
### 목표
|
||||
|
||||
- 기존 모달 기반 필드 매핑을 메인 화면으로 통합
|
||||
- 중복된 테이블 선택 과정 제거
|
||||
- 시각적 필드 연결 매핑 구현
|
||||
- 좌우 분할 레이아웃으로 정보 가시성 향상
|
||||
|
||||
### 현재 문제점
|
||||
|
||||
- ❌ **이중 작업**: 테이블을 3번 선택해야 함 (더블클릭 → 모달 → 재선택)
|
||||
- ❌ **혼란스러운 UX**: 사전 선택의 의미가 없어짐
|
||||
- ❌ **불필요한 모달**: 연결 설정이 메인 기능인데 숨겨져 있음
|
||||
- ❌ **시각적 피드백 부족**: 필드 매핑 관계가 명확하지 않음
|
||||
|
||||
## 🎯 새로운 UI 구조
|
||||
|
||||
### 레이아웃 구성
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 제어관리 - 데이터 연결 설정 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 좌측 패널 (30%) │ 우측 패널 (70%) │
|
||||
│ - 연결 타입 선택 │ - 단계별 설정 UI │
|
||||
│ - 매핑 정보 모니터링 │ - 시각적 필드 매핑 │
|
||||
│ - 상세 설정 목록 │ - 실시간 연결선 표시 │
|
||||
│ - 액션 버튼 │ - 드래그 앤 드롭 지원 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 🔧 구현 단계
|
||||
|
||||
### Phase 1: 기본 구조 구축
|
||||
|
||||
- [ ] 좌우 분할 레이아웃 컴포넌트 생성
|
||||
- [ ] 기존 모달 컴포넌트들을 메인 화면용으로 리팩토링
|
||||
- [ ] 연결 타입 선택 컴포넌트 구현
|
||||
|
||||
### Phase 2: 좌측 패널 구현
|
||||
|
||||
- [ ] 연결 타입 선택 (데이터 저장 / 외부 호출)
|
||||
- [ ] 실시간 매핑 정보 표시
|
||||
- [ ] 매핑 상세 목록 컴포넌트
|
||||
- [ ] 고급 설정 패널
|
||||
|
||||
### Phase 3: 우측 패널 구현
|
||||
|
||||
- [ ] 단계별 진행 UI (연결 → 테이블 → 매핑)
|
||||
- [ ] 시각적 필드 매핑 영역
|
||||
- [ ] SVG 기반 연결선 시스템
|
||||
- [ ] 드래그 앤 드롭 매핑 기능
|
||||
|
||||
### Phase 4: 고급 기능
|
||||
|
||||
- [ ] 실시간 검증 및 피드백
|
||||
- [ ] 매핑 미리보기 기능
|
||||
- [ ] 설정 저장/불러오기
|
||||
- [ ] 테스트 실행 기능
|
||||
|
||||
## 📁 파일 구조
|
||||
|
||||
### 새로 생성할 컴포넌트
|
||||
|
||||
```
|
||||
frontend/components/dataflow/connection/redesigned/
|
||||
├── DataConnectionDesigner.tsx # 메인 컨테이너
|
||||
├── LeftPanel/
|
||||
│ ├── ConnectionTypeSelector.tsx # 연결 타입 선택
|
||||
│ ├── MappingInfoPanel.tsx # 매핑 정보 표시
|
||||
│ ├── MappingDetailList.tsx # 매핑 상세 목록
|
||||
│ ├── AdvancedSettings.tsx # 고급 설정
|
||||
│ └── ActionButtons.tsx # 액션 버튼들
|
||||
├── RightPanel/
|
||||
│ ├── StepProgress.tsx # 단계 진행 표시
|
||||
│ ├── ConnectionStep.tsx # 1단계: 연결 선택
|
||||
│ ├── TableStep.tsx # 2단계: 테이블 선택
|
||||
│ ├── FieldMappingStep.tsx # 3단계: 필드 매핑
|
||||
│ └── VisualMapping/
|
||||
│ ├── FieldMappingCanvas.tsx # 시각적 매핑 캔버스
|
||||
│ ├── FieldColumn.tsx # 필드 컬럼 컴포넌트
|
||||
│ ├── ConnectionLine.tsx # SVG 연결선
|
||||
│ └── MappingControls.tsx # 매핑 제어 도구
|
||||
└── types/
|
||||
└── redesigned.ts # 타입 정의
|
||||
```
|
||||
|
||||
### 수정할 기존 파일
|
||||
|
||||
```
|
||||
frontend/components/dataflow/connection/
|
||||
├── DataSaveSettings.tsx # 새 UI로 교체
|
||||
├── ConnectionSelectionPanel.tsx # 재사용을 위한 리팩토링
|
||||
├── TableSelectionPanel.tsx # 재사용을 위한 리팩토링
|
||||
└── ActionFieldMappings.tsx # 레거시 처리
|
||||
```
|
||||
|
||||
## 🎨 UI 컴포넌트 상세
|
||||
|
||||
### 1. 연결 타입 선택 (ConnectionTypeSelector)
|
||||
|
||||
```typescript
|
||||
interface ConnectionType {
|
||||
id: "data_save" | "external_call";
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const connectionTypes: ConnectionType[] = [
|
||||
{
|
||||
id: "data_save",
|
||||
label: "데이터 저장",
|
||||
description: "INSERT/UPDATE/DELETE 작업",
|
||||
icon: <Database />,
|
||||
},
|
||||
{
|
||||
id: "external_call",
|
||||
label: "외부 호출",
|
||||
description: "API/Webhook 호출",
|
||||
icon: <Globe />,
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
### 2. 시각적 필드 매핑 (FieldMappingCanvas)
|
||||
|
||||
```typescript
|
||||
interface FieldMapping {
|
||||
id: string;
|
||||
fromField: ColumnInfo;
|
||||
toField: ColumnInfo;
|
||||
transformRule?: string;
|
||||
isValid: boolean;
|
||||
validationMessage?: string;
|
||||
}
|
||||
|
||||
interface MappingLine {
|
||||
id: string;
|
||||
fromX: number;
|
||||
fromY: number;
|
||||
toX: number;
|
||||
toY: number;
|
||||
isValid: boolean;
|
||||
isHovered: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 매핑 정보 패널 (MappingInfoPanel)
|
||||
|
||||
```typescript
|
||||
interface MappingStats {
|
||||
totalMappings: number;
|
||||
validMappings: number;
|
||||
invalidMappings: number;
|
||||
missingRequiredFields: number;
|
||||
estimatedRows: number;
|
||||
actionType: "INSERT" | "UPDATE" | "DELETE";
|
||||
}
|
||||
```
|
||||
|
||||
## 🔄 데이터 플로우
|
||||
|
||||
### 상태 관리
|
||||
|
||||
```typescript
|
||||
interface DataConnectionState {
|
||||
// 기본 설정
|
||||
connectionType: "data_save" | "external_call";
|
||||
currentStep: 1 | 2 | 3;
|
||||
|
||||
// 연결 정보
|
||||
fromConnection?: Connection;
|
||||
toConnection?: Connection;
|
||||
fromTable?: TableInfo;
|
||||
toTable?: TableInfo;
|
||||
|
||||
// 매핑 정보
|
||||
fieldMappings: FieldMapping[];
|
||||
mappingStats: MappingStats;
|
||||
|
||||
// UI 상태
|
||||
selectedMapping?: string;
|
||||
isLoading: boolean;
|
||||
validationErrors: ValidationError[];
|
||||
}
|
||||
```
|
||||
|
||||
### 이벤트 핸들링
|
||||
|
||||
```typescript
|
||||
interface DataConnectionActions {
|
||||
// 연결 타입
|
||||
setConnectionType: (type: "data_save" | "external_call") => void;
|
||||
|
||||
// 단계 진행
|
||||
goToStep: (step: 1 | 2 | 3) => void;
|
||||
|
||||
// 연결/테이블 선택
|
||||
selectConnection: (type: "from" | "to", connection: Connection) => void;
|
||||
selectTable: (type: "from" | "to", table: TableInfo) => void;
|
||||
|
||||
// 필드 매핑
|
||||
createMapping: (fromField: ColumnInfo, toField: ColumnInfo) => void;
|
||||
updateMapping: (mappingId: string, updates: Partial<FieldMapping>) => void;
|
||||
deleteMapping: (mappingId: string) => void;
|
||||
|
||||
// 검증 및 저장
|
||||
validateMappings: () => Promise<ValidationResult>;
|
||||
saveMappings: () => Promise<void>;
|
||||
testExecution: () => Promise<TestResult>;
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 사용자 경험 (UX) 개선점
|
||||
|
||||
### Before (기존)
|
||||
|
||||
1. 테이블 더블클릭 → 화면에 표시
|
||||
2. 모달 열기 → 다시 테이블 선택
|
||||
3. 외부 커넥션 설정 → 또 다시 테이블 선택
|
||||
4. 필드 매핑 → 텍스트 기반 매핑
|
||||
|
||||
### After (개선)
|
||||
|
||||
1. **연결 타입 선택** → 목적 명확화
|
||||
2. **연결 선택** → 한 번에 FROM/TO 설정
|
||||
3. **테이블 선택** → 즉시 필드 정보 로드
|
||||
4. **시각적 매핑** → 드래그 앤 드롭으로 직관적 연결
|
||||
|
||||
## 🚀 구현 우선순위
|
||||
|
||||
### 🔥 High Priority
|
||||
|
||||
1. **기본 레이아웃** - 좌우 분할 구조
|
||||
2. **연결 타입 선택** - 데이터 저장/외부 호출
|
||||
3. **단계별 진행** - 연결 → 테이블 → 매핑
|
||||
4. **기본 필드 매핑** - 드래그 앤 드롭 없이 클릭 기반
|
||||
|
||||
### 🔶 Medium Priority
|
||||
|
||||
1. **시각적 연결선** - SVG 기반 라인 표시
|
||||
2. **실시간 검증** - 타입 호환성 체크
|
||||
3. **매핑 정보 패널** - 통계 및 상태 표시
|
||||
4. **드래그 앤 드롭** - 고급 매핑 기능
|
||||
|
||||
### 🔵 Low Priority
|
||||
|
||||
1. **고급 설정** - 트랜잭션, 배치 설정
|
||||
2. **미리보기 기능** - 데이터 변환 미리보기
|
||||
3. **설정 템플릿** - 자주 사용하는 매핑 저장
|
||||
4. **성능 최적화** - 대용량 테이블 처리
|
||||
|
||||
## 📅 개발 일정
|
||||
|
||||
### Week 1: 기본 구조
|
||||
|
||||
- [ ] 레이아웃 컴포넌트 생성
|
||||
- [ ] 연결 타입 선택 구현
|
||||
- [ ] 기존 컴포넌트 리팩토링
|
||||
|
||||
### Week 2: 핵심 기능
|
||||
|
||||
- [ ] 단계별 진행 UI
|
||||
- [ ] 연결/테이블 선택 통합
|
||||
- [ ] 기본 필드 매핑 구현
|
||||
|
||||
### Week 3: 시각적 개선
|
||||
|
||||
- [ ] SVG 연결선 시스템
|
||||
- [ ] 드래그 앤 드롭 매핑
|
||||
- [ ] 실시간 검증 기능
|
||||
|
||||
### Week 4: 완성 및 테스트
|
||||
|
||||
- [ ] 고급 기능 구현
|
||||
- [ ] 통합 테스트
|
||||
- [ ] 사용자 테스트 및 피드백 반영
|
||||
|
||||
## 🔍 기술적 고려사항
|
||||
|
||||
### 성능 최적화
|
||||
|
||||
- **가상화**: 대용량 필드 목록 처리
|
||||
- **메모이제이션**: 불필요한 리렌더링 방지
|
||||
- **지연 로딩**: 필요한 시점에만 데이터 로드
|
||||
|
||||
### 접근성
|
||||
|
||||
- **키보드 네비게이션**: 모든 기능을 키보드로 접근 가능
|
||||
- **스크린 리더**: 시각적 매핑의 대체 텍스트 제공
|
||||
- **색상 대비**: 연결선과 상태 표시의 명확한 구분
|
||||
|
||||
### 확장성
|
||||
|
||||
- **플러그인 구조**: 새로운 연결 타입 쉽게 추가
|
||||
- **커스텀 변환**: 사용자 정의 데이터 변환 규칙
|
||||
- **API 확장**: 외부 시스템과의 연동 지원
|
||||
|
||||
---
|
||||
|
||||
## 🎯 다음 단계
|
||||
|
||||
이 계획서를 바탕으로 **Phase 1부터 순차적으로 구현**을 시작하겠습니다.
|
||||
|
||||
**첫 번째 작업**: 좌우 분할 레이아웃과 연결 타입 선택 컴포넌트 구현
|
||||
|
||||
구현을 시작하시겠어요? 🚀
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -27,7 +27,7 @@
|
|||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.7.1",
|
||||
"@prisma/client": "^6.16.2",
|
||||
"@types/mssql": "^9.1.8",
|
||||
"axios": "^1.11.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
|
|
@ -46,7 +46,6 @@
|
|||
"nodemailer": "^6.9.7",
|
||||
"oracledb": "^6.9.0",
|
||||
"pg": "^8.16.3",
|
||||
"prisma": "^5.7.1",
|
||||
"redis": "^4.6.10",
|
||||
"winston": "^3.11.0"
|
||||
},
|
||||
|
|
@ -73,6 +72,7 @@
|
|||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.1.10",
|
||||
"prettier": "^3.1.0",
|
||||
"prisma": "^6.16.2",
|
||||
"supertest": "^6.3.3",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-node": "^10.9.2",
|
||||
|
|
|
|||
|
|
@ -50,27 +50,27 @@ model db_type_categories {
|
|||
}
|
||||
|
||||
model external_db_connections {
|
||||
id Int @id @default(autoincrement())
|
||||
connection_name String @db.VarChar(100)
|
||||
id Int @id @default(autoincrement())
|
||||
connection_name String @db.VarChar(100)
|
||||
description String?
|
||||
db_type String @db.VarChar(20)
|
||||
host String @db.VarChar(255)
|
||||
db_type String @db.VarChar(20)
|
||||
host String @db.VarChar(255)
|
||||
port Int
|
||||
database_name String @db.VarChar(100)
|
||||
username String @db.VarChar(100)
|
||||
database_name String @db.VarChar(100)
|
||||
username String @db.VarChar(100)
|
||||
password String
|
||||
connection_timeout Int? @default(30)
|
||||
query_timeout Int? @default(60)
|
||||
max_connections Int? @default(10)
|
||||
ssl_enabled String? @default("N") @db.Char(1)
|
||||
ssl_cert_path String? @db.VarChar(500)
|
||||
connection_timeout Int? @default(30)
|
||||
query_timeout Int? @default(60)
|
||||
max_connections Int? @default(10)
|
||||
ssl_enabled String? @default("N") @db.Char(1)
|
||||
ssl_cert_path String? @db.VarChar(500)
|
||||
connection_options Json?
|
||||
company_code String? @default("*") @db.VarChar(20)
|
||||
is_active String? @default("Y") @db.Char(1)
|
||||
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
created_by String? @db.VarChar(50)
|
||||
updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6)
|
||||
updated_by String? @db.VarChar(50)
|
||||
company_code String? @default("*") @db.VarChar(20)
|
||||
is_active String? @default("Y") @db.Char(1)
|
||||
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
created_by String? @db.VarChar(50)
|
||||
updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6)
|
||||
updated_by String? @db.VarChar(50)
|
||||
|
||||
// 관계
|
||||
db_type_category db_type_categories? @relation(fields: [db_type], references: [type_code])
|
||||
|
|
@ -80,6 +80,83 @@ model external_db_connections {
|
|||
@@index([db_type], map: "idx_external_db_connections_db_type")
|
||||
}
|
||||
|
||||
model batch_configs {
|
||||
id Int @id @default(autoincrement())
|
||||
batch_name String @db.VarChar(100)
|
||||
description String?
|
||||
cron_schedule String @db.VarChar(50)
|
||||
is_active String? @default("Y") @db.Char(1)
|
||||
company_code String? @default("*") @db.VarChar(20)
|
||||
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
created_by String? @db.VarChar(50)
|
||||
updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6)
|
||||
updated_by String? @db.VarChar(50)
|
||||
|
||||
// 관계 설정
|
||||
batch_mappings batch_mappings[]
|
||||
execution_logs batch_execution_logs[]
|
||||
|
||||
@@index([batch_name], map: "idx_batch_configs_name")
|
||||
@@index([is_active], map: "idx_batch_configs_active")
|
||||
}
|
||||
|
||||
model batch_mappings {
|
||||
id Int @id @default(autoincrement())
|
||||
batch_config_id Int
|
||||
from_connection_type String @db.VarChar(20)
|
||||
from_connection_id Int?
|
||||
from_table_name String @db.VarChar(100)
|
||||
from_column_name String @db.VarChar(100)
|
||||
from_column_type String? @db.VarChar(50)
|
||||
from_api_url String? @db.VarChar(500)
|
||||
from_api_key String? @db.VarChar(200)
|
||||
from_api_method String? @db.VarChar(10)
|
||||
to_connection_type String @db.VarChar(20)
|
||||
to_connection_id Int?
|
||||
to_table_name String @db.VarChar(100)
|
||||
to_column_name String @db.VarChar(100)
|
||||
to_column_type String? @db.VarChar(50)
|
||||
to_api_url String? @db.VarChar(500)
|
||||
to_api_key String? @db.VarChar(200)
|
||||
to_api_method String? @db.VarChar(10)
|
||||
to_api_body String? @db.Text
|
||||
mapping_order Int? @default(1)
|
||||
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||
created_by String? @db.VarChar(50)
|
||||
|
||||
// 관계 설정
|
||||
batch_config batch_configs @relation(fields: [batch_config_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([batch_config_id], map: "idx_batch_mappings_config")
|
||||
@@index([from_connection_type, from_connection_id], map: "idx_batch_mappings_from")
|
||||
@@index([to_connection_type, to_connection_id], map: "idx_batch_mappings_to")
|
||||
@@index([from_connection_type, from_api_url], map: "idx_batch_mappings_from_api")
|
||||
@@index([to_connection_type, to_api_url], map: "idx_batch_mappings_to_api")
|
||||
}
|
||||
|
||||
model batch_execution_logs {
|
||||
id Int @id @default(autoincrement())
|
||||
batch_config_id Int
|
||||
execution_status String @db.VarChar(20)
|
||||
start_time DateTime @default(now()) @db.Timestamp(6)
|
||||
end_time DateTime? @db.Timestamp(6)
|
||||
duration_ms Int?
|
||||
total_records Int? @default(0)
|
||||
success_records Int? @default(0)
|
||||
failed_records Int? @default(0)
|
||||
error_message String?
|
||||
error_details String?
|
||||
server_name String? @db.VarChar(100)
|
||||
process_id String? @db.VarChar(50)
|
||||
|
||||
// 관계 설정
|
||||
batch_config batch_configs @relation(fields: [batch_config_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([batch_config_id], map: "idx_batch_execution_logs_config")
|
||||
@@index([execution_status], map: "idx_batch_execution_logs_status")
|
||||
@@index([start_time], map: "idx_batch_execution_logs_start_time")
|
||||
}
|
||||
|
||||
model admin_supply_mng {
|
||||
objid Decimal @id @default(0) @db.Decimal
|
||||
supply_code String? @default("NULL::character varying") @db.VarChar(100)
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import commonCodeRoutes from "./routes/commonCodeRoutes";
|
|||
import dynamicFormRoutes from "./routes/dynamicFormRoutes";
|
||||
import fileRoutes from "./routes/fileRoutes";
|
||||
import companyManagementRoutes from "./routes/companyManagementRoutes";
|
||||
// import dataflowRoutes from "./routes/dataflowRoutes"; // 임시 주석
|
||||
import dataflowRoutes from "./routes/dataflowRoutes";
|
||||
import dataflowDiagramRoutes from "./routes/dataflowDiagramRoutes";
|
||||
import webTypeStandardRoutes from "./routes/webTypeStandardRoutes";
|
||||
import buttonActionStandardRoutes from "./routes/buttonActionStandardRoutes";
|
||||
|
|
@ -33,9 +33,16 @@ import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
|
|||
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
|
||||
import multiConnectionRoutes from "./routes/multiConnectionRoutes";
|
||||
import screenFileRoutes from "./routes/screenFileRoutes";
|
||||
import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes";
|
||||
//import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes";
|
||||
import batchRoutes from "./routes/batchRoutes";
|
||||
import batchManagementRoutes from "./routes/batchManagementRoutes";
|
||||
import batchExecutionLogRoutes from "./routes/batchExecutionLogRoutes";
|
||||
// import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes"; // 파일이 존재하지 않음
|
||||
import ddlRoutes from "./routes/ddlRoutes";
|
||||
import entityReferenceRoutes from "./routes/entityReferenceRoutes";
|
||||
import externalCallRoutes from "./routes/externalCallRoutes";
|
||||
import externalCallConfigRoutes from "./routes/externalCallConfigRoutes";
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
// import userRoutes from './routes/userRoutes';
|
||||
|
|
@ -44,7 +51,14 @@ import entityReferenceRoutes from "./routes/entityReferenceRoutes";
|
|||
const app = express();
|
||||
|
||||
// 기본 미들웨어
|
||||
app.use(helmet());
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
...helmet.contentSecurityPolicy.getDefaultDirectives(),
|
||||
"frame-ancestors": ["'self'", "http://localhost:9771", "http://localhost:3000"], // 프론트엔드 도메인 허용
|
||||
},
|
||||
},
|
||||
}));
|
||||
app.use(compression());
|
||||
app.use(express.json({ limit: "10mb" }));
|
||||
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
|
||||
|
|
@ -89,13 +103,20 @@ app.use(
|
|||
// Rate Limiting (개발 환경에서는 완화)
|
||||
const limiter = rateLimit({
|
||||
windowMs: 1 * 60 * 1000, // 1분
|
||||
max: config.nodeEnv === "development" ? 1000 : 100, // 개발환경에서는 1000, 운영환경에서는 100
|
||||
max: config.nodeEnv === "development" ? 10000 : 1000, // 개발환경에서는 10000으로 증가, 운영환경에서는 100
|
||||
message: {
|
||||
error: "너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요.",
|
||||
},
|
||||
skip: (req) => {
|
||||
// 헬스 체크는 Rate Limiting 제외
|
||||
return req.path === "/health";
|
||||
// 헬스 체크와 자주 호출되는 API들은 Rate Limiting 완화
|
||||
return (
|
||||
req.path === "/health" ||
|
||||
req.path.includes("/table-management/") ||
|
||||
req.path.includes("/external-db-connections/") ||
|
||||
req.path.includes("/screen-management/") ||
|
||||
req.path.includes("/multi-connection/") ||
|
||||
req.path.includes("/dataflow-diagrams/")
|
||||
);
|
||||
},
|
||||
});
|
||||
app.use("/api/", limiter);
|
||||
|
|
@ -121,7 +142,7 @@ app.use("/api/common-codes", commonCodeRoutes);
|
|||
app.use("/api/dynamic-form", dynamicFormRoutes);
|
||||
app.use("/api/files", fileRoutes);
|
||||
app.use("/api/company-management", companyManagementRoutes);
|
||||
// app.use("/api/dataflow", dataflowRoutes); // 임시 주석
|
||||
app.use("/api/dataflow", dataflowRoutes);
|
||||
app.use("/api/dataflow-diagrams", dataflowDiagramRoutes);
|
||||
app.use("/api/admin/web-types", webTypeStandardRoutes);
|
||||
app.use("/api/admin/button-actions", buttonActionStandardRoutes);
|
||||
|
|
@ -134,9 +155,14 @@ app.use("/api/test-button-dataflow", testButtonDataflowRoutes);
|
|||
app.use("/api/external-db-connections", externalDbConnectionRoutes);
|
||||
app.use("/api/multi-connection", multiConnectionRoutes);
|
||||
app.use("/api/screen-files", screenFileRoutes);
|
||||
app.use("/api/db-type-categories", dbTypeCategoryRoutes);
|
||||
app.use("/api/batch-configs", batchRoutes);
|
||||
app.use("/api/batch-management", batchManagementRoutes);
|
||||
app.use("/api/batch-execution-logs", batchExecutionLogRoutes);
|
||||
// app.use("/api/db-type-categories", dbTypeCategoryRoutes); // 파일이 존재하지 않음
|
||||
app.use("/api/ddl", ddlRoutes);
|
||||
app.use("/api/entity-reference", entityReferenceRoutes);
|
||||
app.use("/api/external-calls", externalCallRoutes);
|
||||
app.use("/api/external-call-configs", externalCallConfigRoutes);
|
||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||
// app.use('/api/users', userRoutes);
|
||||
|
|
@ -158,11 +184,19 @@ app.use(errorHandler);
|
|||
const PORT = config.port;
|
||||
const HOST = config.host;
|
||||
|
||||
app.listen(PORT, HOST, () => {
|
||||
app.listen(PORT, HOST, async () => {
|
||||
logger.info(`🚀 Server is running on ${HOST}:${PORT}`);
|
||||
logger.info(`📊 Environment: ${config.nodeEnv}`);
|
||||
logger.info(`🔗 Health check: http://${HOST}:${PORT}/health`);
|
||||
logger.info(`🌐 External access: http://39.117.244.52:${PORT}/health`);
|
||||
|
||||
// 배치 스케줄러 초기화
|
||||
try {
|
||||
await BatchSchedulerService.initialize();
|
||||
logger.info(`⏰ 배치 스케줄러가 시작되었습니다.`);
|
||||
} catch (error) {
|
||||
logger.error(`❌ 배치 스케줄러 초기화 실패:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
|
|
|
|||
|
|
@ -1,15 +1,20 @@
|
|||
import { PrismaClient } from "@prisma/client";
|
||||
import config from "./environment";
|
||||
|
||||
// Prisma 클라이언트 인스턴스 생성
|
||||
const prisma = new PrismaClient({
|
||||
datasources: {
|
||||
db: {
|
||||
url: config.databaseUrl,
|
||||
// Prisma 클라이언트 생성 함수
|
||||
function createPrismaClient() {
|
||||
return new PrismaClient({
|
||||
datasources: {
|
||||
db: {
|
||||
url: config.databaseUrl,
|
||||
},
|
||||
},
|
||||
},
|
||||
log: config.debug ? ["query", "info", "warn", "error"] : ["error"],
|
||||
});
|
||||
log: config.debug ? ["query", "info", "warn", "error"] : ["error"],
|
||||
});
|
||||
}
|
||||
|
||||
// 단일 인스턴스 생성
|
||||
const prisma = createPrismaClient();
|
||||
|
||||
// 데이터베이스 연결 테스트
|
||||
async function testConnection() {
|
||||
|
|
@ -41,4 +46,5 @@ if (config.nodeEnv === "development") {
|
|||
testConnection();
|
||||
}
|
||||
|
||||
export default prisma;
|
||||
// 기본 내보내기
|
||||
export = prisma;
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ const getCorsOrigin = (): string[] | boolean => {
|
|||
|
||||
const config: Config = {
|
||||
// 서버 설정
|
||||
port: parseInt(process.env.PORT || "3000", 10),
|
||||
port: parseInt(process.env.PORT || "8080", 10),
|
||||
host: process.env.HOST || "0.0.0.0",
|
||||
nodeEnv: process.env.NODE_ENV || "development",
|
||||
|
||||
|
|
|
|||
|
|
@ -1523,7 +1523,7 @@ export const getUserInfo = async (req: AuthenticatedRequest, res: Response) => {
|
|||
partnerObjid: user.partner_objid,
|
||||
rank: user.rank,
|
||||
photo: user.photo
|
||||
? `data:image/jpeg;base64,${user.photo.toString("base64")}`
|
||||
? `data:image/jpeg;base64,${Buffer.from(user.photo).toString("base64")}`
|
||||
: null,
|
||||
locale: user.locale,
|
||||
companyCode: user.company_code,
|
||||
|
|
@ -2415,7 +2415,7 @@ export const updateProfile = async (
|
|||
const responseData = {
|
||||
...updatedUser,
|
||||
photo: updatedUser?.photo
|
||||
? `data:image/jpeg;base64,${updatedUser.photo.toString("base64")}`
|
||||
? `data:image/jpeg;base64,${Buffer.from(updatedUser.photo).toString("base64")}`
|
||||
: null,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,294 +1,281 @@
|
|||
// 배치 관리 컨트롤러
|
||||
// 작성일: 2024-12-23
|
||||
// 배치관리 컨트롤러
|
||||
// 작성일: 2024-12-24
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { BatchService } from '../services/batchService';
|
||||
import { BatchJob, BatchJobFilter } from '../types/batchManagement';
|
||||
import { AuthenticatedRequest } from '../middleware/authMiddleware';
|
||||
import { Request, Response } from "express";
|
||||
import { BatchService } from "../services/batchService";
|
||||
import { BatchConfigFilter, CreateBatchConfigRequest, UpdateBatchConfigRequest } from "../types/batchTypes";
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user?: {
|
||||
userId: string;
|
||||
username: string;
|
||||
companyCode: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class BatchController {
|
||||
/**
|
||||
* 배치 작업 목록 조회
|
||||
* 배치 설정 목록 조회
|
||||
* GET /api/batch-configs
|
||||
*/
|
||||
static async getBatchJobs(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
static async getBatchConfigs(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const filter: BatchJobFilter = {
|
||||
job_name: req.query.job_name as string,
|
||||
job_type: req.query.job_type as string,
|
||||
is_active: req.query.is_active as string,
|
||||
company_code: req.user?.companyCode || '*',
|
||||
search: req.query.search as string,
|
||||
const { page = 1, limit = 10, search, isActive } = req.query;
|
||||
|
||||
const filter: BatchConfigFilter = {
|
||||
page: Number(page),
|
||||
limit: Number(limit),
|
||||
search: search as string,
|
||||
is_active: isActive as string
|
||||
};
|
||||
|
||||
const jobs = await BatchService.getBatchJobs(filter);
|
||||
|
||||
res.status(200).json({
|
||||
const result = await BatchService.getBatchConfigs(filter);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: jobs,
|
||||
message: '배치 작업 목록을 조회했습니다.',
|
||||
data: result.data,
|
||||
pagination: result.pagination
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('배치 작업 목록 조회 오류:', error);
|
||||
console.error("배치 설정 목록 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '배치 작업 목록 조회에 실패했습니다.',
|
||||
message: "배치 설정 목록 조회에 실패했습니다."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 작업 상세 조회
|
||||
* 사용 가능한 커넥션 목록 조회
|
||||
* GET /api/batch-configs/connections
|
||||
*/
|
||||
static async getBatchJobById(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
static async getAvailableConnections(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({
|
||||
const result = await BatchService.getAvailableConnections();
|
||||
|
||||
if (result.success) {
|
||||
res.json(result);
|
||||
} else {
|
||||
res.status(500).json(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("커넥션 목록 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "커넥션 목록 조회에 실패했습니다."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 목록 조회 (내부/외부 DB)
|
||||
* GET /api/batch-configs/connections/:type/tables
|
||||
* GET /api/batch-configs/connections/:type/:id/tables
|
||||
*/
|
||||
static async getTablesFromConnection(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { type, id } = req.params;
|
||||
|
||||
if (!type || (type !== 'internal' && type !== 'external')) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '유효하지 않은 ID입니다.',
|
||||
message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const job = await BatchService.getBatchJobById(id);
|
||||
if (!job) {
|
||||
res.status(404).json({
|
||||
const connectionId = type === 'external' ? Number(id) : undefined;
|
||||
const result = await BatchService.getTablesFromConnection(type, connectionId);
|
||||
|
||||
if (result.success) {
|
||||
return res.json(result);
|
||||
} else {
|
||||
return res.status(500).json(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "테이블 목록 조회에 실패했습니다."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 컬럼 정보 조회 (내부/외부 DB)
|
||||
* GET /api/batch-configs/connections/:type/tables/:tableName/columns
|
||||
* GET /api/batch-configs/connections/:type/:id/tables/:tableName/columns
|
||||
*/
|
||||
static async getTableColumns(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { type, id, tableName } = req.params;
|
||||
|
||||
if (!type || !tableName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '배치 작업을 찾을 수 없습니다.',
|
||||
message: "연결 타입과 테이블명을 모두 지정해주세요."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: job,
|
||||
message: '배치 작업을 조회했습니다.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('배치 작업 조회 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '배치 작업 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 작업 생성
|
||||
*/
|
||||
static async createBatchJob(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const data: BatchJob = {
|
||||
...req.body,
|
||||
company_code: req.user?.companyCode || '*',
|
||||
created_by: req.user?.userId,
|
||||
};
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!data.job_name || !data.job_type) {
|
||||
res.status(400).json({
|
||||
if (type !== 'internal' && type !== 'external') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '필수 필드가 누락되었습니다.',
|
||||
message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const job = await BatchService.createBatchJob(data);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: job,
|
||||
message: '배치 작업을 생성했습니다.',
|
||||
});
|
||||
const connectionId = type === 'external' ? Number(id) : undefined;
|
||||
const result = await BatchService.getTableColumns(type, connectionId, tableName);
|
||||
|
||||
if (result.success) {
|
||||
return res.json(result);
|
||||
} else {
|
||||
return res.status(500).json(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('배치 작업 생성 오류:', error);
|
||||
res.status(500).json({
|
||||
console.error("컬럼 정보 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '배치 작업 생성에 실패했습니다.',
|
||||
message: "컬럼 정보 조회에 실패했습니다."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 작업 수정
|
||||
* 특정 배치 설정 조회
|
||||
* GET /api/batch-configs/:id
|
||||
*/
|
||||
static async updateBatchJob(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
static async getBatchConfigById(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({
|
||||
const { id } = req.params;
|
||||
const batchConfig = await BatchService.getBatchConfigById(Number(id));
|
||||
|
||||
if (!batchConfig) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '유효하지 않은 ID입니다.',
|
||||
message: "배치 설정을 찾을 수 없습니다."
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: batchConfig
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("배치 설정 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 설정 조회에 실패했습니다."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 설정 생성
|
||||
* POST /api/batch-configs
|
||||
*/
|
||||
static async createBatchConfig(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { batchName, description, cronSchedule, mappings } = req.body;
|
||||
|
||||
if (!batchName || !cronSchedule || !mappings || !Array.isArray(mappings)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다. (batchName, cronSchedule, mappings)"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const data: Partial<BatchJob> = {
|
||||
...req.body,
|
||||
updated_by: req.user?.userId,
|
||||
};
|
||||
|
||||
const job = await BatchService.updateBatchJob(id, data);
|
||||
|
||||
res.status(200).json({
|
||||
const batchConfig = await BatchService.createBatchConfig({
|
||||
batchName,
|
||||
description,
|
||||
cronSchedule,
|
||||
mappings
|
||||
} as CreateBatchConfigRequest);
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: job,
|
||||
message: '배치 작업을 수정했습니다.',
|
||||
data: batchConfig,
|
||||
message: "배치 설정이 성공적으로 생성되었습니다."
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('배치 작업 수정 오류:', error);
|
||||
res.status(500).json({
|
||||
console.error("배치 설정 생성 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '배치 작업 수정에 실패했습니다.',
|
||||
message: "배치 설정 생성에 실패했습니다."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 작업 삭제
|
||||
* 배치 설정 수정
|
||||
* PUT /api/batch-configs/:id
|
||||
*/
|
||||
static async deleteBatchJob(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
static async updateBatchConfig(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({
|
||||
const { id } = req.params;
|
||||
const { batchName, description, cronSchedule, mappings, isActive } = req.body;
|
||||
|
||||
if (!batchName || !cronSchedule) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '유효하지 않은 ID입니다.',
|
||||
message: "필수 필드가 누락되었습니다. (batchName, cronSchedule)"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await BatchService.deleteBatchJob(id);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: '배치 작업을 삭제했습니다.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('배치 작업 삭제 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '배치 작업 삭제에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 작업 수동 실행
|
||||
*/
|
||||
static async executeBatchJob(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({
|
||||
const batchConfig = await BatchService.updateBatchConfig(Number(id), {
|
||||
batchName,
|
||||
description,
|
||||
cronSchedule,
|
||||
mappings,
|
||||
isActive
|
||||
} as UpdateBatchConfigRequest);
|
||||
|
||||
if (!batchConfig) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '유효하지 않은 ID입니다.',
|
||||
message: "배치 설정을 찾을 수 없습니다."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const execution = await BatchService.executeBatchJob(id);
|
||||
|
||||
res.status(200).json({
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: execution,
|
||||
message: '배치 작업을 실행했습니다.',
|
||||
data: batchConfig,
|
||||
message: "배치 설정이 성공적으로 수정되었습니다."
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('배치 작업 실행 오류:', error);
|
||||
res.status(500).json({
|
||||
console.error("배치 설정 수정 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '배치 작업 실행에 실패했습니다.',
|
||||
message: "배치 설정 수정에 실패했습니다."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 실행 목록 조회
|
||||
* 배치 설정 삭제 (논리 삭제)
|
||||
* DELETE /api/batch-configs/:id
|
||||
*/
|
||||
static async getBatchExecutions(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
static async deleteBatchConfig(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const jobId = req.query.job_id ? parseInt(req.query.job_id as string) : undefined;
|
||||
const executions = await BatchService.getBatchExecutions(jobId);
|
||||
|
||||
res.status(200).json({
|
||||
const { id } = req.params;
|
||||
const result = await BatchService.deleteBatchConfig(Number(id));
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "배치 설정을 찾을 수 없습니다."
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: executions,
|
||||
message: '배치 실행 목록을 조회했습니다.',
|
||||
message: "배치 설정이 성공적으로 삭제되었습니다."
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('배치 실행 목록 조회 오류:', error);
|
||||
res.status(500).json({
|
||||
console.error("배치 설정 삭제 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '배치 실행 목록 조회에 실패했습니다.',
|
||||
message: "배치 설정 삭제에 실패했습니다."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 모니터링 정보 조회
|
||||
*/
|
||||
static async getBatchMonitoring(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const monitoring = await BatchService.getBatchMonitoring();
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: monitoring,
|
||||
message: '배치 모니터링 정보를 조회했습니다.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('배치 모니터링 조회 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '배치 모니터링 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 지원되는 작업 타입 조회
|
||||
*/
|
||||
static async getSupportedJobTypes(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { BATCH_JOB_TYPE_OPTIONS } = await import('../types/batchManagement');
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
types: BATCH_JOB_TYPE_OPTIONS,
|
||||
},
|
||||
message: '지원하는 작업 타입 목록을 조회했습니다.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('작업 타입 조회 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '작업 타입 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 스케줄 프리셋 조회
|
||||
*/
|
||||
static async getSchedulePresets(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { SCHEDULE_PRESETS } = await import('../types/batchManagement');
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
presets: SCHEDULE_PRESETS,
|
||||
},
|
||||
message: '스케줄 프리셋 목록을 조회했습니다.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('스케줄 프리셋 조회 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '스케줄 프리셋 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
// 배치 실행 로그 컨트롤러
|
||||
// 작성일: 2024-12-24
|
||||
|
||||
import { Request, Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { BatchExecutionLogService } from "../services/batchExecutionLogService";
|
||||
import { BatchExecutionLogFilter, CreateBatchExecutionLogRequest, UpdateBatchExecutionLogRequest } from "../types/batchExecutionLogTypes";
|
||||
|
||||
export class BatchExecutionLogController {
|
||||
/**
|
||||
* 배치 실행 로그 목록 조회
|
||||
*/
|
||||
static async getExecutionLogs(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const {
|
||||
batch_config_id,
|
||||
execution_status,
|
||||
start_date,
|
||||
end_date,
|
||||
page,
|
||||
limit
|
||||
} = req.query;
|
||||
|
||||
const filter: BatchExecutionLogFilter = {
|
||||
batch_config_id: batch_config_id ? Number(batch_config_id) : undefined,
|
||||
execution_status: execution_status as string,
|
||||
start_date: start_date ? new Date(start_date as string) : undefined,
|
||||
end_date: end_date ? new Date(end_date as string) : undefined,
|
||||
page: page ? Number(page) : undefined,
|
||||
limit: limit ? Number(limit) : undefined
|
||||
};
|
||||
|
||||
const result = await BatchExecutionLogService.getExecutionLogs(filter);
|
||||
|
||||
if (result.success) {
|
||||
res.json(result);
|
||||
} else {
|
||||
res.status(500).json(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("배치 실행 로그 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 실행 로그 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 실행 로그 생성
|
||||
*/
|
||||
static async createExecutionLog(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const data: CreateBatchExecutionLogRequest = req.body;
|
||||
|
||||
const result = await BatchExecutionLogService.createExecutionLog(data);
|
||||
|
||||
if (result.success) {
|
||||
res.status(201).json(result);
|
||||
} else {
|
||||
res.status(500).json(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("배치 실행 로그 생성 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 실행 로그 생성 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 실행 로그 업데이트
|
||||
*/
|
||||
static async updateExecutionLog(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const data: UpdateBatchExecutionLogRequest = req.body;
|
||||
|
||||
const result = await BatchExecutionLogService.updateExecutionLog(Number(id), data);
|
||||
|
||||
if (result.success) {
|
||||
res.json(result);
|
||||
} else {
|
||||
res.status(500).json(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("배치 실행 로그 업데이트 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 실행 로그 업데이트 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 실행 로그 삭제
|
||||
*/
|
||||
static async deleteExecutionLog(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await BatchExecutionLogService.deleteExecutionLog(Number(id));
|
||||
|
||||
if (result.success) {
|
||||
res.json(result);
|
||||
} else {
|
||||
res.status(500).json(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("배치 실행 로그 삭제 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 실행 로그 삭제 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 배치의 최신 실행 로그 조회
|
||||
*/
|
||||
static async getLatestExecutionLog(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { batchConfigId } = req.params;
|
||||
|
||||
const result = await BatchExecutionLogService.getLatestExecutionLog(Number(batchConfigId));
|
||||
|
||||
if (result.success) {
|
||||
res.json(result);
|
||||
} else {
|
||||
res.status(500).json(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("최신 배치 실행 로그 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "최신 배치 실행 로그 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 실행 통계 조회
|
||||
*/
|
||||
static async getExecutionStats(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const {
|
||||
batch_config_id,
|
||||
start_date,
|
||||
end_date
|
||||
} = req.query;
|
||||
|
||||
const result = await BatchExecutionLogService.getExecutionStats(
|
||||
batch_config_id ? Number(batch_config_id) : undefined,
|
||||
start_date ? new Date(start_date as string) : undefined,
|
||||
end_date ? new Date(end_date as string) : undefined
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
res.json(result);
|
||||
} else {
|
||||
res.status(500).json(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("배치 실행 통계 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 실행 통계 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,619 @@
|
|||
// 배치관리 전용 컨트롤러 (기존 소스와 완전 분리)
|
||||
// 작성일: 2024-12-24
|
||||
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { BatchManagementService, BatchConnectionInfo, BatchTableInfo, BatchColumnInfo } from "../services/batchManagementService";
|
||||
import { BatchService } from "../services/batchService";
|
||||
import { BatchSchedulerService } from "../services/batchSchedulerService";
|
||||
import { BatchExternalDbService } from "../services/batchExternalDbService";
|
||||
import { CreateBatchConfigRequest, BatchConfig } from "../types/batchTypes";
|
||||
|
||||
export class BatchManagementController {
|
||||
/**
|
||||
* 사용 가능한 커넥션 목록 조회
|
||||
*/
|
||||
static async getAvailableConnections(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const result = await BatchManagementService.getAvailableConnections();
|
||||
if (result.success) {
|
||||
res.json(result);
|
||||
} else {
|
||||
res.status(500).json(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("커넥션 목록 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "커넥션 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 커넥션의 테이블 목록 조회
|
||||
*/
|
||||
static async getTablesFromConnection(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { type, id } = req.params;
|
||||
|
||||
if (type !== 'internal' && type !== 'external') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)"
|
||||
});
|
||||
}
|
||||
|
||||
const connectionId = type === 'external' ? Number(id) : undefined;
|
||||
const result = await BatchManagementService.getTablesFromConnection(type, connectionId);
|
||||
|
||||
if (result.success) {
|
||||
return res.json(result);
|
||||
} else {
|
||||
return res.status(500).json(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "테이블 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 테이블의 컬럼 정보 조회
|
||||
*/
|
||||
static async getTableColumns(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { type, id, tableName } = req.params;
|
||||
|
||||
if (type !== 'internal' && type !== 'external') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)"
|
||||
});
|
||||
}
|
||||
|
||||
const connectionId = type === 'external' ? Number(id) : undefined;
|
||||
const result = await BatchManagementService.getTableColumns(type, connectionId, tableName);
|
||||
|
||||
if (result.success) {
|
||||
return res.json(result);
|
||||
} else {
|
||||
return res.status(500).json(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 정보 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "컬럼 정보 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 설정 생성
|
||||
* POST /api/batch-management/batch-configs
|
||||
*/
|
||||
static async createBatchConfig(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { batchName, description, cronSchedule, mappings, isActive } = req.body;
|
||||
|
||||
if (!batchName || !cronSchedule || !mappings || !Array.isArray(mappings)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다. (batchName, cronSchedule, mappings)"
|
||||
});
|
||||
}
|
||||
|
||||
const batchConfig = await BatchService.createBatchConfig({
|
||||
batchName,
|
||||
description,
|
||||
cronSchedule,
|
||||
mappings,
|
||||
isActive: isActive !== undefined ? isActive : true
|
||||
} as CreateBatchConfigRequest);
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: batchConfig,
|
||||
message: "배치 설정이 성공적으로 생성되었습니다."
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("배치 설정 생성 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 설정 생성에 실패했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 배치 설정 조회
|
||||
* GET /api/batch-management/batch-configs/:id
|
||||
*/
|
||||
static async getBatchConfigById(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
console.log("🔍 배치 설정 조회 요청:", id);
|
||||
|
||||
const result = await BatchService.getBatchConfigById(Number(id));
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: result.message || "배치 설정을 찾을 수 없습니다."
|
||||
});
|
||||
}
|
||||
|
||||
console.log("📋 조회된 배치 설정:", result.data);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.data
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ 배치 설정 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 설정 조회에 실패했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 설정 목록 조회
|
||||
* GET /api/batch-management/batch-configs
|
||||
*/
|
||||
static async getBatchConfigs(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { page = 1, limit = 10, search, isActive } = req.query;
|
||||
|
||||
const filter = {
|
||||
page: Number(page),
|
||||
limit: Number(limit),
|
||||
search: search as string,
|
||||
is_active: isActive as string
|
||||
};
|
||||
|
||||
const result = await BatchService.getBatchConfigs(filter);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
pagination: result.pagination
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("배치 설정 목록 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 설정 목록 조회에 실패했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 수동 실행
|
||||
* POST /api/batch-management/batch-configs/:id/execute
|
||||
*/
|
||||
static async executeBatchConfig(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id || isNaN(Number(id))) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "올바른 배치 설정 ID를 제공해주세요."
|
||||
});
|
||||
}
|
||||
|
||||
// 배치 설정 조회
|
||||
const batchConfigResult = await BatchService.getBatchConfigById(Number(id));
|
||||
if (!batchConfigResult.success || !batchConfigResult.data) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "배치 설정을 찾을 수 없습니다."
|
||||
});
|
||||
}
|
||||
|
||||
const batchConfig = batchConfigResult.data as BatchConfig;
|
||||
|
||||
// 배치 실행 로직 (간단한 버전)
|
||||
const startTime = new Date();
|
||||
let totalRecords = 0;
|
||||
let successRecords = 0;
|
||||
let failedRecords = 0;
|
||||
|
||||
try {
|
||||
console.log(`배치 실행 시작: ${batchConfig.batch_name} (ID: ${id})`);
|
||||
|
||||
// 실행 로그 생성
|
||||
const executionLog = await BatchService.createExecutionLog({
|
||||
batch_config_id: Number(id),
|
||||
execution_status: 'RUNNING',
|
||||
start_time: startTime,
|
||||
total_records: 0,
|
||||
success_records: 0,
|
||||
failed_records: 0
|
||||
});
|
||||
|
||||
// 실제 배치 실행 (매핑이 있는 경우)
|
||||
if (batchConfig.batch_mappings && batchConfig.batch_mappings.length > 0) {
|
||||
// 테이블별로 매핑을 그룹화
|
||||
const tableGroups = new Map<string, typeof batchConfig.batch_mappings>();
|
||||
|
||||
for (const mapping of batchConfig.batch_mappings) {
|
||||
const key = `${mapping.from_connection_type}:${mapping.from_connection_id || 'internal'}:${mapping.from_table_name}`;
|
||||
if (!tableGroups.has(key)) {
|
||||
tableGroups.set(key, []);
|
||||
}
|
||||
tableGroups.get(key)!.push(mapping);
|
||||
}
|
||||
|
||||
// 각 테이블 그룹별로 처리
|
||||
for (const [tableKey, mappings] of tableGroups) {
|
||||
try {
|
||||
const firstMapping = mappings[0];
|
||||
console.log(`테이블 처리 시작: ${tableKey} -> ${mappings.length}개 컬럼 매핑`);
|
||||
|
||||
let fromData: any[] = [];
|
||||
|
||||
// FROM 데이터 조회 (DB 또는 REST API)
|
||||
if (firstMapping.from_connection_type === 'restapi') {
|
||||
// REST API에서 데이터 조회
|
||||
console.log(`REST API에서 데이터 조회: ${firstMapping.from_api_url}${firstMapping.from_table_name}`);
|
||||
console.log(`API 설정:`, {
|
||||
url: firstMapping.from_api_url,
|
||||
key: firstMapping.from_api_key ? '***' : 'null',
|
||||
method: firstMapping.from_api_method,
|
||||
endpoint: firstMapping.from_table_name
|
||||
});
|
||||
|
||||
try {
|
||||
const apiResult = await BatchExternalDbService.getDataFromRestApi(
|
||||
firstMapping.from_api_url!,
|
||||
firstMapping.from_api_key!,
|
||||
firstMapping.from_table_name,
|
||||
firstMapping.from_api_method as 'GET' | 'POST' | 'PUT' | 'DELETE' || 'GET',
|
||||
mappings.map(m => m.from_column_name)
|
||||
);
|
||||
|
||||
console.log(`API 조회 결과:`, {
|
||||
success: apiResult.success,
|
||||
dataCount: apiResult.data ? apiResult.data.length : 0,
|
||||
message: apiResult.message
|
||||
});
|
||||
|
||||
if (apiResult.success && apiResult.data) {
|
||||
fromData = apiResult.data;
|
||||
} else {
|
||||
throw new Error(`REST API 데이터 조회 실패: ${apiResult.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`REST API 조회 오류:`, error);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
// DB에서 데이터 조회
|
||||
const fromColumns = mappings.map(m => m.from_column_name);
|
||||
fromData = await BatchService.getDataFromTableWithColumns(
|
||||
firstMapping.from_table_name,
|
||||
fromColumns,
|
||||
firstMapping.from_connection_type as 'internal' | 'external',
|
||||
firstMapping.from_connection_id || undefined
|
||||
);
|
||||
}
|
||||
|
||||
totalRecords += fromData.length;
|
||||
|
||||
// 컬럼 매핑 적용하여 TO 테이블 형식으로 변환
|
||||
const mappedData = fromData.map(row => {
|
||||
const mappedRow: any = {};
|
||||
for (const mapping of mappings) {
|
||||
// DB → REST API 배치인지 확인
|
||||
if (firstMapping.to_connection_type === 'restapi' && mapping.to_api_body) {
|
||||
// DB → REST API: 원본 컬럼명을 키로 사용 (템플릿 처리용)
|
||||
mappedRow[mapping.from_column_name] = row[mapping.from_column_name];
|
||||
} else {
|
||||
// 기존 로직: to_column_name을 키로 사용
|
||||
mappedRow[mapping.to_column_name] = row[mapping.from_column_name];
|
||||
}
|
||||
}
|
||||
return mappedRow;
|
||||
});
|
||||
|
||||
// TO 테이블에 데이터 삽입 (DB 또는 REST API)
|
||||
let insertResult: { successCount: number; failedCount: number };
|
||||
|
||||
if (firstMapping.to_connection_type === 'restapi') {
|
||||
// REST API로 데이터 전송
|
||||
console.log(`REST API로 데이터 전송: ${firstMapping.to_api_url}${firstMapping.to_table_name}`);
|
||||
|
||||
// DB → REST API 배치인지 확인 (to_api_body가 있으면 템플릿 기반)
|
||||
const hasTemplate = mappings.some(m => m.to_api_body);
|
||||
|
||||
if (hasTemplate) {
|
||||
// 템플릿 기반 REST API 전송 (DB → REST API 배치)
|
||||
const templateBody = firstMapping.to_api_body || '{}';
|
||||
console.log(`템플릿 기반 REST API 전송, Request Body 템플릿: ${templateBody}`);
|
||||
|
||||
// URL 경로 컬럼 찾기 (PUT/DELETE용)
|
||||
const urlPathColumn = mappings.find(m => m.to_column_name === 'URL_PATH_PARAM')?.from_column_name;
|
||||
|
||||
const apiResult = await BatchExternalDbService.sendDataToRestApiWithTemplate(
|
||||
firstMapping.to_api_url!,
|
||||
firstMapping.to_api_key!,
|
||||
firstMapping.to_table_name,
|
||||
firstMapping.to_api_method as 'POST' | 'PUT' | 'DELETE' || 'POST',
|
||||
templateBody,
|
||||
mappedData,
|
||||
urlPathColumn
|
||||
);
|
||||
|
||||
if (apiResult.success && apiResult.data) {
|
||||
insertResult = apiResult.data;
|
||||
} else {
|
||||
throw new Error(`템플릿 기반 REST API 데이터 전송 실패: ${apiResult.message}`);
|
||||
}
|
||||
} 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
|
||||
);
|
||||
|
||||
if (apiResult.success && apiResult.data) {
|
||||
insertResult = apiResult.data;
|
||||
} else {
|
||||
throw new Error(`REST API 데이터 전송 실패: ${apiResult.message}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// DB에 데이터 삽입
|
||||
insertResult = await BatchService.insertDataToTable(
|
||||
firstMapping.to_table_name,
|
||||
mappedData,
|
||||
firstMapping.to_connection_type as 'internal' | 'external',
|
||||
firstMapping.to_connection_id || undefined
|
||||
);
|
||||
}
|
||||
|
||||
successRecords += insertResult.successCount;
|
||||
failedRecords += insertResult.failedCount;
|
||||
|
||||
console.log(`테이블 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패`);
|
||||
} catch (error) {
|
||||
console.error(`테이블 처리 실패: ${tableKey}`, error);
|
||||
failedRecords += 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log("매핑이 없어서 데이터 처리를 건너뜁니다.");
|
||||
}
|
||||
|
||||
// 실행 로그 업데이트 (성공)
|
||||
await BatchService.updateExecutionLog(executionLog.id, {
|
||||
execution_status: 'SUCCESS',
|
||||
end_time: new Date(),
|
||||
duration_ms: Date.now() - startTime.getTime(),
|
||||
total_records: totalRecords,
|
||||
success_records: successRecords,
|
||||
failed_records: failedRecords
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "배치가 성공적으로 실행되었습니다.",
|
||||
data: {
|
||||
batchId: id,
|
||||
totalRecords,
|
||||
successRecords,
|
||||
failedRecords,
|
||||
duration: Date.now() - startTime.getTime()
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`배치 실행 실패: ${batchConfig.batch_name}`, error);
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 실행에 실패했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("배치 실행 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 실행 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 설정 업데이트
|
||||
* PUT /api/batch-management/batch-configs/:id
|
||||
*/
|
||||
static async updateBatchConfig(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updateData = req.body;
|
||||
|
||||
if (!id || isNaN(Number(id))) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "올바른 배치 설정 ID를 제공해주세요."
|
||||
});
|
||||
}
|
||||
|
||||
const batchConfig = await BatchService.updateBatchConfig(Number(id), updateData);
|
||||
|
||||
// 스케줄러에서 배치 스케줄 업데이트
|
||||
await BatchSchedulerService.updateBatchSchedule(Number(id));
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: batchConfig,
|
||||
message: "배치 설정이 성공적으로 업데이트되었습니다."
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("배치 설정 업데이트 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 설정 업데이트에 실패했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* REST API 데이터 미리보기
|
||||
*/
|
||||
static async previewRestApiData(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { apiUrl, apiKey, endpoint, method = 'GET' } = req.body;
|
||||
|
||||
if (!apiUrl || !apiKey || !endpoint) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "API URL, API Key, 엔드포인트는 필수입니다."
|
||||
});
|
||||
}
|
||||
|
||||
// RestApiConnector 사용하여 데이터 조회
|
||||
const { RestApiConnector } = await import('../database/RestApiConnector');
|
||||
|
||||
const connector = new RestApiConnector({
|
||||
baseUrl: apiUrl,
|
||||
apiKey: apiKey,
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
// 연결 테스트
|
||||
await connector.connect();
|
||||
|
||||
// 데이터 조회 (최대 5개만) - GET 메서드만 지원
|
||||
const result = await connector.executeQuery(endpoint, method);
|
||||
console.log(`[previewRestApiData] executeQuery 결과:`, {
|
||||
rowCount: result.rowCount,
|
||||
rowsLength: result.rows ? result.rows.length : 'undefined',
|
||||
firstRow: result.rows && result.rows.length > 0 ? result.rows[0] : 'no data'
|
||||
});
|
||||
|
||||
const data = result.rows.slice(0, 5); // 최대 5개 샘플만
|
||||
console.log(`[previewRestApiData] 슬라이스된 데이터:`, data);
|
||||
|
||||
if (data.length > 0) {
|
||||
// 첫 번째 객체에서 필드명 추출
|
||||
const fields = Object.keys(data[0]);
|
||||
console.log(`[previewRestApiData] 추출된 필드:`, fields);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
fields: fields,
|
||||
samples: data,
|
||||
totalCount: result.rowCount || data.length
|
||||
},
|
||||
message: `${fields.length}개 필드, ${result.rowCount || data.length}개 레코드를 조회했습니다.`
|
||||
});
|
||||
} else {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
fields: [],
|
||||
samples: [],
|
||||
totalCount: 0
|
||||
},
|
||||
message: "API에서 데이터를 가져올 수 없습니다."
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("REST API 미리보기 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "REST API 데이터 미리보기 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* REST API 배치 설정 저장
|
||||
*/
|
||||
static async saveRestApiBatch(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const {
|
||||
batchName,
|
||||
batchType,
|
||||
cronSchedule,
|
||||
description,
|
||||
apiMappings
|
||||
} = req.body;
|
||||
|
||||
if (!batchName || !batchType || !cronSchedule || !apiMappings || apiMappings.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다."
|
||||
});
|
||||
}
|
||||
|
||||
console.log("REST API 배치 저장 요청:", {
|
||||
batchName,
|
||||
batchType,
|
||||
cronSchedule,
|
||||
description,
|
||||
apiMappings
|
||||
});
|
||||
|
||||
// BatchService를 사용하여 배치 설정 저장
|
||||
const batchConfig: CreateBatchConfigRequest = {
|
||||
batchName: batchName,
|
||||
description: description || '',
|
||||
cronSchedule: cronSchedule,
|
||||
mappings: apiMappings
|
||||
};
|
||||
|
||||
const result = await BatchService.createBatchConfig(batchConfig);
|
||||
|
||||
if (result.success && result.data) {
|
||||
// 스케줄러에 자동 등록 ✅
|
||||
try {
|
||||
await BatchSchedulerService.scheduleBatchConfig(result.data);
|
||||
console.log(`✅ 새로운 배치가 스케줄러에 등록되었습니다: ${batchName} (ID: ${result.data.id})`);
|
||||
} catch (schedulerError) {
|
||||
console.error(`❌ 스케줄러 등록 실패: ${batchName}`, schedulerError);
|
||||
// 스케줄러 등록 실패해도 배치 저장은 성공으로 처리
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "REST API 배치가 성공적으로 저장되었습니다.",
|
||||
data: result.data
|
||||
});
|
||||
} else {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: result.message || "배치 저장에 실패했습니다."
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("REST API 배치 저장 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 저장 중 오류가 발생했습니다."
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -63,9 +63,19 @@ export class CommonCodeController {
|
|||
size: size ? parseInt(size as string) : undefined,
|
||||
});
|
||||
|
||||
// 프론트엔드가 기대하는 형식으로 데이터 변환
|
||||
const transformedData = result.data.map((code: any) => ({
|
||||
codeValue: code.code_value,
|
||||
codeName: code.code_name,
|
||||
description: code.description,
|
||||
sortOrder: code.sort_order,
|
||||
isActive: code.is_active === "Y",
|
||||
useYn: code.is_active,
|
||||
}));
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
data: transformedData,
|
||||
total: result.total,
|
||||
message: `코드 목록 조회 성공 (${categoryCode})`,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ import { generateUUID } from "../utils/generateId";
|
|||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 임시 토큰 저장소 (메모리 기반, 실제 운영에서는 Redis 사용 권장)
|
||||
const tempTokens = new Map<string, { objid: string; expires: number }>();
|
||||
|
||||
// 업로드 디렉토리 설정 (회사별로 분리)
|
||||
const baseUploadDir = path.join(process.cwd(), "uploads");
|
||||
|
||||
|
|
@ -266,9 +269,7 @@ export const uploadFiles = async (
|
|||
|
||||
// 회사코드가 *인 경우 company_*로 변환
|
||||
const actualCompanyCode = companyCode === "*" ? "company_*" : companyCode;
|
||||
const relativePath = `/${actualCompanyCode}/${dateFolder}/${file.filename}`;
|
||||
const fullFilePath = `/uploads${relativePath}`;
|
||||
|
||||
|
||||
// 임시 파일을 최종 위치로 이동
|
||||
const tempFilePath = file.path; // Multer가 저장한 임시 파일 경로
|
||||
const finalUploadDir = getCompanyUploadDir(companyCode, dateFolder);
|
||||
|
|
@ -277,6 +278,10 @@ export const uploadFiles = async (
|
|||
// 파일 이동
|
||||
fs.renameSync(tempFilePath, finalFilePath);
|
||||
|
||||
// DB에 저장할 경로 (실제 파일 위치와 일치)
|
||||
const relativePath = `/${actualCompanyCode}/${dateFolder}/${file.filename}`;
|
||||
const fullFilePath = `/uploads${relativePath}`;
|
||||
|
||||
// attach_file_info 테이블에 저장
|
||||
const fileRecord = await prisma.attach_file_info.create({
|
||||
data: {
|
||||
|
|
@ -485,6 +490,133 @@ export const getFileList = async (
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 컴포넌트의 템플릿 파일과 데이터 파일을 모두 조회
|
||||
*/
|
||||
export const getComponentFiles = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { screenId, componentId, tableName, recordId, columnName } = req.query;
|
||||
|
||||
console.log("📂 [getComponentFiles] API 호출:", {
|
||||
screenId,
|
||||
componentId,
|
||||
tableName,
|
||||
recordId,
|
||||
columnName,
|
||||
user: req.user?.userId
|
||||
});
|
||||
|
||||
if (!screenId || !componentId) {
|
||||
console.log("❌ [getComponentFiles] 필수 파라미터 누락");
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "screenId와 componentId가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 템플릿 파일 조회 (화면 설계 시 업로드한 파일들)
|
||||
const templateTargetObjid = `screen_files:${screenId}:${componentId}:${columnName || 'field_1'}`;
|
||||
console.log("🔍 [getComponentFiles] 템플릿 파일 조회:", { templateTargetObjid });
|
||||
|
||||
// 모든 파일 조회해서 실제 저장된 target_objid 패턴 확인
|
||||
const allFiles = await prisma.attach_file_info.findMany({
|
||||
where: {
|
||||
status: "ACTIVE",
|
||||
},
|
||||
select: {
|
||||
target_objid: true,
|
||||
real_file_name: true,
|
||||
regdate: true,
|
||||
},
|
||||
orderBy: {
|
||||
regdate: "desc",
|
||||
},
|
||||
take: 10,
|
||||
});
|
||||
console.log("🗂️ [getComponentFiles] 최근 저장된 파일들의 target_objid:", allFiles.map(f => ({ target_objid: f.target_objid, name: f.real_file_name })));
|
||||
|
||||
const templateFiles = await prisma.attach_file_info.findMany({
|
||||
where: {
|
||||
target_objid: templateTargetObjid,
|
||||
status: "ACTIVE",
|
||||
},
|
||||
orderBy: {
|
||||
regdate: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
console.log("📁 [getComponentFiles] 템플릿 파일 결과:", templateFiles.length);
|
||||
|
||||
// 2. 데이터 파일 조회 (실제 레코드와 연결된 파일들)
|
||||
let dataFiles: any[] = [];
|
||||
if (tableName && recordId && columnName) {
|
||||
const dataTargetObjid = `${tableName}:${recordId}:${columnName}`;
|
||||
dataFiles = await prisma.attach_file_info.findMany({
|
||||
where: {
|
||||
target_objid: dataTargetObjid,
|
||||
status: "ACTIVE",
|
||||
},
|
||||
orderBy: {
|
||||
regdate: "desc",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 파일 정보 포맷팅 함수
|
||||
const formatFileInfo = (file: any, isTemplate: boolean = false) => ({
|
||||
objid: file.objid.toString(),
|
||||
savedFileName: file.saved_file_name,
|
||||
realFileName: file.real_file_name,
|
||||
fileSize: Number(file.file_size),
|
||||
fileExt: file.file_ext,
|
||||
filePath: file.file_path,
|
||||
docType: file.doc_type,
|
||||
docTypeName: file.doc_type_name,
|
||||
targetObjid: file.target_objid,
|
||||
parentTargetObjid: file.parent_target_objid,
|
||||
writer: file.writer,
|
||||
regdate: file.regdate?.toISOString(),
|
||||
status: file.status,
|
||||
isTemplate, // 템플릿 파일 여부 표시
|
||||
});
|
||||
|
||||
const formattedTemplateFiles = templateFiles.map(file => formatFileInfo(file, true));
|
||||
const formattedDataFiles = dataFiles.map(file => formatFileInfo(file, false));
|
||||
|
||||
// 3. 전체 파일 목록 (데이터 파일 우선, 없으면 템플릿 파일 표시)
|
||||
const totalFiles = formattedDataFiles.length > 0
|
||||
? formattedDataFiles
|
||||
: formattedTemplateFiles;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
templateFiles: formattedTemplateFiles,
|
||||
dataFiles: formattedDataFiles,
|
||||
totalFiles,
|
||||
summary: {
|
||||
templateCount: formattedTemplateFiles.length,
|
||||
dataCount: formattedDataFiles.length,
|
||||
totalCount: totalFiles.length,
|
||||
templateTargetObjid,
|
||||
dataTargetObjid: tableName && recordId && columnName
|
||||
? `${tableName}:${recordId}:${columnName}`
|
||||
: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("컴포넌트 파일 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "컴포넌트 파일 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 파일 미리보기 (이미지 등)
|
||||
*/
|
||||
|
|
@ -512,7 +644,13 @@ export const previewFile = async (
|
|||
|
||||
// 파일 경로에서 회사코드와 날짜 폴더 추출
|
||||
const filePathParts = fileRecord.file_path!.split("/");
|
||||
const companyCode = filePathParts[2] || "DEFAULT";
|
||||
let companyCode = filePathParts[2] || "DEFAULT";
|
||||
|
||||
// company_* 처리 (실제 회사 코드로 변환)
|
||||
if (companyCode === "company_*") {
|
||||
companyCode = "company_*"; // 실제 디렉토리명 유지
|
||||
}
|
||||
|
||||
const fileName = fileRecord.saved_file_name!;
|
||||
|
||||
// 파일 경로에 날짜 구조가 있는지 확인 (YYYY/MM/DD)
|
||||
|
|
@ -527,6 +665,17 @@ export const previewFile = async (
|
|||
);
|
||||
const filePath = path.join(companyUploadDir, fileName);
|
||||
|
||||
console.log("🔍 파일 미리보기 경로 확인:", {
|
||||
objid: objid,
|
||||
filePathFromDB: fileRecord.file_path,
|
||||
companyCode: companyCode,
|
||||
dateFolder: dateFolder,
|
||||
fileName: fileName,
|
||||
companyUploadDir: companyUploadDir,
|
||||
finalFilePath: filePath,
|
||||
fileExists: fs.existsSync(filePath)
|
||||
});
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error("❌ 파일 없음:", filePath);
|
||||
res.status(404).json({
|
||||
|
|
@ -615,7 +764,13 @@ export const downloadFile = async (
|
|||
|
||||
// 파일 경로에서 회사코드와 날짜 폴더 추출 (예: /uploads/company_*/2025/09/05/timestamp_filename.ext)
|
||||
const filePathParts = fileRecord.file_path!.split("/");
|
||||
const companyCode = filePathParts[2] || "DEFAULT"; // /uploads/company_*/2025/09/05/filename.ext에서 company_* 추출
|
||||
let companyCode = filePathParts[2] || "DEFAULT"; // /uploads/company_*/2025/09/05/filename.ext에서 company_* 추출
|
||||
|
||||
// company_* 처리 (실제 회사 코드로 변환)
|
||||
if (companyCode === "company_*") {
|
||||
companyCode = "company_*"; // 실제 디렉토리명 유지
|
||||
}
|
||||
|
||||
const fileName = fileRecord.saved_file_name!;
|
||||
|
||||
// 파일 경로에 날짜 구조가 있는지 확인 (YYYY/MM/DD)
|
||||
|
|
@ -631,6 +786,17 @@ export const downloadFile = async (
|
|||
);
|
||||
const filePath = path.join(companyUploadDir, fileName);
|
||||
|
||||
console.log("🔍 파일 다운로드 경로 확인:", {
|
||||
objid: objid,
|
||||
filePathFromDB: fileRecord.file_path,
|
||||
companyCode: companyCode,
|
||||
dateFolder: dateFolder,
|
||||
fileName: fileName,
|
||||
companyUploadDir: companyUploadDir,
|
||||
finalFilePath: filePath,
|
||||
fileExists: fs.existsSync(filePath)
|
||||
});
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error("❌ 파일 없음:", filePath);
|
||||
res.status(404).json({
|
||||
|
|
@ -660,5 +826,178 @@ export const downloadFile = async (
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Google Docs Viewer용 임시 공개 토큰 생성
|
||||
*/
|
||||
export const generateTempToken = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { objid } = req.params;
|
||||
|
||||
if (!objid) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "파일 ID가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 존재 확인
|
||||
const fileRecord = await prisma.attach_file_info.findUnique({
|
||||
where: { objid: objid },
|
||||
});
|
||||
|
||||
if (!fileRecord) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "파일을 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 임시 토큰 생성 (30분 유효)
|
||||
const token = generateUUID();
|
||||
const expires = Date.now() + 30 * 60 * 1000; // 30분
|
||||
|
||||
tempTokens.set(token, {
|
||||
objid: objid,
|
||||
expires: expires,
|
||||
});
|
||||
|
||||
// 만료된 토큰 정리 (메모리 누수 방지)
|
||||
const now = Date.now();
|
||||
for (const [key, value] of tempTokens.entries()) {
|
||||
if (value.expires < now) {
|
||||
tempTokens.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
token: token,
|
||||
publicUrl: `${req.protocol}://${req.get("host")}/api/files/public/${token}`,
|
||||
expires: new Date(expires).toISOString(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ 임시 토큰 생성 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "임시 토큰 생성에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 임시 토큰으로 파일 접근 (인증 불필요)
|
||||
*/
|
||||
export const getFileByToken = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { token } = req.params;
|
||||
|
||||
if (!token) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "토큰이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 토큰 확인
|
||||
const tokenData = tempTokens.get(token);
|
||||
if (!tokenData) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 토큰입니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 토큰 만료 확인
|
||||
if (tokenData.expires < Date.now()) {
|
||||
tempTokens.delete(token);
|
||||
res.status(410).json({
|
||||
success: false,
|
||||
message: "토큰이 만료되었습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 정보 조회
|
||||
const fileRecord = await prisma.attach_file_info.findUnique({
|
||||
where: { objid: tokenData.objid },
|
||||
});
|
||||
|
||||
if (!fileRecord) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "파일을 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 경로 구성
|
||||
const filePathParts = fileRecord.file_path!.split("/");
|
||||
let companyCode = filePathParts[2] || "DEFAULT";
|
||||
if (companyCode === "company_*") {
|
||||
companyCode = "company_*"; // 실제 디렉토리명 유지
|
||||
}
|
||||
const fileName = fileRecord.saved_file_name!;
|
||||
let dateFolder = "";
|
||||
if (filePathParts.length >= 6) {
|
||||
dateFolder = `${filePathParts[3]}/${filePathParts[4]}/${filePathParts[5]}`;
|
||||
}
|
||||
const companyUploadDir = getCompanyUploadDir(companyCode, dateFolder || undefined);
|
||||
const filePath = path.join(companyUploadDir, fileName);
|
||||
|
||||
// 파일 존재 확인
|
||||
if (!fs.existsSync(filePath)) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "실제 파일을 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// MIME 타입 설정
|
||||
const ext = path.extname(fileName).toLowerCase();
|
||||
let contentType = "application/octet-stream";
|
||||
|
||||
const mimeTypes: { [key: string]: string } = {
|
||||
".pdf": "application/pdf",
|
||||
".doc": "application/msword",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".xls": "application/vnd.ms-excel",
|
||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".ppt": "application/vnd.ms-powerpoint",
|
||||
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".txt": "text/plain",
|
||||
};
|
||||
|
||||
if (mimeTypes[ext]) {
|
||||
contentType = mimeTypes[ext];
|
||||
}
|
||||
|
||||
// 파일 헤더 설정
|
||||
res.setHeader("Content-Type", contentType);
|
||||
res.setHeader("Content-Disposition", `inline; filename="${encodeURIComponent(fileRecord.real_file_name!)}"`);
|
||||
res.setHeader("Cache-Control", "public, max-age=300"); // 5분 캐시
|
||||
|
||||
// 파일 스트림 전송
|
||||
const fileStream = fs.createReadStream(filePath);
|
||||
fileStream.pipe(res);
|
||||
} catch (error) {
|
||||
console.error("❌ 토큰 파일 접근 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "파일 접근에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Multer 미들웨어 export
|
||||
export const uploadMiddleware = upload.array("files", 10); // 최대 10개 파일
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { PostgreSQLConnector } from './PostgreSQLConnector';
|
|||
import { MariaDBConnector } from './MariaDBConnector';
|
||||
import { MSSQLConnector } from './MSSQLConnector';
|
||||
import { OracleConnector } from './OracleConnector';
|
||||
import { RestApiConnector, RestApiConfig } from './RestApiConnector';
|
||||
|
||||
export class DatabaseConnectorFactory {
|
||||
private static connectors = new Map<string, DatabaseConnector>();
|
||||
|
|
@ -33,6 +34,9 @@ export class DatabaseConnectorFactory {
|
|||
case 'oracle':
|
||||
connector = new OracleConnector(config);
|
||||
break;
|
||||
case 'restapi':
|
||||
connector = new RestApiConnector(config as RestApiConfig);
|
||||
break;
|
||||
// Add other database types here
|
||||
default:
|
||||
throw new Error(`지원하지 않는 데이터베이스 타입: ${type}`);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector';
|
||||
import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes';
|
||||
// @ts-ignore
|
||||
import * as mssql from 'mssql';
|
||||
|
||||
export class MSSQLConnector implements DatabaseConnector {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector';
|
||||
import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes';
|
||||
// @ts-ignore
|
||||
import * as mysql from 'mysql2/promise';
|
||||
|
||||
export class MariaDBConnector implements DatabaseConnector {
|
||||
|
|
@ -106,7 +107,10 @@ export class MariaDBConnector implements DatabaseConnector {
|
|||
|
||||
async getColumns(tableName: string): Promise<any[]> {
|
||||
try {
|
||||
console.log(`[MariaDBConnector] getColumns 호출: tableName=${tableName}`);
|
||||
await this.connect();
|
||||
console.log(`[MariaDBConnector] 연결 완료, 쿼리 실행 시작`);
|
||||
|
||||
const [rows] = await this.connection!.query(`
|
||||
SELECT
|
||||
COLUMN_NAME as column_name,
|
||||
|
|
@ -117,11 +121,16 @@ export class MariaDBConnector implements DatabaseConnector {
|
|||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?
|
||||
ORDER BY ORDINAL_POSITION;
|
||||
`, [tableName]);
|
||||
|
||||
console.log(`[MariaDBConnector] 쿼리 결과:`, rows);
|
||||
console.log(`[MariaDBConnector] 결과 개수:`, Array.isArray(rows) ? rows.length : 'not array');
|
||||
|
||||
await this.disconnect();
|
||||
return rows as any[];
|
||||
} catch (error: any) {
|
||||
console.error(`[MariaDBConnector] getColumns 오류:`, error);
|
||||
await this.disconnect();
|
||||
throw new Error(`컬럼 정보 조회 실패: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
// @ts-ignore
|
||||
import * as oracledb from 'oracledb';
|
||||
import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector';
|
||||
import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes';
|
||||
|
|
@ -100,7 +101,7 @@ export class OracleConnector implements DatabaseConnector {
|
|||
|
||||
// Oracle XE 21c 쿼리 실행 옵션
|
||||
const options: any = {
|
||||
outFormat: oracledb.OUT_FORMAT_OBJECT, // OBJECT format
|
||||
outFormat: (oracledb as any).OUT_FORMAT_OBJECT, // OBJECT format
|
||||
maxRows: 10000, // XE 제한 고려
|
||||
fetchArraySize: 100
|
||||
};
|
||||
|
|
@ -176,6 +177,8 @@ export class OracleConnector implements DatabaseConnector {
|
|||
|
||||
async getColumns(tableName: string): Promise<any[]> {
|
||||
try {
|
||||
console.log(`[OracleConnector] getColumns 호출: tableName=${tableName}`);
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
column_name,
|
||||
|
|
@ -190,16 +193,23 @@ export class OracleConnector implements DatabaseConnector {
|
|||
ORDER BY column_id
|
||||
`;
|
||||
|
||||
console.log(`[OracleConnector] 쿼리 실행 시작: ${query}`);
|
||||
const result = await this.executeQuery(query, [tableName]);
|
||||
|
||||
return result.rows.map((row: any) => ({
|
||||
console.log(`[OracleConnector] 쿼리 결과:`, result.rows);
|
||||
console.log(`[OracleConnector] 결과 개수:`, result.rows ? result.rows.length : 'null/undefined');
|
||||
|
||||
const mappedResult = result.rows.map((row: any) => ({
|
||||
column_name: row.COLUMN_NAME,
|
||||
data_type: this.formatOracleDataType(row),
|
||||
is_nullable: row.NULLABLE === 'Y' ? 'YES' : 'NO',
|
||||
column_default: row.DATA_DEFAULT
|
||||
}));
|
||||
|
||||
console.log(`[OracleConnector] 매핑된 결과:`, mappedResult);
|
||||
return mappedResult;
|
||||
} catch (error: any) {
|
||||
console.error('Oracle 테이블 컬럼 조회 실패:', error);
|
||||
console.error('[OracleConnector] getColumns 오류:', error);
|
||||
throw new Error(`테이블 컬럼 조회 실패: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,261 @@
|
|||
import axios, { AxiosInstance, AxiosResponse } from 'axios';
|
||||
import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector';
|
||||
import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes';
|
||||
|
||||
export interface RestApiConfig {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
timeout?: number;
|
||||
// ConnectionConfig 호환성을 위한 더미 필드들 (사용하지 않음)
|
||||
host?: string;
|
||||
port?: number;
|
||||
database?: string;
|
||||
user?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export class RestApiConnector implements DatabaseConnector {
|
||||
private httpClient: AxiosInstance;
|
||||
private config: RestApiConfig;
|
||||
|
||||
constructor(config: RestApiConfig) {
|
||||
this.config = config;
|
||||
|
||||
// Axios 인스턴스 생성
|
||||
this.httpClient = axios.create({
|
||||
baseURL: config.baseUrl,
|
||||
timeout: config.timeout || 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': config.apiKey,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
// 요청/응답 인터셉터 설정
|
||||
this.setupInterceptors();
|
||||
}
|
||||
|
||||
private setupInterceptors() {
|
||||
// 요청 인터셉터
|
||||
this.httpClient.interceptors.request.use(
|
||||
(config) => {
|
||||
console.log(`[RestApiConnector] 요청: ${config.method?.toUpperCase()} ${config.url}`);
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
console.error('[RestApiConnector] 요청 오류:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 응답 인터셉터
|
||||
this.httpClient.interceptors.response.use(
|
||||
(response) => {
|
||||
console.log(`[RestApiConnector] 응답: ${response.status} ${response.statusText}`);
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
console.error('[RestApiConnector] 응답 오류:', error.response?.status, error.response?.statusText);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
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 : '알 수 없는 오류'}`);
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
// REST API는 연결 해제가 필요 없음
|
||||
console.log(`[RestApiConnector] 연결 해제: ${this.config.baseUrl}`);
|
||||
}
|
||||
|
||||
async testConnection(): Promise<ConnectionTestResult> {
|
||||
try {
|
||||
await this.connect();
|
||||
return {
|
||||
success: true,
|
||||
message: 'REST API 연결이 성공했습니다.',
|
||||
details: {
|
||||
response_time: Date.now()
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'REST API 연결에 실패했습니다.',
|
||||
details: {
|
||||
response_time: Date.now()
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async executeQuery(endpoint: string, method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET', data?: any): Promise<QueryResult> {
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
let response: AxiosResponse;
|
||||
|
||||
// HTTP 메서드에 따른 요청 실행
|
||||
switch (method.toUpperCase()) {
|
||||
case 'GET':
|
||||
response = await this.httpClient.get(endpoint);
|
||||
break;
|
||||
case 'POST':
|
||||
response = await this.httpClient.post(endpoint, data);
|
||||
break;
|
||||
case 'PUT':
|
||||
response = await this.httpClient.put(endpoint, data);
|
||||
break;
|
||||
case 'DELETE':
|
||||
response = await this.httpClient.delete(endpoint);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`지원하지 않는 HTTP 메서드: ${method}`);
|
||||
}
|
||||
|
||||
const executionTime = Date.now() - startTime;
|
||||
const responseData = response.data;
|
||||
|
||||
console.log(`[RestApiConnector] 원본 응답 데이터:`, {
|
||||
type: typeof responseData,
|
||||
isArray: Array.isArray(responseData),
|
||||
keys: typeof responseData === 'object' ? Object.keys(responseData) : 'not object',
|
||||
responseData: responseData
|
||||
});
|
||||
|
||||
// 응답 데이터 처리
|
||||
let rows: any[];
|
||||
if (Array.isArray(responseData)) {
|
||||
rows = responseData;
|
||||
} else if (responseData && responseData.data && Array.isArray(responseData.data)) {
|
||||
// API 응답이 {success: true, data: [...]} 형태인 경우
|
||||
rows = responseData.data;
|
||||
} else if (responseData && responseData.data && typeof responseData.data === 'object') {
|
||||
// API 응답이 {success: true, data: {...}} 형태인 경우 (단일 객체)
|
||||
rows = [responseData.data];
|
||||
} else if (responseData && typeof responseData === 'object' && !Array.isArray(responseData)) {
|
||||
// 단일 객체 응답인 경우
|
||||
rows = [responseData];
|
||||
} else {
|
||||
rows = [];
|
||||
}
|
||||
|
||||
console.log(`[RestApiConnector] 처리된 rows:`, {
|
||||
rowsLength: rows.length,
|
||||
firstRow: rows.length > 0 ? rows[0] : 'no data',
|
||||
allRows: rows
|
||||
});
|
||||
|
||||
console.log(`[RestApiConnector] API 호출 결과:`, {
|
||||
endpoint,
|
||||
method,
|
||||
status: response.status,
|
||||
rowCount: rows.length,
|
||||
executionTime: `${executionTime}ms`
|
||||
});
|
||||
|
||||
return {
|
||||
rows: rows,
|
||||
rowCount: rows.length,
|
||||
fields: rows.length > 0 ? Object.keys(rows[0]).map(key => ({ name: key, type: 'string' })) : []
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[RestApiConnector] API 호출 오류 (${method} ${endpoint}):`, error);
|
||||
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw new Error(`REST API 호출 실패: ${error.response?.status} ${error.response?.statusText}`);
|
||||
}
|
||||
|
||||
throw new Error(`REST API 호출 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getTables(): Promise<TableInfo[]> {
|
||||
// REST API의 경우 "테이블"은 사용 가능한 엔드포인트를 의미
|
||||
// 일반적인 REST API 엔드포인트들을 반환
|
||||
return [
|
||||
{
|
||||
table_name: '/api/users',
|
||||
columns: [],
|
||||
description: '사용자 정보 API'
|
||||
},
|
||||
{
|
||||
table_name: '/api/data',
|
||||
columns: [],
|
||||
description: '기본 데이터 API'
|
||||
},
|
||||
{
|
||||
table_name: '/api/custom',
|
||||
columns: [],
|
||||
description: '사용자 정의 엔드포인트'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
async getTableList(): Promise<TableInfo[]> {
|
||||
return this.getTables();
|
||||
}
|
||||
|
||||
async getColumns(endpoint: string): Promise<any[]> {
|
||||
try {
|
||||
// GET 요청으로 샘플 데이터를 가져와서 필드 구조 파악
|
||||
const result = await this.executeQuery(endpoint, 'GET');
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
const sampleRow = result.rows[0];
|
||||
return Object.keys(sampleRow).map(key => ({
|
||||
column_name: key,
|
||||
data_type: typeof sampleRow[key],
|
||||
is_nullable: 'YES',
|
||||
column_default: null,
|
||||
description: `${key} 필드`
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error(`[RestApiConnector] 컬럼 정보 조회 오류 (${endpoint}):`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getTableColumns(endpoint: string): Promise<any[]> {
|
||||
return this.getColumns(endpoint);
|
||||
}
|
||||
|
||||
// REST API 전용 메서드들
|
||||
async getData(endpoint: string, params?: Record<string, any>): Promise<any[]> {
|
||||
const queryString = params ? '?' + new URLSearchParams(params).toString() : '';
|
||||
const result = await this.executeQuery(endpoint + queryString, 'GET');
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async postData(endpoint: string, data: any): Promise<any> {
|
||||
const result = await this.executeQuery(endpoint, 'POST', data);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async putData(endpoint: string, data: any): Promise<any> {
|
||||
const result = await this.executeQuery(endpoint, 'PUT', data);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async deleteData(endpoint: string): Promise<any> {
|
||||
const result = await this.executeQuery(endpoint, 'DELETE');
|
||||
return result.rows[0];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
// 배치 실행 로그 라우트
|
||||
// 작성일: 2024-12-24
|
||||
|
||||
import { Router } from "express";
|
||||
import { BatchExecutionLogController } from "../controllers/batchExecutionLogController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/batch-execution-logs
|
||||
* 배치 실행 로그 목록 조회
|
||||
*/
|
||||
router.get("/", authenticateToken, BatchExecutionLogController.getExecutionLogs);
|
||||
|
||||
/**
|
||||
* POST /api/batch-execution-logs
|
||||
* 배치 실행 로그 생성
|
||||
*/
|
||||
router.post("/", authenticateToken, BatchExecutionLogController.createExecutionLog);
|
||||
|
||||
/**
|
||||
* PUT /api/batch-execution-logs/:id
|
||||
* 배치 실행 로그 업데이트
|
||||
*/
|
||||
router.put("/:id", authenticateToken, BatchExecutionLogController.updateExecutionLog);
|
||||
|
||||
/**
|
||||
* DELETE /api/batch-execution-logs/:id
|
||||
* 배치 실행 로그 삭제
|
||||
*/
|
||||
router.delete("/:id", authenticateToken, BatchExecutionLogController.deleteExecutionLog);
|
||||
|
||||
/**
|
||||
* GET /api/batch-execution-logs/latest/:batchConfigId
|
||||
* 특정 배치의 최신 실행 로그 조회
|
||||
*/
|
||||
router.get("/latest/:batchConfigId", authenticateToken, BatchExecutionLogController.getLatestExecutionLog);
|
||||
|
||||
/**
|
||||
* GET /api/batch-execution-logs/stats
|
||||
* 배치 실행 통계 조회
|
||||
*/
|
||||
router.get("/stats", authenticateToken, BatchExecutionLogController.getExecutionStats);
|
||||
|
||||
export default router;
|
||||
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
// 배치관리 전용 라우트 (기존 소스와 완전 분리)
|
||||
// 작성일: 2024-12-24
|
||||
|
||||
import { Router } from "express";
|
||||
import { BatchManagementController } from "../controllers/batchManagementController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/batch-management/connections
|
||||
* 사용 가능한 커넥션 목록 조회
|
||||
*/
|
||||
router.get("/connections", authenticateToken, BatchManagementController.getAvailableConnections);
|
||||
|
||||
/**
|
||||
* GET /api/batch-management/connections/:type/tables
|
||||
* 내부 DB 테이블 목록 조회
|
||||
*/
|
||||
router.get("/connections/:type/tables", authenticateToken, BatchManagementController.getTablesFromConnection);
|
||||
|
||||
/**
|
||||
* GET /api/batch-management/connections/:type/:id/tables
|
||||
* 외부 DB 테이블 목록 조회
|
||||
*/
|
||||
router.get("/connections/:type/:id/tables", authenticateToken, BatchManagementController.getTablesFromConnection);
|
||||
|
||||
/**
|
||||
* GET /api/batch-management/connections/:type/tables/:tableName/columns
|
||||
* 내부 DB 테이블 컬럼 정보 조회
|
||||
*/
|
||||
router.get("/connections/:type/tables/:tableName/columns", authenticateToken, BatchManagementController.getTableColumns);
|
||||
|
||||
/**
|
||||
* GET /api/batch-management/connections/:type/:id/tables/:tableName/columns
|
||||
* 외부 DB 테이블 컬럼 정보 조회
|
||||
*/
|
||||
router.get("/connections/:type/:id/tables/:tableName/columns", authenticateToken, BatchManagementController.getTableColumns);
|
||||
|
||||
/**
|
||||
* POST /api/batch-management/batch-configs
|
||||
* 배치 설정 생성
|
||||
*/
|
||||
router.post("/batch-configs", authenticateToken, BatchManagementController.createBatchConfig);
|
||||
|
||||
/**
|
||||
* GET /api/batch-management/batch-configs
|
||||
* 배치 설정 목록 조회
|
||||
*/
|
||||
router.get("/batch-configs", authenticateToken, BatchManagementController.getBatchConfigs);
|
||||
|
||||
/**
|
||||
* GET /api/batch-management/batch-configs/:id
|
||||
* 특정 배치 설정 조회
|
||||
*/
|
||||
router.get("/batch-configs/:id", authenticateToken, BatchManagementController.getBatchConfigById);
|
||||
|
||||
/**
|
||||
* PUT /api/batch-management/batch-configs/:id
|
||||
* 배치 설정 업데이트
|
||||
*/
|
||||
router.put("/batch-configs/:id", authenticateToken, BatchManagementController.updateBatchConfig);
|
||||
|
||||
/**
|
||||
* POST /api/batch-management/batch-configs/:id/execute
|
||||
* 배치 수동 실행
|
||||
*/
|
||||
router.post("/batch-configs/:id/execute", authenticateToken, BatchManagementController.executeBatchConfig);
|
||||
|
||||
/**
|
||||
* POST /api/batch-management/rest-api/preview
|
||||
* REST API 데이터 미리보기
|
||||
*/
|
||||
router.post("/rest-api/preview", authenticateToken, BatchManagementController.previewRestApiData);
|
||||
|
||||
/**
|
||||
* POST /api/batch-management/rest-api/save
|
||||
* REST API 배치 저장
|
||||
*/
|
||||
router.post("/rest-api/save", authenticateToken, BatchManagementController.saveRestApiBatch);
|
||||
|
||||
export default router;
|
||||
|
|
@ -1,73 +1,70 @@
|
|||
// 배치 관리 라우트
|
||||
// 작성일: 2024-12-23
|
||||
// 배치관리 라우트
|
||||
// 작성일: 2024-12-24
|
||||
|
||||
import { Router } from 'express';
|
||||
import { BatchController } from '../controllers/batchController';
|
||||
import { authenticateToken } from '../middleware/authMiddleware';
|
||||
import { Router } from "express";
|
||||
import { BatchController } from "../controllers/batchController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 모든 라우트에 인증 미들웨어 적용
|
||||
router.use(authenticateToken);
|
||||
/**
|
||||
* GET /api/batch-configs
|
||||
* 배치 설정 목록 조회
|
||||
*/
|
||||
router.get("/", authenticateToken, BatchController.getBatchConfigs);
|
||||
|
||||
/**
|
||||
* GET /api/batch
|
||||
* 배치 작업 목록 조회
|
||||
* GET /api/batch-configs/connections
|
||||
* 사용 가능한 커넥션 목록 조회
|
||||
*/
|
||||
router.get('/', BatchController.getBatchJobs);
|
||||
router.get("/connections", BatchController.getAvailableConnections);
|
||||
|
||||
/**
|
||||
* GET /api/batch/:id
|
||||
* 배치 작업 상세 조회
|
||||
* GET /api/batch-configs/connections/:type/tables
|
||||
* 내부 DB 테이블 목록 조회
|
||||
*/
|
||||
router.get('/:id', BatchController.getBatchJobById);
|
||||
router.get("/connections/:type/tables", authenticateToken, BatchController.getTablesFromConnection);
|
||||
|
||||
/**
|
||||
* POST /api/batch
|
||||
* 배치 작업 생성
|
||||
* GET /api/batch-configs/connections/:type/:id/tables
|
||||
* 외부 DB 테이블 목록 조회
|
||||
*/
|
||||
router.post('/', BatchController.createBatchJob);
|
||||
router.get("/connections/:type/:id/tables", authenticateToken, BatchController.getTablesFromConnection);
|
||||
|
||||
/**
|
||||
* PUT /api/batch/:id
|
||||
* 배치 작업 수정
|
||||
* GET /api/batch-configs/connections/:type/tables/:tableName/columns
|
||||
* 내부 DB 테이블 컬럼 정보 조회
|
||||
*/
|
||||
router.put('/:id', BatchController.updateBatchJob);
|
||||
router.get("/connections/:type/tables/:tableName/columns", authenticateToken, BatchController.getTableColumns);
|
||||
|
||||
/**
|
||||
* DELETE /api/batch/:id
|
||||
* 배치 작업 삭제
|
||||
* GET /api/batch-configs/connections/:type/:id/tables/:tableName/columns
|
||||
* 외부 DB 테이블 컬럼 정보 조회
|
||||
*/
|
||||
router.delete('/:id', BatchController.deleteBatchJob);
|
||||
router.get("/connections/:type/:id/tables/:tableName/columns", authenticateToken, BatchController.getTableColumns);
|
||||
|
||||
/**
|
||||
* POST /api/batch/:id/execute
|
||||
* 배치 작업 수동 실행
|
||||
* GET /api/batch-configs/:id
|
||||
* 특정 배치 설정 조회
|
||||
*/
|
||||
router.post('/:id/execute', BatchController.executeBatchJob);
|
||||
router.get("/:id", authenticateToken, BatchController.getBatchConfigById);
|
||||
|
||||
/**
|
||||
* GET /api/batch/executions
|
||||
* 배치 실행 목록 조회
|
||||
* POST /api/batch-configs
|
||||
* 배치 설정 생성
|
||||
*/
|
||||
router.get('/executions/list', BatchController.getBatchExecutions);
|
||||
router.post("/", authenticateToken, BatchController.createBatchConfig);
|
||||
|
||||
/**
|
||||
* GET /api/batch/monitoring
|
||||
* 배치 모니터링 정보 조회
|
||||
* PUT /api/batch-configs/:id
|
||||
* 배치 설정 수정
|
||||
*/
|
||||
router.get('/monitoring/status', BatchController.getBatchMonitoring);
|
||||
router.put("/:id", authenticateToken, BatchController.updateBatchConfig);
|
||||
|
||||
/**
|
||||
* GET /api/batch/types/supported
|
||||
* 지원되는 작업 타입 조회
|
||||
* DELETE /api/batch-configs/:id
|
||||
* 배치 설정 삭제 (논리 삭제)
|
||||
*/
|
||||
router.get('/types/supported', BatchController.getSupportedJobTypes);
|
||||
router.delete("/:id", authenticateToken, BatchController.deleteBatchConfig);
|
||||
|
||||
/**
|
||||
* GET /api/batch/schedules/presets
|
||||
* 스케줄 프리셋 조회
|
||||
*/
|
||||
router.get('/schedules/presets', BatchController.getSchedulePresets);
|
||||
|
||||
export default router;
|
||||
export default router;
|
||||
|
|
@ -447,17 +447,28 @@ router.get(
|
|||
return res.status(400).json(externalConnections);
|
||||
}
|
||||
|
||||
// 외부 커넥션들에 대해 연결 테스트 수행 (병렬 처리)
|
||||
// 외부 커넥션들에 대해 연결 테스트 수행 (병렬 처리, 타임아웃 5초)
|
||||
const testedConnections = await Promise.all(
|
||||
(externalConnections.data || []).map(async (connection) => {
|
||||
try {
|
||||
const testResult =
|
||||
await ExternalDbConnectionService.testConnectionById(
|
||||
connection.id!
|
||||
);
|
||||
// 개별 연결 테스트에 5초 타임아웃 적용
|
||||
const testPromise = ExternalDbConnectionService.testConnectionById(
|
||||
connection.id!
|
||||
);
|
||||
const timeoutPromise = new Promise<any>((_, reject) => {
|
||||
setTimeout(() => reject(new Error("연결 테스트 타임아웃")), 5000);
|
||||
});
|
||||
|
||||
const testResult = await Promise.race([
|
||||
testPromise,
|
||||
timeoutPromise,
|
||||
]);
|
||||
return testResult.success ? connection : null;
|
||||
} catch (error) {
|
||||
console.warn(`커넥션 테스트 실패 (ID: ${connection.id}):`, error);
|
||||
console.warn(
|
||||
`커넥션 테스트 실패 (ID: ${connection.id}):`,
|
||||
error instanceof Error ? error.message : error
|
||||
);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,15 +3,26 @@ import {
|
|||
uploadFiles,
|
||||
deleteFile,
|
||||
getFileList,
|
||||
getComponentFiles,
|
||||
downloadFile,
|
||||
previewFile,
|
||||
getLinkedFiles,
|
||||
uploadMiddleware,
|
||||
generateTempToken,
|
||||
getFileByToken,
|
||||
} from "../controllers/fileController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 공개 접근 라우트 (인증 불필요)
|
||||
/**
|
||||
* @route GET /api/files/public/:token
|
||||
* @desc 임시 토큰으로 파일 접근 (Google Docs Viewer용)
|
||||
* @access Public
|
||||
*/
|
||||
router.get("/public/:token", getFileByToken);
|
||||
|
||||
// 모든 파일 API는 인증 필요
|
||||
router.use(authenticateToken);
|
||||
|
||||
|
|
@ -30,6 +41,14 @@ router.post("/upload", uploadMiddleware, uploadFiles);
|
|||
*/
|
||||
router.get("/", getFileList);
|
||||
|
||||
/**
|
||||
* @route GET /api/files/component-files
|
||||
* @desc 컴포넌트의 템플릿 파일과 데이터 파일 모두 조회
|
||||
* @query screenId, componentId, tableName, recordId, columnName
|
||||
* @access Private
|
||||
*/
|
||||
router.get("/component-files", getComponentFiles);
|
||||
|
||||
/**
|
||||
* @route GET /api/files/linked/:tableName/:recordId
|
||||
* @desc 테이블 연결된 파일 조회
|
||||
|
|
@ -58,4 +77,11 @@ router.get("/preview/:objid", previewFile);
|
|||
*/
|
||||
router.get("/download/:objid", downloadFile);
|
||||
|
||||
/**
|
||||
* @route POST /api/files/temp-token/:objid
|
||||
* @desc Google Docs Viewer용 임시 공개 토큰 생성
|
||||
* @access Private
|
||||
*/
|
||||
router.post("/temp-token/:objid", generateTempToken);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -51,6 +51,45 @@ router.get(
|
|||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/multi-connection/connections/:connectionId/tables/batch
|
||||
* 특정 커넥션의 모든 테이블 정보 배치 조회 (컬럼 수 포함)
|
||||
*/
|
||||
router.get(
|
||||
"/connections/:connectionId/tables/batch",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const connectionId = parseInt(req.params.connectionId);
|
||||
|
||||
if (isNaN(connectionId)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 커넥션 ID입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`배치 테이블 정보 조회 요청: connectionId=${connectionId}`);
|
||||
|
||||
const tables =
|
||||
await multiConnectionService.getBatchTablesWithColumns(connectionId);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
data: tables,
|
||||
message: `커넥션 ${connectionId}의 테이블 정보를 배치 조회했습니다.`,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`배치 테이블 정보 조회 실패: ${error}`);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 테이블 정보 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/multi-connection/connections/:connectionId/tables/:tableName/columns
|
||||
* 특정 커넥션의 테이블 컬럼 정보 조회 (메인 DB 포함)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { PrismaClient } from "@prisma/client";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
// 🔧 Prisma 클라이언트 중복 생성 방지 - 기존 인스턴스 재사용
|
||||
import prisma = require("../config/database");
|
||||
|
||||
export class AdminService {
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -186,17 +186,17 @@ export class AuthService {
|
|||
});
|
||||
|
||||
// DB에서 조회한 원본 사용자 정보 상세 로그
|
||||
console.log("🔍 AuthService - DB 원본 사용자 정보:", {
|
||||
userId: userInfo.user_id,
|
||||
company_code: userInfo.company_code,
|
||||
company_code_type: typeof userInfo.company_code,
|
||||
company_code_is_null: userInfo.company_code === null,
|
||||
company_code_is_undefined: userInfo.company_code === undefined,
|
||||
company_code_is_empty: userInfo.company_code === "",
|
||||
dept_code: userInfo.dept_code,
|
||||
allUserFields: Object.keys(userInfo),
|
||||
companyInfo: companyInfo?.company_name,
|
||||
});
|
||||
//console.log("🔍 AuthService - DB 원본 사용자 정보:", {
|
||||
// userId: userInfo.user_id,
|
||||
// company_code: userInfo.company_code,
|
||||
// company_code_type: typeof userInfo.company_code,
|
||||
// company_code_is_null: userInfo.company_code === null,
|
||||
// company_code_is_undefined: userInfo.company_code === undefined,
|
||||
// company_code_is_empty: userInfo.company_code === "",
|
||||
// dept_code: userInfo.dept_code,
|
||||
// allUserFields: Object.keys(userInfo),
|
||||
// companyInfo: companyInfo?.company_name,
|
||||
//});
|
||||
|
||||
// PersonBean 형태로 변환 (null 값을 undefined로 변환)
|
||||
const personBean: PersonBean = {
|
||||
|
|
@ -217,16 +217,16 @@ export class AuthService {
|
|||
authName: authNames || undefined,
|
||||
companyCode: userInfo.company_code || "ILSHIN",
|
||||
photo: userInfo.photo
|
||||
? `data:image/jpeg;base64,${userInfo.photo.toString("base64")}`
|
||||
? `data:image/jpeg;base64,${Buffer.from(userInfo.photo).toString("base64")}`
|
||||
: undefined,
|
||||
locale: userInfo.locale || "KR",
|
||||
};
|
||||
|
||||
console.log("📦 AuthService - 최종 PersonBean:", {
|
||||
userId: personBean.userId,
|
||||
companyCode: personBean.companyCode,
|
||||
deptCode: personBean.deptCode,
|
||||
});
|
||||
//console.log("📦 AuthService - 최종 PersonBean:", {
|
||||
// userId: personBean.userId,
|
||||
// companyCode: personBean.companyCode,
|
||||
// deptCode: personBean.deptCode,
|
||||
//});
|
||||
|
||||
logger.info(`사용자 정보 조회 완료: ${userId}`);
|
||||
return personBean;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,299 @@
|
|||
// 배치 실행 로그 서비스
|
||||
// 작성일: 2024-12-24
|
||||
|
||||
import prisma from "../config/database";
|
||||
import {
|
||||
BatchExecutionLog,
|
||||
CreateBatchExecutionLogRequest,
|
||||
UpdateBatchExecutionLogRequest,
|
||||
BatchExecutionLogFilter,
|
||||
BatchExecutionLogWithConfig
|
||||
} from "../types/batchExecutionLogTypes";
|
||||
import { ApiResponse } from "../types/batchTypes";
|
||||
|
||||
export class BatchExecutionLogService {
|
||||
/**
|
||||
* 배치 실행 로그 목록 조회
|
||||
*/
|
||||
static async getExecutionLogs(
|
||||
filter: BatchExecutionLogFilter = {}
|
||||
): Promise<ApiResponse<BatchExecutionLogWithConfig[]>> {
|
||||
try {
|
||||
const {
|
||||
batch_config_id,
|
||||
execution_status,
|
||||
start_date,
|
||||
end_date,
|
||||
page = 1,
|
||||
limit = 50
|
||||
} = filter;
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
const take = limit;
|
||||
|
||||
// WHERE 조건 구성
|
||||
const where: any = {};
|
||||
|
||||
if (batch_config_id) {
|
||||
where.batch_config_id = batch_config_id;
|
||||
}
|
||||
|
||||
if (execution_status) {
|
||||
where.execution_status = execution_status;
|
||||
}
|
||||
|
||||
if (start_date || end_date) {
|
||||
where.start_time = {};
|
||||
if (start_date) {
|
||||
where.start_time.gte = start_date;
|
||||
}
|
||||
if (end_date) {
|
||||
where.start_time.lte = end_date;
|
||||
}
|
||||
}
|
||||
|
||||
// 로그 조회
|
||||
const [logs, total] = await Promise.all([
|
||||
prisma.batch_execution_logs.findMany({
|
||||
where,
|
||||
include: {
|
||||
batch_config: {
|
||||
select: {
|
||||
id: true,
|
||||
batch_name: true,
|
||||
description: true,
|
||||
cron_schedule: true,
|
||||
is_active: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { start_time: 'desc' },
|
||||
skip,
|
||||
take
|
||||
}),
|
||||
prisma.batch_execution_logs.count({ where })
|
||||
]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: logs as BatchExecutionLogWithConfig[],
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit)
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("배치 실행 로그 조회 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "배치 실행 로그 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 실행 로그 생성
|
||||
*/
|
||||
static async createExecutionLog(
|
||||
data: CreateBatchExecutionLogRequest
|
||||
): Promise<ApiResponse<BatchExecutionLog>> {
|
||||
try {
|
||||
const log = await prisma.batch_execution_logs.create({
|
||||
data: {
|
||||
batch_config_id: data.batch_config_id,
|
||||
execution_status: data.execution_status,
|
||||
start_time: data.start_time || new Date(),
|
||||
end_time: data.end_time,
|
||||
duration_ms: data.duration_ms,
|
||||
total_records: data.total_records || 0,
|
||||
success_records: data.success_records || 0,
|
||||
failed_records: data.failed_records || 0,
|
||||
error_message: data.error_message,
|
||||
error_details: data.error_details,
|
||||
server_name: data.server_name || process.env.HOSTNAME || 'unknown',
|
||||
process_id: data.process_id || process.pid?.toString()
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: log as BatchExecutionLog,
|
||||
message: "배치 실행 로그가 생성되었습니다."
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("배치 실행 로그 생성 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "배치 실행 로그 생성 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 실행 로그 업데이트
|
||||
*/
|
||||
static async updateExecutionLog(
|
||||
id: number,
|
||||
data: UpdateBatchExecutionLogRequest
|
||||
): Promise<ApiResponse<BatchExecutionLog>> {
|
||||
try {
|
||||
const log = await prisma.batch_execution_logs.update({
|
||||
where: { id },
|
||||
data: {
|
||||
execution_status: data.execution_status,
|
||||
end_time: data.end_time,
|
||||
duration_ms: data.duration_ms,
|
||||
total_records: data.total_records,
|
||||
success_records: data.success_records,
|
||||
failed_records: data.failed_records,
|
||||
error_message: data.error_message,
|
||||
error_details: data.error_details
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: log as BatchExecutionLog,
|
||||
message: "배치 실행 로그가 업데이트되었습니다."
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("배치 실행 로그 업데이트 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "배치 실행 로그 업데이트 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 실행 로그 삭제
|
||||
*/
|
||||
static async deleteExecutionLog(id: number): Promise<ApiResponse<void>> {
|
||||
try {
|
||||
await prisma.batch_execution_logs.delete({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "배치 실행 로그가 삭제되었습니다."
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("배치 실행 로그 삭제 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "배치 실행 로그 삭제 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 배치의 최신 실행 로그 조회
|
||||
*/
|
||||
static async getLatestExecutionLog(
|
||||
batchConfigId: number
|
||||
): Promise<ApiResponse<BatchExecutionLog | null>> {
|
||||
try {
|
||||
const log = await prisma.batch_execution_logs.findFirst({
|
||||
where: { batch_config_id: batchConfigId },
|
||||
orderBy: { start_time: 'desc' }
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: log as BatchExecutionLog | null
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("최신 배치 실행 로그 조회 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "최신 배치 실행 로그 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 실행 통계 조회
|
||||
*/
|
||||
static async getExecutionStats(
|
||||
batchConfigId?: number,
|
||||
startDate?: Date,
|
||||
endDate?: Date
|
||||
): Promise<ApiResponse<{
|
||||
total_executions: number;
|
||||
success_count: number;
|
||||
failed_count: number;
|
||||
success_rate: number;
|
||||
average_duration_ms: number;
|
||||
total_records_processed: number;
|
||||
}>> {
|
||||
try {
|
||||
const where: any = {};
|
||||
|
||||
if (batchConfigId) {
|
||||
where.batch_config_id = batchConfigId;
|
||||
}
|
||||
|
||||
if (startDate || endDate) {
|
||||
where.start_time = {};
|
||||
if (startDate) {
|
||||
where.start_time.gte = startDate;
|
||||
}
|
||||
if (endDate) {
|
||||
where.start_time.lte = endDate;
|
||||
}
|
||||
}
|
||||
|
||||
const logs = await prisma.batch_execution_logs.findMany({
|
||||
where,
|
||||
select: {
|
||||
execution_status: true,
|
||||
duration_ms: true,
|
||||
total_records: true
|
||||
}
|
||||
});
|
||||
|
||||
const total_executions = logs.length;
|
||||
const success_count = logs.filter((log: any) => log.execution_status === 'SUCCESS').length;
|
||||
const failed_count = logs.filter((log: any) => log.execution_status === 'FAILED').length;
|
||||
const success_rate = total_executions > 0 ? (success_count / total_executions) * 100 : 0;
|
||||
|
||||
const validDurations = logs
|
||||
.filter((log: any) => log.duration_ms !== null)
|
||||
.map((log: any) => log.duration_ms!);
|
||||
const average_duration_ms = validDurations.length > 0
|
||||
? validDurations.reduce((sum: number, duration: number) => sum + duration, 0) / validDurations.length
|
||||
: 0;
|
||||
|
||||
const total_records_processed = logs
|
||||
.filter((log: any) => log.total_records !== null)
|
||||
.reduce((sum: number, log: any) => sum + (log.total_records || 0), 0);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
total_executions,
|
||||
success_count,
|
||||
failed_count,
|
||||
success_rate,
|
||||
average_duration_ms,
|
||||
total_records_processed
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("배치 실행 통계 조회 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "배치 실행 통계 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,912 @@
|
|||
// 배치관리 전용 외부 DB 서비스
|
||||
// 기존 ExternalDbConnectionService와 분리하여 배치관리 시스템에 특화된 기능 제공
|
||||
// 작성일: 2024-12-24
|
||||
|
||||
import prisma from "../config/database";
|
||||
import { PasswordEncryption } from "../utils/passwordEncryption";
|
||||
import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory";
|
||||
import { RestApiConnector } from "../database/RestApiConnector";
|
||||
import { ApiResponse, ColumnInfo, TableInfo } from "../types/batchTypes";
|
||||
|
||||
export class BatchExternalDbService {
|
||||
/**
|
||||
* 배치관리용 외부 DB 연결 목록 조회
|
||||
*/
|
||||
static async getAvailableConnections(): Promise<ApiResponse<Array<{
|
||||
type: 'internal' | 'external';
|
||||
id?: number;
|
||||
name: string;
|
||||
db_type?: string;
|
||||
}>>> {
|
||||
try {
|
||||
const connections: Array<{
|
||||
type: 'internal' | 'external';
|
||||
id?: number;
|
||||
name: string;
|
||||
db_type?: string;
|
||||
}> = [];
|
||||
|
||||
// 내부 DB 추가
|
||||
connections.push({
|
||||
type: 'internal',
|
||||
name: '내부 데이터베이스 (PostgreSQL)',
|
||||
db_type: 'postgresql'
|
||||
});
|
||||
|
||||
// 활성화된 외부 DB 연결 조회
|
||||
const externalConnections = await prisma.external_db_connections.findMany({
|
||||
where: { is_active: 'Y' },
|
||||
select: {
|
||||
id: true,
|
||||
connection_name: true,
|
||||
db_type: true,
|
||||
description: true
|
||||
},
|
||||
orderBy: { connection_name: 'asc' }
|
||||
});
|
||||
|
||||
// 외부 DB 연결 추가
|
||||
externalConnections.forEach(conn => {
|
||||
connections.push({
|
||||
type: 'external',
|
||||
id: conn.id,
|
||||
name: `${conn.connection_name} (${conn.db_type?.toUpperCase()})`,
|
||||
db_type: conn.db_type || undefined
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: connections,
|
||||
message: `${connections.length}개의 연결을 조회했습니다.`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("배치관리 연결 목록 조회 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "연결 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치관리용 테이블 목록 조회
|
||||
*/
|
||||
static async getTablesFromConnection(
|
||||
connectionType: 'internal' | 'external',
|
||||
connectionId?: number
|
||||
): Promise<ApiResponse<TableInfo[]>> {
|
||||
try {
|
||||
let tables: TableInfo[] = [];
|
||||
|
||||
if (connectionType === 'internal') {
|
||||
// 내부 DB 테이블 조회
|
||||
const result = await prisma.$queryRaw<Array<{ table_name: string }>>`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_type = 'BASE TABLE'
|
||||
ORDER BY table_name
|
||||
`;
|
||||
|
||||
tables = result.map(row => ({
|
||||
table_name: row.table_name,
|
||||
columns: []
|
||||
}));
|
||||
} else if (connectionType === 'external' && connectionId) {
|
||||
// 외부 DB 테이블 조회
|
||||
const tablesResult = await this.getExternalTables(connectionId);
|
||||
if (tablesResult.success && tablesResult.data) {
|
||||
tables = tablesResult.data;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: tables,
|
||||
message: `${tables.length}개의 테이블을 조회했습니다.`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("배치관리 테이블 목록 조회 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "테이블 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치관리용 테이블 컬럼 정보 조회
|
||||
*/
|
||||
static async getTableColumns(
|
||||
connectionType: 'internal' | 'external',
|
||||
connectionId: number | undefined,
|
||||
tableName: string
|
||||
): Promise<ApiResponse<ColumnInfo[]>> {
|
||||
try {
|
||||
console.log(`[BatchExternalDbService] getTableColumns 호출:`, {
|
||||
connectionType,
|
||||
connectionId,
|
||||
tableName
|
||||
});
|
||||
|
||||
let columns: ColumnInfo[] = [];
|
||||
|
||||
if (connectionType === 'internal') {
|
||||
// 내부 DB 컬럼 조회
|
||||
console.log(`[BatchExternalDbService] 내부 DB 컬럼 조회 시작: ${tableName}`);
|
||||
|
||||
const result = await prisma.$queryRaw<Array<{
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable: string;
|
||||
column_default: string | null
|
||||
}>>`
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable,
|
||||
column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = ${tableName}
|
||||
ORDER BY ordinal_position
|
||||
`;
|
||||
|
||||
console.log(`[BatchExternalDbService] 내부 DB 컬럼 조회 결과:`, result);
|
||||
|
||||
columns = result.map(row => ({
|
||||
column_name: row.column_name,
|
||||
data_type: row.data_type,
|
||||
is_nullable: row.is_nullable,
|
||||
column_default: row.column_default,
|
||||
}));
|
||||
} else if (connectionType === 'external' && connectionId) {
|
||||
// 외부 DB 컬럼 조회
|
||||
console.log(`[BatchExternalDbService] 외부 DB 컬럼 조회 시작: connectionId=${connectionId}, tableName=${tableName}`);
|
||||
|
||||
const columnsResult = await this.getExternalTableColumns(connectionId, tableName);
|
||||
|
||||
console.log(`[BatchExternalDbService] 외부 DB 컬럼 조회 결과:`, columnsResult);
|
||||
|
||||
if (columnsResult.success && columnsResult.data) {
|
||||
columns = columnsResult.data;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[BatchExternalDbService] 최종 컬럼 목록:`, columns);
|
||||
return {
|
||||
success: true,
|
||||
data: columns,
|
||||
message: `${columns.length}개의 컬럼을 조회했습니다.`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[BatchExternalDbService] 컬럼 정보 조회 오류:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "컬럼 정보 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 DB 테이블 목록 조회 (내부 구현)
|
||||
*/
|
||||
private static async getExternalTables(connectionId: number): Promise<ApiResponse<TableInfo[]>> {
|
||||
try {
|
||||
// 연결 정보 조회
|
||||
const connection = await prisma.external_db_connections.findUnique({
|
||||
where: { id: connectionId }
|
||||
});
|
||||
|
||||
if (!connection) {
|
||||
return {
|
||||
success: false,
|
||||
message: "연결 정보를 찾을 수 없습니다."
|
||||
};
|
||||
}
|
||||
|
||||
// 비밀번호 복호화
|
||||
const decryptedPassword = PasswordEncryption.decrypt(connection.password);
|
||||
if (!decryptedPassword) {
|
||||
return {
|
||||
success: false,
|
||||
message: "비밀번호 복호화에 실패했습니다."
|
||||
};
|
||||
}
|
||||
|
||||
// 연결 설정 준비
|
||||
const config = {
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
database: connection.database_name,
|
||||
user: connection.username,
|
||||
password: decryptedPassword,
|
||||
connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined,
|
||||
queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined,
|
||||
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
|
||||
};
|
||||
|
||||
// DatabaseConnectorFactory를 통한 테이블 목록 조회
|
||||
const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, connectionId);
|
||||
const tables = await connector.getTables();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "테이블 목록을 조회했습니다.",
|
||||
data: tables
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("외부 DB 테이블 목록 조회 오류:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "테이블 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 DB 테이블 컬럼 정보 조회 (내부 구현)
|
||||
*/
|
||||
private static async getExternalTableColumns(connectionId: number, tableName: string): Promise<ApiResponse<ColumnInfo[]>> {
|
||||
try {
|
||||
console.log(`[BatchExternalDbService] getExternalTableColumns 호출: connectionId=${connectionId}, tableName=${tableName}`);
|
||||
|
||||
// 연결 정보 조회
|
||||
const connection = await prisma.external_db_connections.findUnique({
|
||||
where: { id: connectionId }
|
||||
});
|
||||
|
||||
if (!connection) {
|
||||
console.log(`[BatchExternalDbService] 연결 정보를 찾을 수 없음: connectionId=${connectionId}`);
|
||||
return {
|
||||
success: false,
|
||||
message: "연결 정보를 찾을 수 없습니다."
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`[BatchExternalDbService] 연결 정보 조회 성공:`, {
|
||||
id: connection.id,
|
||||
connection_name: connection.connection_name,
|
||||
db_type: connection.db_type,
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
database_name: connection.database_name
|
||||
});
|
||||
|
||||
// 비밀번호 복호화
|
||||
const decryptedPassword = PasswordEncryption.decrypt(connection.password);
|
||||
|
||||
// 연결 설정 준비
|
||||
const config = {
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
database: connection.database_name,
|
||||
user: connection.username,
|
||||
password: decryptedPassword,
|
||||
connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined,
|
||||
queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined,
|
||||
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
|
||||
};
|
||||
|
||||
console.log(`[BatchExternalDbService] 커넥터 생성 시작: db_type=${connection.db_type}`);
|
||||
|
||||
// 데이터베이스 타입에 따른 커넥터 생성
|
||||
const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, connectionId);
|
||||
|
||||
console.log(`[BatchExternalDbService] 커넥터 생성 완료, 컬럼 조회 시작: tableName=${tableName}`);
|
||||
|
||||
// 컬럼 정보 조회
|
||||
console.log(`[BatchExternalDbService] connector.getColumns 호출 전`);
|
||||
const columns = await connector.getColumns(tableName);
|
||||
|
||||
console.log(`[BatchExternalDbService] 원본 컬럼 조회 결과:`, columns);
|
||||
console.log(`[BatchExternalDbService] 원본 컬럼 개수:`, columns ? columns.length : 'null/undefined');
|
||||
|
||||
// 각 데이터베이스 커넥터의 반환 구조가 다르므로 통일된 구조로 변환
|
||||
const standardizedColumns: ColumnInfo[] = columns.map((col: any) => {
|
||||
console.log(`[BatchExternalDbService] 컬럼 변환 중:`, col);
|
||||
|
||||
// MySQL/MariaDB 구조: {name, dataType, isNullable, defaultValue} (MySQLConnector만)
|
||||
if (col.name && col.dataType !== undefined) {
|
||||
const result = {
|
||||
column_name: col.name,
|
||||
data_type: col.dataType,
|
||||
is_nullable: col.isNullable ? 'YES' : 'NO',
|
||||
column_default: col.defaultValue || null,
|
||||
};
|
||||
console.log(`[BatchExternalDbService] MySQL/MariaDB 구조로 변환:`, result);
|
||||
return result;
|
||||
}
|
||||
// PostgreSQL/Oracle/MSSQL/MariaDB 구조: {column_name, data_type, is_nullable, column_default}
|
||||
else {
|
||||
const result = {
|
||||
column_name: col.column_name || col.COLUMN_NAME,
|
||||
data_type: col.data_type || col.DATA_TYPE,
|
||||
is_nullable: col.is_nullable || col.IS_NULLABLE || (col.nullable === 'Y' ? 'YES' : 'NO'),
|
||||
column_default: col.column_default || col.COLUMN_DEFAULT || null,
|
||||
};
|
||||
console.log(`[BatchExternalDbService] 표준 구조로 변환:`, result);
|
||||
return result;
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[BatchExternalDbService] 표준화된 컬럼 목록:`, standardizedColumns);
|
||||
|
||||
// 빈 배열인 경우 경고 로그
|
||||
if (!standardizedColumns || standardizedColumns.length === 0) {
|
||||
console.warn(`[BatchExternalDbService] 컬럼이 비어있음: connectionId=${connectionId}, tableName=${tableName}`);
|
||||
console.warn(`[BatchExternalDbService] 연결 정보:`, {
|
||||
db_type: connection.db_type,
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
database_name: connection.database_name,
|
||||
username: connection.username
|
||||
});
|
||||
|
||||
// 테이블 존재 여부 확인
|
||||
console.warn(`[BatchExternalDbService] 테이블 존재 여부 확인을 위해 테이블 목록 조회 시도`);
|
||||
try {
|
||||
const tables = await connector.getTables();
|
||||
console.warn(`[BatchExternalDbService] 사용 가능한 테이블 목록:`, tables.map(t => t.table_name));
|
||||
|
||||
// 테이블명이 정확한지 확인
|
||||
const tableExists = tables.some(t => t.table_name.toLowerCase() === tableName.toLowerCase());
|
||||
console.warn(`[BatchExternalDbService] 테이블 존재 여부: ${tableExists}`);
|
||||
|
||||
// 정확한 테이블명 찾기
|
||||
const exactTable = tables.find(t => t.table_name.toLowerCase() === tableName.toLowerCase());
|
||||
if (exactTable) {
|
||||
console.warn(`[BatchExternalDbService] 정확한 테이블명: ${exactTable.table_name}`);
|
||||
}
|
||||
|
||||
// 모든 테이블명 출력
|
||||
console.warn(`[BatchExternalDbService] 모든 테이블명:`, tables.map(t => `"${t.table_name}"`));
|
||||
|
||||
// 테이블명 비교
|
||||
console.warn(`[BatchExternalDbService] 요청된 테이블명: "${tableName}"`);
|
||||
console.warn(`[BatchExternalDbService] 테이블명 비교 결과:`, tables.map(t => ({
|
||||
table_name: t.table_name,
|
||||
matches: t.table_name.toLowerCase() === tableName.toLowerCase(),
|
||||
exact_match: t.table_name === tableName
|
||||
})));
|
||||
|
||||
// 정확한 테이블명으로 다시 시도
|
||||
if (exactTable && exactTable.table_name !== tableName) {
|
||||
console.warn(`[BatchExternalDbService] 정확한 테이블명으로 다시 시도: ${exactTable.table_name}`);
|
||||
try {
|
||||
const correctColumns = await connector.getColumns(exactTable.table_name);
|
||||
console.warn(`[BatchExternalDbService] 정확한 테이블명으로 조회한 컬럼:`, correctColumns);
|
||||
} catch (correctError) {
|
||||
console.error(`[BatchExternalDbService] 정확한 테이블명으로 조회 실패:`, correctError);
|
||||
}
|
||||
}
|
||||
} catch (tableError) {
|
||||
console.error(`[BatchExternalDbService] 테이블 목록 조회 실패:`, tableError);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: standardizedColumns,
|
||||
message: "컬럼 정보를 조회했습니다."
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[BatchExternalDbService] 외부 DB 컬럼 정보 조회 오류:", error);
|
||||
console.error("[BatchExternalDbService] 오류 스택:", error instanceof Error ? error.stack : 'No stack trace');
|
||||
return {
|
||||
success: false,
|
||||
message: "컬럼 정보 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 DB 테이블에서 데이터 조회
|
||||
*/
|
||||
static async getDataFromTable(
|
||||
connectionId: number,
|
||||
tableName: string,
|
||||
limit: number = 100
|
||||
): Promise<ApiResponse<any[]>> {
|
||||
try {
|
||||
console.log(`[BatchExternalDbService] 외부 DB 데이터 조회: connectionId=${connectionId}, tableName=${tableName}`);
|
||||
|
||||
// 외부 DB 연결 정보 조회
|
||||
const connection = await prisma.external_db_connections.findUnique({
|
||||
where: { id: connectionId }
|
||||
});
|
||||
|
||||
if (!connection) {
|
||||
return {
|
||||
success: false,
|
||||
message: "외부 DB 연결을 찾을 수 없습니다."
|
||||
};
|
||||
}
|
||||
|
||||
// 패스워드 복호화
|
||||
const decryptedPassword = PasswordEncryption.decrypt(connection.password);
|
||||
|
||||
// DB 연결 설정
|
||||
const config = {
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
user: connection.username,
|
||||
password: decryptedPassword,
|
||||
database: connection.database_name,
|
||||
};
|
||||
|
||||
// DB 커넥터 생성
|
||||
const connector = await DatabaseConnectorFactory.createConnector(
|
||||
connection.db_type || 'postgresql',
|
||||
config,
|
||||
connectionId
|
||||
);
|
||||
|
||||
// 데이터 조회 (DB 타입에 따라 쿼리 구문 변경)
|
||||
let query: string;
|
||||
const dbType = connection.db_type?.toLowerCase() || 'postgresql';
|
||||
|
||||
if (dbType === 'oracle') {
|
||||
query = `SELECT * FROM ${tableName} WHERE ROWNUM <= ${limit}`;
|
||||
} else {
|
||||
query = `SELECT * FROM ${tableName} LIMIT ${limit}`;
|
||||
}
|
||||
|
||||
console.log(`[BatchExternalDbService] 실행할 쿼리: ${query}`);
|
||||
const result = await connector.executeQuery(query);
|
||||
|
||||
console.log(`[BatchExternalDbService] 외부 DB 데이터 조회 완료: ${result.rows.length}개 레코드`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.rows
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`외부 DB 데이터 조회 오류 (connectionId: ${connectionId}, table: ${tableName}):`, error);
|
||||
return {
|
||||
success: false,
|
||||
message: "외부 DB 데이터 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 DB 테이블에서 특정 컬럼들만 조회
|
||||
*/
|
||||
static async getDataFromTableWithColumns(
|
||||
connectionId: number,
|
||||
tableName: string,
|
||||
columns: string[],
|
||||
limit: number = 100
|
||||
): Promise<ApiResponse<any[]>> {
|
||||
try {
|
||||
console.log(`[BatchExternalDbService] 외부 DB 특정 컬럼 조회: connectionId=${connectionId}, tableName=${tableName}, columns=[${columns.join(', ')}]`);
|
||||
|
||||
// 외부 DB 연결 정보 조회
|
||||
const connection = await prisma.external_db_connections.findUnique({
|
||||
where: { id: connectionId }
|
||||
});
|
||||
|
||||
if (!connection) {
|
||||
return {
|
||||
success: false,
|
||||
message: "외부 DB 연결을 찾을 수 없습니다."
|
||||
};
|
||||
}
|
||||
|
||||
// 패스워드 복호화
|
||||
const decryptedPassword = PasswordEncryption.decrypt(connection.password);
|
||||
|
||||
// DB 연결 설정
|
||||
const config = {
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
user: connection.username,
|
||||
password: decryptedPassword,
|
||||
database: connection.database_name,
|
||||
};
|
||||
|
||||
// DB 커넥터 생성
|
||||
const connector = await DatabaseConnectorFactory.createConnector(
|
||||
connection.db_type || 'postgresql',
|
||||
config,
|
||||
connectionId
|
||||
);
|
||||
|
||||
// 데이터 조회 (DB 타입에 따라 쿼리 구문 변경)
|
||||
let query: string;
|
||||
const dbType = connection.db_type?.toLowerCase() || 'postgresql';
|
||||
const columnList = columns.join(', ');
|
||||
|
||||
if (dbType === 'oracle') {
|
||||
query = `SELECT ${columnList} FROM ${tableName} WHERE ROWNUM <= ${limit}`;
|
||||
} else {
|
||||
query = `SELECT ${columnList} FROM ${tableName} LIMIT ${limit}`;
|
||||
}
|
||||
|
||||
console.log(`[BatchExternalDbService] 실행할 쿼리: ${query}`);
|
||||
const result = await connector.executeQuery(query);
|
||||
|
||||
console.log(`[BatchExternalDbService] 외부 DB 특정 컬럼 조회 완료: ${result.rows.length}개 레코드`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.rows
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`외부 DB 특정 컬럼 조회 오류 (connectionId: ${connectionId}, table: ${tableName}):`, error);
|
||||
return {
|
||||
success: false,
|
||||
message: "외부 DB 특정 컬럼 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 DB 테이블에 데이터 삽입
|
||||
*/
|
||||
static async insertDataToTable(
|
||||
connectionId: number,
|
||||
tableName: string,
|
||||
data: any[]
|
||||
): Promise<ApiResponse<{ successCount: number; failedCount: number }>> {
|
||||
try {
|
||||
console.log(`[BatchExternalDbService] 외부 DB 데이터 삽입: connectionId=${connectionId}, tableName=${tableName}, ${data.length}개 레코드`);
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
data: { successCount: 0, failedCount: 0 }
|
||||
};
|
||||
}
|
||||
|
||||
// 외부 DB 연결 정보 조회
|
||||
const connection = await prisma.external_db_connections.findUnique({
|
||||
where: { id: connectionId }
|
||||
});
|
||||
|
||||
if (!connection) {
|
||||
return {
|
||||
success: false,
|
||||
message: "외부 DB 연결을 찾을 수 없습니다."
|
||||
};
|
||||
}
|
||||
|
||||
// 패스워드 복호화
|
||||
const decryptedPassword = PasswordEncryption.decrypt(connection.password);
|
||||
|
||||
// DB 연결 설정
|
||||
const config = {
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
user: connection.username,
|
||||
password: decryptedPassword,
|
||||
database: connection.database_name,
|
||||
};
|
||||
|
||||
// DB 커넥터 생성
|
||||
const connector = await DatabaseConnectorFactory.createConnector(
|
||||
connection.db_type || 'postgresql',
|
||||
config,
|
||||
connectionId
|
||||
);
|
||||
|
||||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
// 각 레코드를 개별적으로 삽입 (UPSERT 방식으로 중복 처리)
|
||||
for (const record of data) {
|
||||
try {
|
||||
const columns = Object.keys(record);
|
||||
const values = Object.values(record);
|
||||
|
||||
// 값들을 SQL 문자열로 변환 (타입별 처리)
|
||||
const formattedValues = values.map(value => {
|
||||
if (value === null || value === undefined) {
|
||||
return 'NULL';
|
||||
} else if (value instanceof Date) {
|
||||
// Date 객체를 MySQL/MariaDB 형식으로 변환
|
||||
return `'${value.toISOString().slice(0, 19).replace('T', ' ')}'`;
|
||||
} else if (typeof value === 'string') {
|
||||
// 문자열이 날짜 형식인지 확인
|
||||
const dateRegex = /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{2}\s+\d{4}\s+\d{2}:\d{2}:\d{2}/;
|
||||
if (dateRegex.test(value)) {
|
||||
// JavaScript Date 문자열을 MySQL 형식으로 변환
|
||||
const date = new Date(value);
|
||||
return `'${date.toISOString().slice(0, 19).replace('T', ' ')}'`;
|
||||
} else {
|
||||
return `'${value.replace(/'/g, "''")}'`; // SQL 인젝션 방지를 위한 간단한 이스케이프
|
||||
}
|
||||
} else if (typeof value === 'number') {
|
||||
return String(value);
|
||||
} else if (typeof value === 'boolean') {
|
||||
return value ? '1' : '0';
|
||||
} else {
|
||||
// 기타 객체는 문자열로 변환
|
||||
return `'${String(value).replace(/'/g, "''")}'`;
|
||||
}
|
||||
}).join(', ');
|
||||
|
||||
// Primary Key 컬럼 추정
|
||||
const primaryKeyColumn = columns.includes('id') ? 'id' :
|
||||
columns.includes('user_id') ? 'user_id' :
|
||||
columns[0];
|
||||
|
||||
// UPDATE SET 절 생성 (Primary Key 제외)
|
||||
const updateColumns = columns.filter(col => col !== primaryKeyColumn);
|
||||
|
||||
let query: string;
|
||||
const dbType = connection.db_type?.toLowerCase() || 'mysql';
|
||||
|
||||
if (dbType === 'mysql' || dbType === 'mariadb') {
|
||||
// MySQL/MariaDB: ON DUPLICATE KEY UPDATE 사용
|
||||
if (updateColumns.length > 0) {
|
||||
const updateSet = updateColumns.map(col => `${col} = VALUES(${col})`).join(', ');
|
||||
query = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${formattedValues})
|
||||
ON DUPLICATE KEY UPDATE ${updateSet}`;
|
||||
} else {
|
||||
// Primary Key만 있는 경우 IGNORE 사용
|
||||
query = `INSERT IGNORE INTO ${tableName} (${columns.join(', ')}) VALUES (${formattedValues})`;
|
||||
}
|
||||
} else {
|
||||
// 다른 DB는 기본 INSERT 사용
|
||||
query = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${formattedValues})`;
|
||||
}
|
||||
|
||||
console.log(`[BatchExternalDbService] 실행할 쿼리: ${query}`);
|
||||
console.log(`[BatchExternalDbService] 삽입할 데이터:`, record);
|
||||
|
||||
await connector.executeQuery(query);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
console.error(`외부 DB 레코드 UPSERT 실패:`, error);
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[BatchExternalDbService] 외부 DB 데이터 삽입 완료: 성공 ${successCount}개, 실패 ${failedCount}개`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { successCount, failedCount }
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`외부 DB 데이터 삽입 오류 (connectionId: ${connectionId}, table: ${tableName}):`, error);
|
||||
return {
|
||||
success: false,
|
||||
message: "외부 DB 데이터 삽입 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* REST API에서 데이터 조회
|
||||
*/
|
||||
static async getDataFromRestApi(
|
||||
apiUrl: string,
|
||||
apiKey: string,
|
||||
endpoint: string,
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET',
|
||||
columns?: string[],
|
||||
limit: number = 100
|
||||
): Promise<ApiResponse<any[]>> {
|
||||
try {
|
||||
console.log(`[BatchExternalDbService] REST API 데이터 조회: ${apiUrl}${endpoint}`);
|
||||
|
||||
// REST API 커넥터 생성
|
||||
const connector = new RestApiConnector({
|
||||
baseUrl: apiUrl,
|
||||
apiKey: apiKey,
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
// 연결 테스트
|
||||
await connector.connect();
|
||||
|
||||
// 데이터 조회
|
||||
const result = await connector.executeQuery(endpoint, method);
|
||||
let data = result.rows;
|
||||
|
||||
// 컬럼 필터링 (지정된 컬럼만 추출)
|
||||
if (columns && columns.length > 0) {
|
||||
data = data.map(row => {
|
||||
const filteredRow: any = {};
|
||||
columns.forEach(col => {
|
||||
if (row.hasOwnProperty(col)) {
|
||||
filteredRow[col] = row[col];
|
||||
}
|
||||
});
|
||||
return filteredRow;
|
||||
});
|
||||
}
|
||||
|
||||
// 제한 개수 적용
|
||||
if (limit > 0) {
|
||||
data = data.slice(0, limit);
|
||||
}
|
||||
|
||||
console.log(`[BatchExternalDbService] REST API 데이터 조회 완료: ${data.length}개 레코드`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: data
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[BatchExternalDbService] REST API 데이터 조회 오류 (${apiUrl}${endpoint}):`, error);
|
||||
return {
|
||||
success: false,
|
||||
message: "REST API 데이터 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 기반 REST API로 데이터 전송 (DB → REST API 배치용)
|
||||
*/
|
||||
static async sendDataToRestApiWithTemplate(
|
||||
apiUrl: string,
|
||||
apiKey: string,
|
||||
endpoint: string,
|
||||
method: 'POST' | 'PUT' | 'DELETE' = 'POST',
|
||||
templateBody: string,
|
||||
data: any[],
|
||||
urlPathColumn?: string // URL 경로에 사용할 컬럼명 (PUT/DELETE용)
|
||||
): Promise<ApiResponse<{ successCount: number; failedCount: number }>> {
|
||||
try {
|
||||
console.log(`[BatchExternalDbService] 템플릿 기반 REST API 데이터 전송: ${apiUrl}${endpoint}, ${data.length}개 레코드`);
|
||||
console.log(`[BatchExternalDbService] Request Body 템플릿:`, templateBody);
|
||||
|
||||
// REST API 커넥터 생성
|
||||
const connector = new RestApiConnector({
|
||||
baseUrl: apiUrl,
|
||||
apiKey: apiKey,
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
// 연결 테스트
|
||||
await connector.connect();
|
||||
|
||||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
// 각 레코드를 개별적으로 전송
|
||||
for (const record of data) {
|
||||
try {
|
||||
// 템플릿 처리: {{컬럼명}} → 실제 값으로 치환
|
||||
let processedBody = templateBody;
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
const placeholder = `{{${key}}}`;
|
||||
let stringValue = '';
|
||||
|
||||
if (value !== null && value !== undefined) {
|
||||
// Date 객체인 경우 다양한 포맷으로 변환
|
||||
if (value instanceof Date) {
|
||||
// ISO 형식: 2025-09-25T07:22:52.000Z
|
||||
stringValue = value.toISOString();
|
||||
|
||||
// 다른 포맷이 필요한 경우 여기서 처리
|
||||
// 예: YYYY-MM-DD 형식
|
||||
// stringValue = value.toISOString().split('T')[0];
|
||||
|
||||
// 예: YYYY-MM-DD HH:mm:ss 형식
|
||||
// stringValue = value.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
|
||||
} else {
|
||||
stringValue = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
processedBody = processedBody.replace(new RegExp(placeholder.replace(/[{}]/g, '\\$&'), 'g'), stringValue);
|
||||
}
|
||||
|
||||
console.log(`[BatchExternalDbService] 원본 레코드:`, record);
|
||||
console.log(`[BatchExternalDbService] 처리된 Request Body:`, processedBody);
|
||||
|
||||
// JSON 파싱하여 객체로 변환
|
||||
let requestData;
|
||||
try {
|
||||
requestData = JSON.parse(processedBody);
|
||||
} catch (parseError) {
|
||||
console.error(`[BatchExternalDbService] JSON 파싱 오류:`, parseError);
|
||||
throw new Error(`Request Body JSON 파싱 실패: ${parseError}`);
|
||||
}
|
||||
|
||||
// URL 경로 파라미터 처리 (PUT/DELETE용)
|
||||
let finalEndpoint = endpoint;
|
||||
if ((method === 'PUT' || method === 'DELETE') && urlPathColumn && record[urlPathColumn]) {
|
||||
// /api/users → /api/users/user123
|
||||
finalEndpoint = `${endpoint}/${record[urlPathColumn]}`;
|
||||
}
|
||||
|
||||
console.log(`[BatchExternalDbService] 실행할 API 호출: ${method} ${finalEndpoint}`);
|
||||
console.log(`[BatchExternalDbService] 전송할 데이터:`, requestData);
|
||||
|
||||
await connector.executeQuery(finalEndpoint, method, requestData);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
console.error(`REST API 레코드 전송 실패:`, error);
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[BatchExternalDbService] 템플릿 기반 REST API 데이터 전송 완료: 성공 ${successCount}개, 실패 ${failedCount}개`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { successCount, failedCount }
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[BatchExternalDbService] 템플릿 기반 REST API 데이터 전송 오류:`, error);
|
||||
return {
|
||||
success: false,
|
||||
message: `REST API 데이터 전송 실패: ${error}`,
|
||||
data: { successCount: 0, failedCount: 0 }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* REST API로 데이터 전송 (기존 메서드)
|
||||
*/
|
||||
static async sendDataToRestApi(
|
||||
apiUrl: string,
|
||||
apiKey: string,
|
||||
endpoint: string,
|
||||
method: 'POST' | 'PUT' = 'POST',
|
||||
data: any[]
|
||||
): Promise<ApiResponse<{ successCount: number; failedCount: number }>> {
|
||||
try {
|
||||
console.log(`[BatchExternalDbService] REST API 데이터 전송: ${apiUrl}${endpoint}, ${data.length}개 레코드`);
|
||||
|
||||
// REST API 커넥터 생성
|
||||
const connector = new RestApiConnector({
|
||||
baseUrl: apiUrl,
|
||||
apiKey: apiKey,
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
// 연결 테스트
|
||||
await connector.connect();
|
||||
|
||||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
// 각 레코드를 개별적으로 전송
|
||||
for (const record of data) {
|
||||
try {
|
||||
console.log(`[BatchExternalDbService] 실행할 API 호출: ${method} ${endpoint}`);
|
||||
console.log(`[BatchExternalDbService] 전송할 데이터:`, record);
|
||||
|
||||
await connector.executeQuery(endpoint, method, record);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
console.error(`REST API 레코드 전송 실패:`, error);
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[BatchExternalDbService] REST API 데이터 전송 완료: 성공 ${successCount}개, 실패 ${failedCount}개`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { successCount, failedCount }
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[BatchExternalDbService] REST API 데이터 전송 오류 (${apiUrl}${endpoint}):`, error);
|
||||
return {
|
||||
success: false,
|
||||
message: "REST API 데이터 전송 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,373 @@
|
|||
// 배치관리 전용 서비스 (기존 소스와 완전 분리)
|
||||
// 작성일: 2024-12-24
|
||||
|
||||
import prisma from "../config/database";
|
||||
import { PasswordEncryption } from "../utils/passwordEncryption";
|
||||
import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory";
|
||||
|
||||
// 배치관리 전용 타입 정의
|
||||
export interface BatchConnectionInfo {
|
||||
type: 'internal' | 'external';
|
||||
id?: number;
|
||||
name: string;
|
||||
db_type?: string;
|
||||
}
|
||||
|
||||
export interface BatchTableInfo {
|
||||
table_name: string;
|
||||
columns: BatchColumnInfo[];
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface BatchColumnInfo {
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable?: string;
|
||||
column_default?: string | null;
|
||||
}
|
||||
|
||||
export interface BatchApiResponse<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class BatchManagementService {
|
||||
/**
|
||||
* 배치관리용 연결 목록 조회
|
||||
*/
|
||||
static async getAvailableConnections(): Promise<BatchApiResponse<BatchConnectionInfo[]>> {
|
||||
try {
|
||||
const connections: BatchConnectionInfo[] = [];
|
||||
|
||||
// 내부 DB 추가
|
||||
connections.push({
|
||||
type: 'internal',
|
||||
name: '내부 데이터베이스 (PostgreSQL)',
|
||||
db_type: 'postgresql'
|
||||
});
|
||||
|
||||
// 활성화된 외부 DB 연결 조회
|
||||
const externalConnections = await prisma.external_db_connections.findMany({
|
||||
where: { is_active: 'Y' },
|
||||
select: {
|
||||
id: true,
|
||||
connection_name: true,
|
||||
db_type: true,
|
||||
description: true
|
||||
},
|
||||
orderBy: { connection_name: 'asc' }
|
||||
});
|
||||
|
||||
// 외부 DB 연결 추가
|
||||
externalConnections.forEach(conn => {
|
||||
connections.push({
|
||||
type: 'external',
|
||||
id: conn.id,
|
||||
name: `${conn.connection_name} (${conn.db_type?.toUpperCase()})`,
|
||||
db_type: conn.db_type || undefined
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: connections,
|
||||
message: `${connections.length}개의 연결을 조회했습니다.`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("배치관리 연결 목록 조회 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "연결 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치관리용 테이블 목록 조회
|
||||
*/
|
||||
static async getTablesFromConnection(
|
||||
connectionType: 'internal' | 'external',
|
||||
connectionId?: number
|
||||
): Promise<BatchApiResponse<BatchTableInfo[]>> {
|
||||
try {
|
||||
let tables: BatchTableInfo[] = [];
|
||||
|
||||
if (connectionType === 'internal') {
|
||||
// 내부 DB 테이블 조회
|
||||
const result = await prisma.$queryRaw<Array<{ table_name: string }>>`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_type = 'BASE TABLE'
|
||||
ORDER BY table_name
|
||||
`;
|
||||
|
||||
tables = result.map(row => ({
|
||||
table_name: row.table_name,
|
||||
columns: []
|
||||
}));
|
||||
} else if (connectionType === 'external' && connectionId) {
|
||||
// 외부 DB 테이블 조회
|
||||
const tablesResult = await this.getExternalTables(connectionId);
|
||||
if (tablesResult.success && tablesResult.data) {
|
||||
tables = tablesResult.data;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: tables,
|
||||
message: `${tables.length}개의 테이블을 조회했습니다.`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("배치관리 테이블 목록 조회 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "테이블 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치관리용 테이블 컬럼 정보 조회
|
||||
*/
|
||||
static async getTableColumns(
|
||||
connectionType: 'internal' | 'external',
|
||||
connectionId: number | undefined,
|
||||
tableName: string
|
||||
): Promise<BatchApiResponse<BatchColumnInfo[]>> {
|
||||
try {
|
||||
console.log(`[BatchManagementService] getTableColumns 호출:`, {
|
||||
connectionType,
|
||||
connectionId,
|
||||
tableName
|
||||
});
|
||||
|
||||
let columns: BatchColumnInfo[] = [];
|
||||
|
||||
if (connectionType === 'internal') {
|
||||
// 내부 DB 컬럼 조회
|
||||
console.log(`[BatchManagementService] 내부 DB 컬럼 조회 시작: ${tableName}`);
|
||||
|
||||
const result = await prisma.$queryRaw<Array<{
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable: string;
|
||||
column_default: string | null
|
||||
}>>`
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable,
|
||||
column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = ${tableName}
|
||||
ORDER BY ordinal_position
|
||||
`;
|
||||
|
||||
console.log(`[BatchManagementService] 쿼리 결과:`, result);
|
||||
|
||||
console.log(`[BatchManagementService] 내부 DB 컬럼 조회 결과:`, result);
|
||||
|
||||
columns = result.map(row => ({
|
||||
column_name: row.column_name,
|
||||
data_type: row.data_type,
|
||||
is_nullable: row.is_nullable,
|
||||
column_default: row.column_default,
|
||||
}));
|
||||
} else if (connectionType === 'external' && connectionId) {
|
||||
// 외부 DB 컬럼 조회
|
||||
console.log(`[BatchManagementService] 외부 DB 컬럼 조회 시작: connectionId=${connectionId}, tableName=${tableName}`);
|
||||
|
||||
const columnsResult = await this.getExternalTableColumns(connectionId, tableName);
|
||||
|
||||
console.log(`[BatchManagementService] 외부 DB 컬럼 조회 결과:`, columnsResult);
|
||||
|
||||
if (columnsResult.success && columnsResult.data) {
|
||||
columns = columnsResult.data;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[BatchManagementService] 최종 컬럼 목록:`, columns);
|
||||
return {
|
||||
success: true,
|
||||
data: columns,
|
||||
message: `${columns.length}개의 컬럼을 조회했습니다.`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[BatchManagementService] 컬럼 정보 조회 오류:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "컬럼 정보 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 DB 테이블 목록 조회 (내부 구현)
|
||||
*/
|
||||
private static async getExternalTables(connectionId: number): Promise<BatchApiResponse<BatchTableInfo[]>> {
|
||||
try {
|
||||
// 연결 정보 조회
|
||||
const connection = await prisma.external_db_connections.findUnique({
|
||||
where: { id: connectionId }
|
||||
});
|
||||
|
||||
if (!connection) {
|
||||
return {
|
||||
success: false,
|
||||
message: "연결 정보를 찾을 수 없습니다."
|
||||
};
|
||||
}
|
||||
|
||||
// 비밀번호 복호화
|
||||
const decryptedPassword = PasswordEncryption.decrypt(connection.password);
|
||||
if (!decryptedPassword) {
|
||||
return {
|
||||
success: false,
|
||||
message: "비밀번호 복호화에 실패했습니다."
|
||||
};
|
||||
}
|
||||
|
||||
// 연결 설정 준비
|
||||
const config = {
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
database: connection.database_name,
|
||||
user: connection.username,
|
||||
password: decryptedPassword,
|
||||
connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined,
|
||||
queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined,
|
||||
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
|
||||
};
|
||||
|
||||
// DatabaseConnectorFactory를 통한 테이블 목록 조회
|
||||
const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, connectionId);
|
||||
const tables = await connector.getTables();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "테이블 목록을 조회했습니다.",
|
||||
data: tables
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("외부 DB 테이블 목록 조회 오류:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "테이블 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 DB 테이블 컬럼 정보 조회 (내부 구현)
|
||||
*/
|
||||
private static async getExternalTableColumns(connectionId: number, tableName: string): Promise<BatchApiResponse<BatchColumnInfo[]>> {
|
||||
try {
|
||||
console.log(`[BatchManagementService] getExternalTableColumns 호출: connectionId=${connectionId}, tableName=${tableName}`);
|
||||
|
||||
// 연결 정보 조회
|
||||
const connection = await prisma.external_db_connections.findUnique({
|
||||
where: { id: connectionId }
|
||||
});
|
||||
|
||||
if (!connection) {
|
||||
console.log(`[BatchManagementService] 연결 정보를 찾을 수 없음: connectionId=${connectionId}`);
|
||||
return {
|
||||
success: false,
|
||||
message: "연결 정보를 찾을 수 없습니다."
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`[BatchManagementService] 연결 정보 조회 성공:`, {
|
||||
id: connection.id,
|
||||
connection_name: connection.connection_name,
|
||||
db_type: connection.db_type,
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
database_name: connection.database_name
|
||||
});
|
||||
|
||||
// 비밀번호 복호화
|
||||
const decryptedPassword = PasswordEncryption.decrypt(connection.password);
|
||||
|
||||
// 연결 설정 준비
|
||||
const config = {
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
database: connection.database_name,
|
||||
user: connection.username,
|
||||
password: decryptedPassword,
|
||||
connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined,
|
||||
queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined,
|
||||
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
|
||||
};
|
||||
|
||||
console.log(`[BatchManagementService] 커넥터 생성 시작: db_type=${connection.db_type}`);
|
||||
|
||||
// 데이터베이스 타입에 따른 커넥터 생성
|
||||
const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, connectionId);
|
||||
|
||||
console.log(`[BatchManagementService] 커넥터 생성 완료, 컬럼 조회 시작: tableName=${tableName}`);
|
||||
|
||||
// 컬럼 정보 조회
|
||||
console.log(`[BatchManagementService] connector.getColumns 호출 전`);
|
||||
const columns = await connector.getColumns(tableName);
|
||||
|
||||
console.log(`[BatchManagementService] 원본 컬럼 조회 결과:`, columns);
|
||||
console.log(`[BatchManagementService] 원본 컬럼 개수:`, columns ? columns.length : 'null/undefined');
|
||||
|
||||
// 각 데이터베이스 커넥터의 반환 구조가 다르므로 통일된 구조로 변환
|
||||
const standardizedColumns: BatchColumnInfo[] = columns.map((col: any) => {
|
||||
console.log(`[BatchManagementService] 컬럼 변환 중:`, col);
|
||||
|
||||
// MySQL/MariaDB 구조: {name, dataType, isNullable, defaultValue} (MySQLConnector만)
|
||||
if (col.name && col.dataType !== undefined) {
|
||||
const result = {
|
||||
column_name: col.name,
|
||||
data_type: col.dataType,
|
||||
is_nullable: col.isNullable ? 'YES' : 'NO',
|
||||
column_default: col.defaultValue || null,
|
||||
};
|
||||
console.log(`[BatchManagementService] MySQL/MariaDB 구조로 변환:`, result);
|
||||
return result;
|
||||
}
|
||||
// PostgreSQL/Oracle/MSSQL/MariaDB 구조: {column_name, data_type, is_nullable, column_default}
|
||||
else {
|
||||
const result = {
|
||||
column_name: col.column_name || col.COLUMN_NAME,
|
||||
data_type: col.data_type || col.DATA_TYPE,
|
||||
is_nullable: col.is_nullable || col.IS_NULLABLE || (col.nullable === 'Y' ? 'YES' : 'NO'),
|
||||
column_default: col.column_default || col.COLUMN_DEFAULT || null,
|
||||
};
|
||||
console.log(`[BatchManagementService] 표준 구조로 변환:`, result);
|
||||
return result;
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[BatchManagementService] 표준화된 컬럼 목록:`, standardizedColumns);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: standardizedColumns,
|
||||
message: "컬럼 정보를 조회했습니다."
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[BatchManagementService] 외부 DB 컬럼 정보 조회 오류:", error);
|
||||
console.error("[BatchManagementService] 오류 스택:", error instanceof Error ? error.stack : 'No stack trace');
|
||||
return {
|
||||
success: false,
|
||||
message: "컬럼 정보 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,484 @@
|
|||
// 배치 스케줄러 서비스
|
||||
// 작성일: 2024-12-24
|
||||
|
||||
import * as cron from 'node-cron';
|
||||
import prisma from '../config/database';
|
||||
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;
|
||||
|
||||
/**
|
||||
* 스케줄러 초기화
|
||||
*/
|
||||
static async initialize() {
|
||||
if (this.isInitialized) {
|
||||
logger.info('배치 스케줄러가 이미 초기화되었습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info('배치 스케줄러 초기화 시작...');
|
||||
|
||||
// 활성화된 배치 설정들을 로드하여 스케줄 등록
|
||||
await this.loadActiveBatchConfigs();
|
||||
|
||||
this.isInitialized = true;
|
||||
logger.info('배치 스케줄러 초기화 완료');
|
||||
} catch (error) {
|
||||
logger.error('배치 스케줄러 초기화 실패:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성화된 배치 설정들을 로드하여 스케줄 등록
|
||||
*/
|
||||
private static async loadActiveBatchConfigs() {
|
||||
try {
|
||||
const activeConfigs = await prisma.batch_configs.findMany({
|
||||
where: {
|
||||
is_active: 'Y'
|
||||
},
|
||||
include: {
|
||||
batch_mappings: true
|
||||
}
|
||||
});
|
||||
|
||||
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})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 새로운 스케줄 등록
|
||||
const task = cron.schedule(cron_schedule, async () => {
|
||||
logger.info(`🔄 스케줄 배치 실행 시작: ${batch_name} (ID: ${id})`);
|
||||
await this.executeBatchConfig(config);
|
||||
});
|
||||
|
||||
// 스케줄 시작 (기본적으로 시작되지만 명시적으로 호출)
|
||||
task.start();
|
||||
|
||||
this.scheduledTasks.set(id, task);
|
||||
logger.info(`배치 스케줄 등록 완료: ${batch_name} (ID: ${id}, Schedule: ${cron_schedule}) - 스케줄 시작됨`);
|
||||
} catch (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) {
|
||||
try {
|
||||
// 기존 스케줄 제거
|
||||
await this.unscheduleBatchConfig(configId);
|
||||
|
||||
// 업데이트된 배치 설정 조회
|
||||
const config = await prisma.batch_configs.findUnique({
|
||||
where: { id: configId },
|
||||
include: { batch_mappings: true }
|
||||
});
|
||||
|
||||
if (!config) {
|
||||
logger.warn(`배치 설정을 찾을 수 없습니다: ID ${configId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 활성화된 배치만 다시 스케줄 등록
|
||||
if (config.is_active === 'Y') {
|
||||
await this.scheduleBatchConfig(config);
|
||||
logger.info(`배치 스케줄 업데이트 완료: ${config.batch_name} (ID: ${configId})`);
|
||||
} else {
|
||||
logger.info(`비활성화된 배치 스케줄 제거: ${config.batch_name} (ID: ${configId})`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`배치 스케줄 업데이트 실패: ID ${configId}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 설정 실행
|
||||
*/
|
||||
private static async executeBatchConfig(config: any) {
|
||||
const startTime = new Date();
|
||||
let executionLog: any = null;
|
||||
|
||||
try {
|
||||
logger.info(`배치 실행 시작: ${config.batch_name} (ID: ${config.id})`);
|
||||
|
||||
// 실행 로그 생성
|
||||
const executionLogResponse = await BatchExecutionLogService.createExecutionLog({
|
||||
batch_config_id: config.id,
|
||||
execution_status: 'RUNNING',
|
||||
start_time: startTime,
|
||||
total_records: 0,
|
||||
success_records: 0,
|
||||
failed_records: 0
|
||||
});
|
||||
|
||||
if (!executionLogResponse.success || !executionLogResponse.data) {
|
||||
logger.error(`배치 실행 로그 생성 실패: ${config.batch_name}`, executionLogResponse.message);
|
||||
return;
|
||||
}
|
||||
|
||||
executionLog = executionLogResponse.data;
|
||||
|
||||
// 실제 배치 실행 로직 (수동 실행과 동일한 로직 사용)
|
||||
const result = await this.executeBatchMappings(config);
|
||||
|
||||
// 실행 로그 업데이트 (성공)
|
||||
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
||||
execution_status: 'SUCCESS',
|
||||
end_time: new Date(),
|
||||
duration_ms: Date.now() - startTime.getTime(),
|
||||
total_records: result.totalRecords,
|
||||
success_records: result.successRecords,
|
||||
failed_records: result.failedRecords
|
||||
});
|
||||
|
||||
logger.info(`배치 실행 완료: ${config.batch_name} (처리된 레코드: ${result.totalRecords})`);
|
||||
} catch (error) {
|
||||
logger.error(`배치 실행 실패: ${config.batch_name}`, error);
|
||||
|
||||
// 실행 로그 업데이트 (실패)
|
||||
if (executionLog) {
|
||||
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
||||
execution_status: 'FAILED',
|
||||
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)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 매핑 실행 (수동 실행과 동일한 로직)
|
||||
*/
|
||||
private static async executeBatchMappings(config: any) {
|
||||
let totalRecords = 0;
|
||||
let successRecords = 0;
|
||||
let failedRecords = 0;
|
||||
|
||||
if (!config.batch_mappings || config.batch_mappings.length === 0) {
|
||||
logger.warn(`배치 매핑이 없습니다: ${config.batch_name}`);
|
||||
return { totalRecords, successRecords, failedRecords };
|
||||
}
|
||||
|
||||
// 테이블별로 매핑을 그룹화
|
||||
const tableGroups = new Map<string, typeof config.batch_mappings>();
|
||||
|
||||
for (const mapping of config.batch_mappings) {
|
||||
const key = `${mapping.from_connection_type}:${mapping.from_connection_id || 'internal'}:${mapping.from_table_name}`;
|
||||
if (!tableGroups.has(key)) {
|
||||
tableGroups.set(key, []);
|
||||
}
|
||||
tableGroups.get(key)!.push(mapping);
|
||||
}
|
||||
|
||||
// 각 테이블 그룹별로 처리
|
||||
for (const [tableKey, mappings] of tableGroups) {
|
||||
try {
|
||||
const firstMapping = mappings[0];
|
||||
logger.info(`테이블 처리 시작: ${tableKey} -> ${mappings.length}개 컬럼 매핑`);
|
||||
|
||||
let fromData: any[] = [];
|
||||
|
||||
// FROM 데이터 조회 (DB 또는 REST API)
|
||||
if (firstMapping.from_connection_type === 'restapi') {
|
||||
// REST API에서 데이터 조회
|
||||
logger.info(`REST API에서 데이터 조회: ${firstMapping.from_api_url}${firstMapping.from_table_name}`);
|
||||
const { BatchExternalDbService } = await import('./batchExternalDbService');
|
||||
const apiResult = await BatchExternalDbService.getDataFromRestApi(
|
||||
firstMapping.from_api_url!,
|
||||
firstMapping.from_api_key!,
|
||||
firstMapping.from_table_name,
|
||||
firstMapping.from_api_method as 'GET' | 'POST' | 'PUT' | 'DELETE' || 'GET',
|
||||
mappings.map((m: any) => m.from_column_name)
|
||||
);
|
||||
|
||||
if (apiResult.success && apiResult.data) {
|
||||
fromData = apiResult.data;
|
||||
} else {
|
||||
throw new Error(`REST API 데이터 조회 실패: ${apiResult.message}`);
|
||||
}
|
||||
} else {
|
||||
// DB에서 데이터 조회
|
||||
const fromColumns = mappings.map((m: any) => m.from_column_name);
|
||||
fromData = await BatchService.getDataFromTableWithColumns(
|
||||
firstMapping.from_table_name,
|
||||
fromColumns,
|
||||
firstMapping.from_connection_type as 'internal' | 'external',
|
||||
firstMapping.from_connection_id || undefined
|
||||
);
|
||||
}
|
||||
|
||||
totalRecords += fromData.length;
|
||||
|
||||
// 컬럼 매핑 적용하여 TO 테이블 형식으로 변환
|
||||
const mappedData = fromData.map(row => {
|
||||
const mappedRow: any = {};
|
||||
for (const mapping of mappings) {
|
||||
// DB → REST API 배치인지 확인
|
||||
if (firstMapping.to_connection_type === 'restapi' && mapping.to_api_body) {
|
||||
// DB → REST API: 원본 컬럼명을 키로 사용 (템플릿 처리용)
|
||||
mappedRow[mapping.from_column_name] = row[mapping.from_column_name];
|
||||
} else {
|
||||
// 기존 로직: to_column_name을 키로 사용
|
||||
mappedRow[mapping.to_column_name] = row[mapping.from_column_name];
|
||||
}
|
||||
}
|
||||
return mappedRow;
|
||||
});
|
||||
|
||||
// TO 테이블에 데이터 삽입 (DB 또는 REST API)
|
||||
let insertResult: { successCount: number; failedCount: number };
|
||||
|
||||
if (firstMapping.to_connection_type === 'restapi') {
|
||||
// REST API로 데이터 전송
|
||||
logger.info(`REST API로 데이터 전송: ${firstMapping.to_api_url}${firstMapping.to_table_name}`);
|
||||
const { BatchExternalDbService } = await import('./batchExternalDbService');
|
||||
|
||||
// DB → REST API 배치인지 확인 (to_api_body가 있으면 템플릿 기반)
|
||||
const hasTemplate = mappings.some((m: any) => m.to_api_body);
|
||||
|
||||
if (hasTemplate) {
|
||||
// 템플릿 기반 REST API 전송 (DB → REST API 배치)
|
||||
const templateBody = firstMapping.to_api_body || '{}';
|
||||
logger.info(`템플릿 기반 REST API 전송, Request Body 템플릿: ${templateBody}`);
|
||||
|
||||
// URL 경로 컬럼 찾기 (PUT/DELETE용)
|
||||
const urlPathColumn = mappings.find((m: any) => m.to_column_name === 'URL_PATH_PARAM')?.from_column_name;
|
||||
|
||||
const apiResult = await BatchExternalDbService.sendDataToRestApiWithTemplate(
|
||||
firstMapping.to_api_url!,
|
||||
firstMapping.to_api_key!,
|
||||
firstMapping.to_table_name,
|
||||
firstMapping.to_api_method as 'POST' | 'PUT' | 'DELETE' || 'POST',
|
||||
templateBody,
|
||||
mappedData,
|
||||
urlPathColumn
|
||||
);
|
||||
|
||||
if (apiResult.success && apiResult.data) {
|
||||
insertResult = apiResult.data;
|
||||
} else {
|
||||
throw new Error(`템플릿 기반 REST API 데이터 전송 실패: ${apiResult.message}`);
|
||||
}
|
||||
} 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
|
||||
);
|
||||
|
||||
if (apiResult.success && apiResult.data) {
|
||||
insertResult = apiResult.data;
|
||||
} else {
|
||||
throw new Error(`REST API 데이터 전송 실패: ${apiResult.message}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// DB에 데이터 삽입
|
||||
insertResult = await BatchService.insertDataToTable(
|
||||
firstMapping.to_table_name,
|
||||
mappedData,
|
||||
firstMapping.to_connection_type as 'internal' | 'external',
|
||||
firstMapping.to_connection_id || undefined
|
||||
);
|
||||
}
|
||||
|
||||
successRecords += insertResult.successCount;
|
||||
failedRecords += insertResult.failedCount;
|
||||
|
||||
logger.info(`테이블 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패`);
|
||||
} catch (error) {
|
||||
logger.error(`테이블 처리 실패: ${tableKey}`, error);
|
||||
failedRecords += 1;
|
||||
}
|
||||
}
|
||||
|
||||
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 prisma.$queryRawUnsafe(
|
||||
`SELECT * FROM ${mapping.from_table_name}`
|
||||
);
|
||||
return result as any[];
|
||||
} 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);
|
||||
|
||||
await prisma.$executeRawUnsafe(
|
||||
`INSERT INTO ${mapping.to_table_name} (${Object.keys(mappedData).join(', ')}) VALUES (${Object.values(mappedData).map(() => '?').join(', ')})`,
|
||||
...Object.values(mappedData)
|
||||
);
|
||||
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
|
|
@ -1,6 +1,5 @@
|
|||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
// 🔧 Prisma 클라이언트 중복 생성 방지 - 기존 인스턴스 재사용
|
||||
import prisma = require("../config/database");
|
||||
|
||||
export interface ControlCondition {
|
||||
id: string;
|
||||
|
|
@ -33,6 +32,16 @@ export interface ControlAction {
|
|||
sourceField?: string;
|
||||
targetField?: string;
|
||||
};
|
||||
// 🆕 다중 커넥션 지원 추가
|
||||
fromConnection?: {
|
||||
id: number;
|
||||
name?: string;
|
||||
};
|
||||
toConnection?: {
|
||||
id: number;
|
||||
name?: string;
|
||||
};
|
||||
targetTable?: string;
|
||||
}
|
||||
|
||||
export interface ControlPlan {
|
||||
|
|
@ -84,13 +93,59 @@ export class DataflowControlService {
|
|||
};
|
||||
}
|
||||
|
||||
// 제어 규칙과 실행 계획 추출
|
||||
const controlRules = Array.isArray(diagram.control)
|
||||
? (diagram.control as unknown as ControlRule[])
|
||||
: [];
|
||||
const executionPlans = Array.isArray(diagram.plan)
|
||||
? (diagram.plan as unknown as ControlPlan[])
|
||||
: [];
|
||||
// 제어 규칙과 실행 계획 추출 (기존 구조 + redesigned UI 구조 지원)
|
||||
let controlRules: ControlRule[] = [];
|
||||
let executionPlans: ControlPlan[] = [];
|
||||
|
||||
// 🆕 redesigned UI 구조 처리
|
||||
if (diagram.relationships && typeof diagram.relationships === "object") {
|
||||
const relationships = diagram.relationships as any;
|
||||
|
||||
// Case 1: redesigned UI 단일 관계 구조
|
||||
if (relationships.controlConditions && relationships.fieldMappings) {
|
||||
console.log("🔄 Redesigned UI 구조 감지, 기존 구조로 변환 중");
|
||||
|
||||
// redesigned → 기존 구조 변환
|
||||
controlRules = [
|
||||
{
|
||||
id: relationshipId,
|
||||
triggerType: triggerType,
|
||||
conditions: relationships.controlConditions || [],
|
||||
},
|
||||
];
|
||||
|
||||
executionPlans = [
|
||||
{
|
||||
id: relationshipId,
|
||||
sourceTable: relationships.fromTable || tableName,
|
||||
actions: [
|
||||
{
|
||||
id: "action_1",
|
||||
name: "액션 1",
|
||||
actionType: relationships.actionType || "insert",
|
||||
conditions: relationships.actionConditions || [],
|
||||
fieldMappings: relationships.fieldMappings || [],
|
||||
fromConnection: relationships.fromConnection,
|
||||
toConnection: relationships.toConnection,
|
||||
targetTable: relationships.toTable,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
console.log("✅ Redesigned → 기존 구조 변환 완료");
|
||||
}
|
||||
}
|
||||
|
||||
// 기존 구조 처리 (하위 호환성)
|
||||
if (controlRules.length === 0) {
|
||||
controlRules = Array.isArray(diagram.control)
|
||||
? (diagram.control as unknown as ControlRule[])
|
||||
: [];
|
||||
executionPlans = Array.isArray(diagram.plan)
|
||||
? (diagram.plan as unknown as ControlPlan[])
|
||||
: [];
|
||||
}
|
||||
|
||||
console.log(`📋 제어 규칙:`, controlRules);
|
||||
console.log(`📋 실행 계획:`, executionPlans);
|
||||
|
|
@ -174,37 +229,29 @@ export class DataflowControlService {
|
|||
logicalOperator: action.logicalOperator,
|
||||
conditions: action.conditions,
|
||||
fieldMappings: action.fieldMappings,
|
||||
fromConnection: (action as any).fromConnection,
|
||||
toConnection: (action as any).toConnection,
|
||||
targetTable: (action as any).targetTable,
|
||||
});
|
||||
|
||||
// 액션 조건 검증 (있는 경우) - 동적 테이블 지원
|
||||
if (action.conditions && action.conditions.length > 0) {
|
||||
const actionConditionResult = await this.evaluateActionConditions(
|
||||
action,
|
||||
sourceData,
|
||||
tableName
|
||||
);
|
||||
// 🆕 다중 커넥션 지원 액션 실행
|
||||
const actionResult = await this.executeMultiConnectionAction(
|
||||
action,
|
||||
sourceData,
|
||||
targetPlan.sourceTable
|
||||
);
|
||||
|
||||
if (!actionConditionResult.satisfied) {
|
||||
console.log(
|
||||
`⚠️ 액션 조건 미충족: ${actionConditionResult.reason}`
|
||||
);
|
||||
previousActionSuccess = false;
|
||||
if (action.logicalOperator === "AND") {
|
||||
shouldSkipRemainingActions = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const actionResult = await this.executeAction(action, sourceData);
|
||||
executedActions.push({
|
||||
actionId: action.id,
|
||||
actionName: action.name,
|
||||
actionType: action.actionType,
|
||||
result: actionResult,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
previousActionSuccess = true;
|
||||
shouldSkipRemainingActions = false; // 성공했으므로 다시 실행 가능
|
||||
previousActionSuccess = actionResult?.success !== false;
|
||||
|
||||
// 액션 조건 검증은 이미 위에서 처리됨 (중복 제거)
|
||||
} catch (error) {
|
||||
console.error(`❌ 액션 실행 오류: ${action.name}`, error);
|
||||
const errorMessage =
|
||||
|
|
@ -235,6 +282,191 @@ export class DataflowControlService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 다중 커넥션 액션 실행
|
||||
*/
|
||||
private async executeMultiConnectionAction(
|
||||
action: ControlAction,
|
||||
sourceData: Record<string, any>,
|
||||
sourceTable: string
|
||||
): Promise<any> {
|
||||
try {
|
||||
const extendedAction = action as any; // redesigned UI 구조 접근
|
||||
|
||||
// 연결 정보 추출
|
||||
const fromConnection = extendedAction.fromConnection || { id: 0 };
|
||||
const toConnection = extendedAction.toConnection || { id: 0 };
|
||||
const targetTable = extendedAction.targetTable || sourceTable;
|
||||
|
||||
console.log(`🔗 다중 커넥션 액션 실행:`, {
|
||||
actionType: action.actionType,
|
||||
fromConnectionId: fromConnection.id,
|
||||
toConnectionId: toConnection.id,
|
||||
sourceTable,
|
||||
targetTable,
|
||||
});
|
||||
|
||||
// MultiConnectionQueryService import 필요
|
||||
const { MultiConnectionQueryService } = await import(
|
||||
"./multiConnectionQueryService"
|
||||
);
|
||||
const multiConnService = new MultiConnectionQueryService();
|
||||
|
||||
switch (action.actionType) {
|
||||
case "insert":
|
||||
return await this.executeMultiConnectionInsert(
|
||||
action,
|
||||
sourceData,
|
||||
sourceTable,
|
||||
targetTable,
|
||||
fromConnection.id,
|
||||
toConnection.id,
|
||||
multiConnService
|
||||
);
|
||||
|
||||
case "update":
|
||||
return await this.executeMultiConnectionUpdate(
|
||||
action,
|
||||
sourceData,
|
||||
sourceTable,
|
||||
targetTable,
|
||||
fromConnection.id,
|
||||
toConnection.id,
|
||||
multiConnService
|
||||
);
|
||||
|
||||
case "delete":
|
||||
return await this.executeMultiConnectionDelete(
|
||||
action,
|
||||
sourceData,
|
||||
sourceTable,
|
||||
targetTable,
|
||||
fromConnection.id,
|
||||
toConnection.id,
|
||||
multiConnService
|
||||
);
|
||||
|
||||
default:
|
||||
throw new Error(`지원되지 않는 액션 타입: ${action.actionType}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ 다중 커넥션 액션 실행 실패:`, error);
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 다중 커넥션 INSERT 실행
|
||||
*/
|
||||
private async executeMultiConnectionInsert(
|
||||
action: ControlAction,
|
||||
sourceData: Record<string, any>,
|
||||
sourceTable: string,
|
||||
targetTable: string,
|
||||
fromConnectionId: number,
|
||||
toConnectionId: number,
|
||||
multiConnService: any
|
||||
): Promise<any> {
|
||||
try {
|
||||
// 필드 매핑 적용
|
||||
const mappedData: Record<string, any> = {};
|
||||
|
||||
for (const mapping of action.fieldMappings) {
|
||||
const sourceField = mapping.sourceField;
|
||||
const targetField = mapping.targetField;
|
||||
|
||||
if (mapping.defaultValue !== undefined) {
|
||||
// 기본값 사용
|
||||
mappedData[targetField] = mapping.defaultValue;
|
||||
} else if (sourceField && sourceData[sourceField] !== undefined) {
|
||||
// 소스 데이터에서 매핑
|
||||
mappedData[targetField] = sourceData[sourceField];
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`📋 매핑된 데이터:`, mappedData);
|
||||
|
||||
// 대상 연결에 데이터 삽입
|
||||
const result = await multiConnService.insertDataToConnection(
|
||||
toConnectionId,
|
||||
targetTable,
|
||||
mappedData
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `${targetTable}에 데이터 삽입 완료`,
|
||||
insertedCount: 1,
|
||||
data: result,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`❌ INSERT 실행 실패:`, error);
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 다중 커넥션 UPDATE 실행
|
||||
*/
|
||||
private async executeMultiConnectionUpdate(
|
||||
action: ControlAction,
|
||||
sourceData: Record<string, any>,
|
||||
sourceTable: string,
|
||||
targetTable: string,
|
||||
fromConnectionId: number,
|
||||
toConnectionId: number,
|
||||
multiConnService: any
|
||||
): Promise<any> {
|
||||
try {
|
||||
// UPDATE 로직 구현 (향후 확장)
|
||||
console.log(`⚠️ UPDATE 액션은 향후 구현 예정`);
|
||||
return {
|
||||
success: true,
|
||||
message: "UPDATE 액션 실행됨 (향후 구현)",
|
||||
updatedCount: 0,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 다중 커넥션 DELETE 실행
|
||||
*/
|
||||
private async executeMultiConnectionDelete(
|
||||
action: ControlAction,
|
||||
sourceData: Record<string, any>,
|
||||
sourceTable: string,
|
||||
targetTable: string,
|
||||
fromConnectionId: number,
|
||||
toConnectionId: number,
|
||||
multiConnService: any
|
||||
): Promise<any> {
|
||||
try {
|
||||
// DELETE 로직 구현 (향후 확장)
|
||||
console.log(`⚠️ DELETE 액션은 향후 구현 예정`);
|
||||
return {
|
||||
success: true,
|
||||
message: "DELETE 액션 실행됨 (향후 구현)",
|
||||
deletedCount: 0,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 액션별 조건 평가 (동적 테이블 지원)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -12,21 +12,14 @@ import { MultiConnectionQueryService } from "./multiConnectionQueryService";
|
|||
import { logger } from "../utils/logger";
|
||||
|
||||
export interface EnhancedControlAction extends ControlAction {
|
||||
// 🆕 커넥션 정보 추가
|
||||
fromConnection?: {
|
||||
connectionId?: number;
|
||||
connectionName?: string;
|
||||
dbType?: string;
|
||||
};
|
||||
toConnection?: {
|
||||
connectionId?: number;
|
||||
connectionName?: string;
|
||||
dbType?: string;
|
||||
};
|
||||
// 🆕 기본 ControlAction 속성들 (상속됨)
|
||||
id?: number;
|
||||
actionType?: string;
|
||||
fromTable: string;
|
||||
|
||||
// 🆕 명시적 테이블 정보
|
||||
fromTable?: string;
|
||||
targetTable: string;
|
||||
// 🆕 추가 속성들
|
||||
conditions?: ControlCondition[];
|
||||
fieldMappings?: any[];
|
||||
|
||||
// 🆕 UPDATE 액션 관련 필드
|
||||
updateConditions?: UpdateCondition[];
|
||||
|
|
@ -172,13 +165,20 @@ export class EnhancedDataflowControlService extends DataflowControlService {
|
|||
const enhancedAction = action as EnhancedControlAction;
|
||||
let actionResult: any;
|
||||
|
||||
// 커넥션 ID 추출
|
||||
const sourceConnectionId = enhancedAction.fromConnection?.connectionId || enhancedAction.fromConnection?.id || 0;
|
||||
const targetConnectionId = enhancedAction.toConnection?.connectionId || enhancedAction.toConnection?.id || 0;
|
||||
|
||||
switch (enhancedAction.actionType) {
|
||||
case "insert":
|
||||
actionResult = await this.executeMultiConnectionInsert(
|
||||
enhancedAction,
|
||||
sourceData,
|
||||
enhancedAction.fromTable,
|
||||
enhancedAction.targetTable,
|
||||
sourceConnectionId,
|
||||
targetConnectionId
|
||||
targetConnectionId,
|
||||
null
|
||||
);
|
||||
break;
|
||||
|
||||
|
|
@ -186,8 +186,11 @@ export class EnhancedDataflowControlService extends DataflowControlService {
|
|||
actionResult = await this.executeMultiConnectionUpdate(
|
||||
enhancedAction,
|
||||
sourceData,
|
||||
enhancedAction.fromTable,
|
||||
enhancedAction.targetTable,
|
||||
sourceConnectionId,
|
||||
targetConnectionId
|
||||
targetConnectionId,
|
||||
null
|
||||
);
|
||||
break;
|
||||
|
||||
|
|
@ -195,8 +198,11 @@ export class EnhancedDataflowControlService extends DataflowControlService {
|
|||
actionResult = await this.executeMultiConnectionDelete(
|
||||
enhancedAction,
|
||||
sourceData,
|
||||
enhancedAction.fromTable,
|
||||
enhancedAction.targetTable,
|
||||
sourceConnectionId,
|
||||
targetConnectionId
|
||||
targetConnectionId,
|
||||
null
|
||||
);
|
||||
break;
|
||||
|
||||
|
|
@ -241,20 +247,21 @@ export class EnhancedDataflowControlService extends DataflowControlService {
|
|||
/**
|
||||
* 🆕 다중 커넥션 INSERT 실행
|
||||
*/
|
||||
private async executeMultiConnectionInsert(
|
||||
async executeMultiConnectionInsert(
|
||||
action: EnhancedControlAction,
|
||||
sourceData: Record<string, any>,
|
||||
sourceConnectionId?: number,
|
||||
targetConnectionId?: number
|
||||
sourceTable: string,
|
||||
targetTable: string,
|
||||
fromConnectionId: number,
|
||||
toConnectionId: number,
|
||||
multiConnService: any
|
||||
): Promise<any> {
|
||||
try {
|
||||
logger.info(`다중 커넥션 INSERT 실행: action=${action.id}`);
|
||||
logger.info(`다중 커넥션 INSERT 실행: action=${action.action}`);
|
||||
|
||||
// 커넥션 ID 결정
|
||||
const fromConnId =
|
||||
sourceConnectionId || action.fromConnection?.connectionId || 0;
|
||||
const toConnId =
|
||||
targetConnectionId || action.toConnection?.connectionId || 0;
|
||||
const fromConnId = fromConnectionId || action.fromConnection?.connectionId || 0;
|
||||
const toConnId = toConnectionId || action.toConnection?.connectionId || 0;
|
||||
|
||||
// FROM 테이블에서 소스 데이터 조회 (조건이 있는 경우)
|
||||
let fromData = sourceData;
|
||||
|
|
@ -287,7 +294,7 @@ export class EnhancedDataflowControlService extends DataflowControlService {
|
|||
|
||||
// 필드 매핑 적용
|
||||
const mappedData = this.applyFieldMappings(
|
||||
action.fieldMappings,
|
||||
action.fieldMappings || [],
|
||||
fromData
|
||||
);
|
||||
|
||||
|
|
@ -310,20 +317,21 @@ export class EnhancedDataflowControlService extends DataflowControlService {
|
|||
/**
|
||||
* 🆕 다중 커넥션 UPDATE 실행
|
||||
*/
|
||||
private async executeMultiConnectionUpdate(
|
||||
async executeMultiConnectionUpdate(
|
||||
action: EnhancedControlAction,
|
||||
sourceData: Record<string, any>,
|
||||
sourceConnectionId?: number,
|
||||
targetConnectionId?: number
|
||||
sourceTable: string,
|
||||
targetTable: string,
|
||||
fromConnectionId: number,
|
||||
toConnectionId: number,
|
||||
multiConnService: any
|
||||
): Promise<any> {
|
||||
try {
|
||||
logger.info(`다중 커넥션 UPDATE 실행: action=${action.id}`);
|
||||
logger.info(`다중 커넥션 UPDATE 실행: action=${action.action}`);
|
||||
|
||||
// 커넥션 ID 결정
|
||||
const fromConnId =
|
||||
sourceConnectionId || action.fromConnection?.connectionId || 0;
|
||||
const toConnId =
|
||||
targetConnectionId || action.toConnection?.connectionId || 0;
|
||||
const fromConnId = fromConnectionId || action.fromConnection?.connectionId || 0;
|
||||
const toConnId = toConnectionId || action.toConnection?.connectionId || 0;
|
||||
|
||||
// UPDATE 조건 확인
|
||||
if (!action.updateConditions || action.updateConditions.length === 0) {
|
||||
|
|
@ -382,20 +390,23 @@ export class EnhancedDataflowControlService extends DataflowControlService {
|
|||
/**
|
||||
* 🆕 다중 커넥션 DELETE 실행
|
||||
*/
|
||||
private async executeMultiConnectionDelete(
|
||||
async executeMultiConnectionDelete(
|
||||
action: EnhancedControlAction,
|
||||
sourceData: Record<string, any>,
|
||||
sourceConnectionId?: number,
|
||||
targetConnectionId?: number
|
||||
sourceTable: string,
|
||||
targetTable: string,
|
||||
fromConnectionId: number,
|
||||
toConnectionId: number,
|
||||
multiConnService: any
|
||||
): Promise<any> {
|
||||
try {
|
||||
logger.info(`다중 커넥션 DELETE 실행: action=${action.id}`);
|
||||
logger.info(`다중 커넥션 DELETE 실행: action=${action.action}`);
|
||||
|
||||
// 커넥션 ID 결정
|
||||
const fromConnId =
|
||||
sourceConnectionId || action.fromConnection?.connectionId || 0;
|
||||
fromConnectionId || action.fromConnection?.connectionId || 0;
|
||||
const toConnId =
|
||||
targetConnectionId || action.toConnection?.connectionId || 0;
|
||||
toConnectionId || action.toConnection?.connectionId || 0;
|
||||
|
||||
// DELETE 조건 확인
|
||||
if (!action.deleteConditions || action.deleteConditions.length === 0) {
|
||||
|
|
|
|||
|
|
@ -180,10 +180,57 @@ export class ExternalCallService {
|
|||
body = this.processTemplate(body, templateData);
|
||||
}
|
||||
|
||||
// 기본 헤더 준비
|
||||
const headers = { ...(settings.headers || {}) };
|
||||
|
||||
// 인증 정보 처리
|
||||
if (settings.authentication) {
|
||||
switch (settings.authentication.type) {
|
||||
case "api-key":
|
||||
if (settings.authentication.apiKey) {
|
||||
headers["X-API-Key"] = settings.authentication.apiKey;
|
||||
}
|
||||
break;
|
||||
case "basic":
|
||||
if (
|
||||
settings.authentication.username &&
|
||||
settings.authentication.password
|
||||
) {
|
||||
const credentials = Buffer.from(
|
||||
`${settings.authentication.username}:${settings.authentication.password}`
|
||||
).toString("base64");
|
||||
headers["Authorization"] = `Basic ${credentials}`;
|
||||
}
|
||||
break;
|
||||
case "bearer":
|
||||
if (settings.authentication.token) {
|
||||
headers["Authorization"] =
|
||||
`Bearer ${settings.authentication.token}`;
|
||||
}
|
||||
break;
|
||||
case "custom":
|
||||
if (
|
||||
settings.authentication.headerName &&
|
||||
settings.authentication.headerValue
|
||||
) {
|
||||
headers[settings.authentication.headerName] =
|
||||
settings.authentication.headerValue;
|
||||
}
|
||||
break;
|
||||
// 'none' 타입은 아무것도 하지 않음
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`🔐 [ExternalCallService] 인증 처리 완료:`, {
|
||||
authType: settings.authentication?.type || "none",
|
||||
hasAuthHeader: !!headers["Authorization"],
|
||||
headers: Object.keys(headers),
|
||||
});
|
||||
|
||||
return await this.makeHttpRequest({
|
||||
url: settings.url,
|
||||
method: settings.method,
|
||||
headers: settings.headers || {},
|
||||
headers: headers,
|
||||
body: body,
|
||||
timeout: settings.timeout || this.DEFAULT_TIMEOUT,
|
||||
});
|
||||
|
|
@ -213,17 +260,36 @@ export class ExternalCallService {
|
|||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), options.timeout);
|
||||
|
||||
const response = await fetch(options.url, {
|
||||
// GET, HEAD 메서드는 body를 가질 수 없음
|
||||
const method = options.method.toUpperCase();
|
||||
const requestOptions: RequestInit = {
|
||||
method: options.method,
|
||||
headers: options.headers,
|
||||
body: options.body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
};
|
||||
|
||||
// GET, HEAD 메서드가 아닌 경우에만 body 추가
|
||||
if (method !== "GET" && method !== "HEAD" && options.body) {
|
||||
requestOptions.body = options.body;
|
||||
}
|
||||
|
||||
const response = await fetch(options.url, requestOptions);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const responseText = await response.text();
|
||||
|
||||
// 디버깅을 위한 로그 추가
|
||||
console.log(`🔍 [ExternalCallService] HTTP 응답:`, {
|
||||
url: options.url,
|
||||
method: options.method,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
ok: response.ok,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
responseText: responseText.substring(0, 500), // 처음 500자만 로그
|
||||
});
|
||||
|
||||
return {
|
||||
success: response.ok,
|
||||
statusCode: response.status,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// 외부 DB 연결 서비스
|
||||
// 작성일: 2024-12-17
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import prisma from "../config/database";
|
||||
import {
|
||||
ExternalDbConnection,
|
||||
ExternalDbConnectionFilter,
|
||||
|
|
@ -11,8 +11,6 @@ import {
|
|||
import { PasswordEncryption } from "../utils/passwordEncryption";
|
||||
import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export class ExternalDbConnectionService {
|
||||
/**
|
||||
* 외부 DB 연결 목록 조회
|
||||
|
|
@ -90,23 +88,26 @@ export class ExternalDbConnectionService {
|
|||
try {
|
||||
// 기본 연결 목록 조회
|
||||
const connectionsResult = await this.getConnections(filter);
|
||||
|
||||
|
||||
if (!connectionsResult.success || !connectionsResult.data) {
|
||||
return {
|
||||
success: false,
|
||||
message: "연결 목록 조회에 실패했습니다.",
|
||||
message: "연결 목록 조회에 실패했습니다."
|
||||
};
|
||||
}
|
||||
|
||||
// DB 타입 카테고리 정보 조회
|
||||
const categories = await prisma.db_type_categories.findMany({
|
||||
where: { is_active: true },
|
||||
orderBy: [{ sort_order: "asc" }, { display_name: "asc" }],
|
||||
orderBy: [
|
||||
{ sort_order: 'asc' },
|
||||
{ display_name: 'asc' }
|
||||
]
|
||||
});
|
||||
|
||||
// DB 타입별로 그룹화
|
||||
const groupedConnections: Record<string, any> = {};
|
||||
|
||||
|
||||
// 카테고리 정보를 포함한 그룹 초기화
|
||||
categories.forEach((category: any) => {
|
||||
groupedConnections[category.type_code] = {
|
||||
|
|
@ -115,36 +116,36 @@ export class ExternalDbConnectionService {
|
|||
display_name: category.display_name,
|
||||
icon: category.icon,
|
||||
color: category.color,
|
||||
sort_order: category.sort_order,
|
||||
sort_order: category.sort_order
|
||||
},
|
||||
connections: [],
|
||||
connections: []
|
||||
};
|
||||
});
|
||||
|
||||
// 연결을 해당 타입 그룹에 배치
|
||||
connectionsResult.data.forEach((connection) => {
|
||||
connectionsResult.data.forEach(connection => {
|
||||
if (groupedConnections[connection.db_type]) {
|
||||
groupedConnections[connection.db_type].connections.push(connection);
|
||||
} else {
|
||||
// 카테고리에 없는 DB 타입인 경우 기타 그룹에 추가
|
||||
if (!groupedConnections["other"]) {
|
||||
groupedConnections["other"] = {
|
||||
if (!groupedConnections['other']) {
|
||||
groupedConnections['other'] = {
|
||||
category: {
|
||||
type_code: "other",
|
||||
display_name: "기타",
|
||||
icon: "database",
|
||||
color: "#6B7280",
|
||||
sort_order: 999,
|
||||
type_code: 'other',
|
||||
display_name: '기타',
|
||||
icon: 'database',
|
||||
color: '#6B7280',
|
||||
sort_order: 999
|
||||
},
|
||||
connections: [],
|
||||
connections: []
|
||||
};
|
||||
}
|
||||
groupedConnections["other"].connections.push(connection);
|
||||
groupedConnections['other'].connections.push(connection);
|
||||
}
|
||||
});
|
||||
|
||||
// 연결이 없는 빈 그룹 제거
|
||||
Object.keys(groupedConnections).forEach((key) => {
|
||||
Object.keys(groupedConnections).forEach(key => {
|
||||
if (groupedConnections[key].connections.length === 0) {
|
||||
delete groupedConnections[key];
|
||||
}
|
||||
|
|
@ -153,20 +154,20 @@ export class ExternalDbConnectionService {
|
|||
return {
|
||||
success: true,
|
||||
data: groupedConnections,
|
||||
message: `DB 타입별로 그룹화된 연결 목록을 조회했습니다.`,
|
||||
message: `DB 타입별로 그룹화된 연결 목록을 조회했습니다.`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("그룹화된 연결 목록 조회 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "그룹화된 연결 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 외부 DB 연결 조회
|
||||
* 특정 외부 DB 연결 조회 (비밀번호 마스킹)
|
||||
*/
|
||||
static async getConnectionById(
|
||||
id: number
|
||||
|
|
@ -205,6 +206,45 @@ export class ExternalDbConnectionService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔑 특정 외부 DB 연결 조회 (실제 비밀번호 포함 - 내부 서비스 전용)
|
||||
*/
|
||||
static async getConnectionByIdWithPassword(
|
||||
id: number
|
||||
): Promise<ApiResponse<ExternalDbConnection>> {
|
||||
try {
|
||||
const connection = await prisma.external_db_connections.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!connection) {
|
||||
return {
|
||||
success: false,
|
||||
message: "해당 연결 설정을 찾을 수 없습니다.",
|
||||
};
|
||||
}
|
||||
|
||||
// 🔑 실제 비밀번호 포함하여 반환 (내부 서비스 전용)
|
||||
const connectionWithPassword = {
|
||||
...connection,
|
||||
description: connection.description || undefined,
|
||||
} as ExternalDbConnection;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: connectionWithPassword,
|
||||
message: "연결 설정을 조회했습니다.",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("외부 DB 연결 조회 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "연결 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 외부 DB 연결 생성
|
||||
*/
|
||||
|
|
@ -547,10 +587,18 @@ export class ExternalDbConnectionService {
|
|||
`🔍 [연결테스트] 새 커넥터로 DB 연결 시도 - Host: ${config.host}, DB: ${config.database}, User: ${config.user}`
|
||||
);
|
||||
|
||||
const testResult = await connector.testConnection();
|
||||
console.log(
|
||||
`🔍 [연결테스트] 결과 - Success: ${testResult.success}, Message: ${testResult.message}`
|
||||
);
|
||||
let testResult;
|
||||
try {
|
||||
testResult = await connector.testConnection();
|
||||
console.log(
|
||||
`🔍 [연결테스트] 결과 - Success: ${testResult.success}, Message: ${testResult.message}`
|
||||
);
|
||||
} finally {
|
||||
// 🔧 연결 해제 추가 - 메모리 누수 방지
|
||||
if (connector && typeof connector.disconnect === "function") {
|
||||
await connector.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: testResult.success,
|
||||
|
|
@ -700,7 +748,14 @@ export class ExternalDbConnectionService {
|
|||
config,
|
||||
id
|
||||
);
|
||||
const result = await connector.executeQuery(query);
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await connector.executeQuery(query);
|
||||
} finally {
|
||||
// 🔧 연결 해제 추가 - 메모리 누수 방지
|
||||
await DatabaseConnectorFactory.closeConnector(id, connection.db_type);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
@ -823,7 +878,14 @@ export class ExternalDbConnectionService {
|
|||
config,
|
||||
id
|
||||
);
|
||||
const tables = await connector.getTables();
|
||||
|
||||
let tables;
|
||||
try {
|
||||
tables = await connector.getTables();
|
||||
} finally {
|
||||
// 🔧 연결 해제 추가 - 메모리 누수 방지
|
||||
await DatabaseConnectorFactory.closeConnector(id, connection.db_type);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
@ -914,26 +976,70 @@ export class ExternalDbConnectionService {
|
|||
let client: any = null;
|
||||
|
||||
try {
|
||||
const connection = await this.getConnectionById(connectionId);
|
||||
console.log(
|
||||
`🔍 컬럼 조회 시작: connectionId=${connectionId}, tableName=${tableName}`
|
||||
);
|
||||
|
||||
const connection = await this.getConnectionByIdWithPassword(connectionId);
|
||||
if (!connection.success || !connection.data) {
|
||||
console.log(`❌ 연결 정보 조회 실패: connectionId=${connectionId}`);
|
||||
return {
|
||||
success: false,
|
||||
message: "연결 정보를 찾을 수 없습니다.",
|
||||
};
|
||||
}
|
||||
|
||||
console.log(
|
||||
`✅ 연결 정보 조회 성공: ${connection.data.connection_name} (${connection.data.db_type})`
|
||||
);
|
||||
|
||||
const connectionData = connection.data;
|
||||
|
||||
// 비밀번호 복호화 (실패 시 일반적인 패스워드들 시도)
|
||||
let decryptedPassword: string;
|
||||
|
||||
// 🔍 암호화/복호화 상태 진단
|
||||
console.log(`🔍 암호화 상태 진단:`);
|
||||
console.log(
|
||||
`- 원본 비밀번호 형태: ${connectionData.password.substring(0, 20)}...`
|
||||
);
|
||||
console.log(`- 비밀번호 길이: ${connectionData.password.length}`);
|
||||
console.log(`- 콜론 포함 여부: ${connectionData.password.includes(":")}`);
|
||||
console.log(
|
||||
`- 암호화 키 설정됨: ${PasswordEncryption.isKeyConfigured()}`
|
||||
);
|
||||
|
||||
// 암호화/복호화 테스트
|
||||
const testResult = PasswordEncryption.testEncryption();
|
||||
console.log(
|
||||
`- 암호화 테스트 결과: ${testResult.success ? "성공" : "실패"} - ${testResult.message}`
|
||||
);
|
||||
|
||||
try {
|
||||
decryptedPassword = PasswordEncryption.decrypt(connectionData.password);
|
||||
console.log(`✅ 비밀번호 복호화 성공 (connectionId: ${connectionId})`);
|
||||
} catch (decryptError) {
|
||||
// ConnectionId=2의 경우 알려진 패스워드 사용 (로그 최소화)
|
||||
// ConnectionId별 알려진 패스워드 사용
|
||||
if (connectionId === 2) {
|
||||
decryptedPassword = "postgres"; // PostgreSQL 기본 패스워드
|
||||
console.log(`💡 ConnectionId=2: 기본 패스워드 사용`);
|
||||
} else if (connectionId === 9) {
|
||||
// PostgreSQL "테스트 db" 연결 - 다양한 패스워드 시도
|
||||
const testPasswords = [
|
||||
"qlalfqjsgh11",
|
||||
"postgres",
|
||||
"wace",
|
||||
"admin",
|
||||
"1234",
|
||||
];
|
||||
console.log(`💡 ConnectionId=9: 다양한 패스워드 시도 중...`);
|
||||
console.log(`🔍 복호화 에러 상세:`, decryptError);
|
||||
|
||||
// 첫 번째 시도할 패스워드
|
||||
decryptedPassword = testPasswords[0];
|
||||
console.log(
|
||||
`💡 ConnectionId=9: "${decryptedPassword}" 패스워드 사용`
|
||||
);
|
||||
} else {
|
||||
// 다른 연결들은 원본 패스워드 사용
|
||||
console.warn(
|
||||
|
|
@ -971,8 +1077,21 @@ export class ExternalDbConnectionService {
|
|||
connectionId
|
||||
);
|
||||
|
||||
// 컬럼 정보 조회
|
||||
const columns = await connector.getColumns(tableName);
|
||||
let columns;
|
||||
try {
|
||||
// 컬럼 정보 조회
|
||||
console.log(`📋 테이블 ${tableName} 컬럼 조회 중...`);
|
||||
columns = await connector.getColumns(tableName);
|
||||
console.log(
|
||||
`✅ 테이블 ${tableName} 컬럼 조회 완료: ${columns ? columns.length : 0}개`
|
||||
);
|
||||
} finally {
|
||||
// 🔧 연결 해제 추가 - 메모리 누수 방지
|
||||
await DatabaseConnectorFactory.closeConnector(
|
||||
connectionId,
|
||||
connectionData.db_type
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -6,11 +6,13 @@
|
|||
|
||||
import { ExternalDbConnectionService } from "./externalDbConnectionService";
|
||||
import { TableManagementService } from "./tableManagementService";
|
||||
import { ExternalDbConnection } from "../types/externalDbTypes";
|
||||
import { ExternalDbConnection, ApiResponse } from "../types/externalDbTypes";
|
||||
import { ColumnTypeInfo, TableInfo } from "../types/tableManagement";
|
||||
import prisma from "../config/database";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// 🔧 Prisma 클라이언트 중복 생성 방지 - 기존 인스턴스 재사용
|
||||
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
|
|
@ -424,6 +426,171 @@ export class MultiConnectionQueryService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 테이블 정보 조회 (컬럼 수 포함)
|
||||
*/
|
||||
async getBatchTablesWithColumns(
|
||||
connectionId: number
|
||||
): Promise<
|
||||
{ tableName: string; displayName?: string; columnCount: number }[]
|
||||
> {
|
||||
try {
|
||||
logger.info(`배치 테이블 정보 조회 시작: connectionId=${connectionId}`);
|
||||
|
||||
// connectionId가 0이면 메인 DB
|
||||
if (connectionId === 0) {
|
||||
console.log("🔍 메인 DB 배치 테이블 정보 조회");
|
||||
|
||||
// 메인 DB의 모든 테이블과 각 테이블의 컬럼 수 조회
|
||||
const tables = await this.tableManagementService.getTableList();
|
||||
|
||||
const result = await Promise.all(
|
||||
tables.map(async (table) => {
|
||||
try {
|
||||
const columnsResult =
|
||||
await this.tableManagementService.getColumnList(
|
||||
table.tableName,
|
||||
1,
|
||||
1000
|
||||
);
|
||||
|
||||
return {
|
||||
tableName: table.tableName,
|
||||
displayName: table.displayName,
|
||||
columnCount: columnsResult.columns.length,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`메인 DB 테이블 ${table.tableName} 컬럼 수 조회 실패:`,
|
||||
error
|
||||
);
|
||||
return {
|
||||
tableName: table.tableName,
|
||||
displayName: table.displayName,
|
||||
columnCount: 0,
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
logger.info(`✅ 메인 DB 배치 조회 완료: ${result.length}개 테이블`);
|
||||
return result;
|
||||
}
|
||||
|
||||
// 외부 DB 연결 정보 가져오기
|
||||
const connectionResult =
|
||||
await ExternalDbConnectionService.getConnectionById(connectionId);
|
||||
if (!connectionResult.success || !connectionResult.data) {
|
||||
throw new Error(`커넥션을 찾을 수 없습니다: ${connectionId}`);
|
||||
}
|
||||
const connection = connectionResult.data;
|
||||
|
||||
console.log(
|
||||
`🔍 외부 DB 배치 테이블 정보 조회: connectionId=${connectionId}`
|
||||
);
|
||||
|
||||
// 외부 DB의 테이블 목록 먼저 조회
|
||||
const tablesResult =
|
||||
await ExternalDbConnectionService.getTables(connectionId);
|
||||
|
||||
if (!tablesResult.success || !tablesResult.data) {
|
||||
throw new Error("외부 DB 테이블 목록 조회 실패");
|
||||
}
|
||||
|
||||
const tableNames = tablesResult.data;
|
||||
|
||||
// 🔧 각 테이블의 컬럼 수를 순차적으로 조회 (타임아웃 방지)
|
||||
const result = [];
|
||||
logger.info(
|
||||
`📊 외부 DB 테이블 컬럼 조회 시작: ${tableNames.length}개 테이블`
|
||||
);
|
||||
|
||||
for (let i = 0; i < tableNames.length; i++) {
|
||||
const tableInfo = tableNames[i];
|
||||
const tableName = tableInfo.table_name;
|
||||
|
||||
try {
|
||||
logger.info(
|
||||
`📋 테이블 ${i + 1}/${tableNames.length}: ${tableName} 컬럼 조회 중...`
|
||||
);
|
||||
|
||||
// 🔧 타임아웃과 재시도 로직 추가
|
||||
let columnsResult: ApiResponse<any[]> | undefined;
|
||||
let retryCount = 0;
|
||||
const maxRetries = 2;
|
||||
|
||||
while (retryCount <= maxRetries) {
|
||||
try {
|
||||
columnsResult = (await Promise.race([
|
||||
ExternalDbConnectionService.getTableColumns(
|
||||
connectionId,
|
||||
tableName
|
||||
),
|
||||
new Promise<ApiResponse<any[]>>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error("컬럼 조회 타임아웃 (15초)")),
|
||||
15000
|
||||
)
|
||||
),
|
||||
])) as ApiResponse<any[]>;
|
||||
break; // 성공하면 루프 종료
|
||||
} catch (attemptError) {
|
||||
retryCount++;
|
||||
if (retryCount > maxRetries) {
|
||||
throw attemptError; // 최대 재시도 후 에러 throw
|
||||
}
|
||||
logger.warn(
|
||||
`⚠️ 테이블 ${tableName} 컬럼 조회 실패 (${retryCount}/${maxRetries}), 재시도 중...`
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000)); // 1초 대기 후 재시도
|
||||
}
|
||||
}
|
||||
|
||||
const columnCount =
|
||||
columnsResult &&
|
||||
columnsResult.success &&
|
||||
Array.isArray(columnsResult.data)
|
||||
? columnsResult.data.length
|
||||
: 0;
|
||||
|
||||
result.push({
|
||||
tableName,
|
||||
displayName: tableName, // 외부 DB는 일반적으로 displayName이 없음
|
||||
columnCount,
|
||||
});
|
||||
|
||||
logger.info(`✅ 테이블 ${tableName}: ${columnCount}개 컬럼`);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
logger.warn(
|
||||
`❌ 외부 DB 테이블 ${tableName} 컬럼 수 조회 최종 실패: ${errorMessage}`
|
||||
);
|
||||
result.push({
|
||||
tableName,
|
||||
displayName: tableName,
|
||||
columnCount: 0, // 실패한 경우 0으로 설정
|
||||
});
|
||||
}
|
||||
|
||||
// 🔧 연결 부하 방지를 위한 약간의 지연
|
||||
if (i < tableNames.length - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100)); // 100ms 지연
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`✅ 외부 DB 배치 조회 완료: ${result.length}개 테이블`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`배치 테이블 정보 조회 실패: connectionId=${connectionId}, error=${
|
||||
error instanceof Error ? error.message : error
|
||||
}`
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 커넥션별 컬럼 정보 조회
|
||||
*/
|
||||
|
|
@ -450,18 +617,56 @@ export class MultiConnectionQueryService {
|
|||
`✅ 메인 DB 컬럼 조회 성공: ${columnsResult.columns.length}개`
|
||||
);
|
||||
|
||||
return columnsResult.columns.map((column) => ({
|
||||
// 디버깅: inputType이 'code'인 컬럼들 확인
|
||||
const codeColumns = columnsResult.columns.filter(
|
||||
(col) => col.inputType === "code"
|
||||
);
|
||||
console.log(
|
||||
"🔍 메인 DB 코드 타입 컬럼들:",
|
||||
codeColumns.map((col) => ({
|
||||
columnName: col.columnName,
|
||||
inputType: col.inputType,
|
||||
webType: col.webType,
|
||||
codeCategory: col.codeCategory,
|
||||
}))
|
||||
);
|
||||
|
||||
const mappedColumns = columnsResult.columns.map((column) => ({
|
||||
columnName: column.columnName,
|
||||
displayName: column.displayName || column.columnName, // 라벨이 있으면 라벨 사용, 없으면 컬럼명
|
||||
dataType: column.dataType,
|
||||
dbType: column.dataType, // dataType을 dbType으로 사용
|
||||
webType: column.webType || "text", // webType 사용, 기본값 text
|
||||
inputType: column.inputType || "direct", // column_labels의 input_type 추가
|
||||
codeCategory: column.codeCategory, // 코드 카테고리 정보 추가
|
||||
isNullable: column.isNullable === "Y",
|
||||
isPrimaryKey: column.isPrimaryKey || false,
|
||||
defaultValue: column.defaultValue,
|
||||
maxLength: column.maxLength,
|
||||
description: column.description,
|
||||
connectionId: 0, // 메인 DB 구분용
|
||||
}));
|
||||
|
||||
// 디버깅: 매핑된 컬럼 정보 확인
|
||||
console.log(
|
||||
"🔍 매핑된 컬럼 정보 샘플:",
|
||||
mappedColumns.slice(0, 3).map((col) => ({
|
||||
columnName: col.columnName,
|
||||
inputType: col.inputType,
|
||||
webType: col.webType,
|
||||
connectionId: col.connectionId,
|
||||
}))
|
||||
);
|
||||
|
||||
// status 컬럼 특별 확인
|
||||
const statusColumn = mappedColumns.find(
|
||||
(col) => col.columnName === "status"
|
||||
);
|
||||
if (statusColumn) {
|
||||
console.log("🔍 status 컬럼 상세 정보:", statusColumn);
|
||||
}
|
||||
|
||||
return mappedColumns;
|
||||
}
|
||||
|
||||
// 외부 DB 연결 정보 가져오기
|
||||
|
|
@ -534,6 +739,7 @@ export class MultiConnectionQueryService {
|
|||
dataType: dataType,
|
||||
dbType: dataType,
|
||||
webType: this.mapDataTypeToWebType(dataType),
|
||||
inputType: "direct", // 외부 DB는 항상 direct (코드 타입 없음)
|
||||
isNullable:
|
||||
column.nullable === "YES" || // MSSQL (MSSQLConnector alias)
|
||||
column.is_nullable === "YES" || // PostgreSQL
|
||||
|
|
@ -548,6 +754,7 @@ export class MultiConnectionQueryService {
|
|||
column.max_length || // MSSQL (MSSQLConnector alias)
|
||||
column.character_maximum_length || // PostgreSQL
|
||||
column.CHARACTER_MAXIMUM_LENGTH,
|
||||
connectionId: connectionId, // 외부 DB 구분용
|
||||
description: columnComment,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import { WebType } from "../types/unified-web-types";
|
|||
import { entityJoinService } from "./entityJoinService";
|
||||
import { referenceCacheService } from "./referenceCacheService";
|
||||
|
||||
// 🔧 Prisma 클라이언트 중복 생성 방지 - 기존 인스턴스 재사용
|
||||
|
||||
export class TableManagementService {
|
||||
constructor() {}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
// 배치 실행 로그 타입 정의
|
||||
// 작성일: 2024-12-24
|
||||
|
||||
export interface BatchExecutionLog {
|
||||
id?: number;
|
||||
batch_config_id: number;
|
||||
execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED';
|
||||
start_time: Date;
|
||||
end_time?: Date | null;
|
||||
duration_ms?: number | null;
|
||||
total_records?: number | null;
|
||||
success_records?: number | null;
|
||||
failed_records?: number | null;
|
||||
error_message?: string | null;
|
||||
error_details?: string | null;
|
||||
server_name?: string | null;
|
||||
process_id?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateBatchExecutionLogRequest {
|
||||
batch_config_id: number;
|
||||
execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED';
|
||||
start_time?: Date;
|
||||
end_time?: Date | null;
|
||||
duration_ms?: number | null;
|
||||
total_records?: number | null;
|
||||
success_records?: number | null;
|
||||
failed_records?: number | null;
|
||||
error_message?: string | null;
|
||||
error_details?: string | null;
|
||||
server_name?: string | null;
|
||||
process_id?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateBatchExecutionLogRequest {
|
||||
execution_status?: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED';
|
||||
end_time?: Date | null;
|
||||
duration_ms?: number | null;
|
||||
total_records?: number | null;
|
||||
success_records?: number | null;
|
||||
failed_records?: number | null;
|
||||
error_message?: string | null;
|
||||
error_details?: string | null;
|
||||
}
|
||||
|
||||
export interface BatchExecutionLogFilter {
|
||||
batch_config_id?: number;
|
||||
execution_status?: string;
|
||||
start_date?: Date;
|
||||
end_date?: Date;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface BatchExecutionLogWithConfig extends BatchExecutionLog {
|
||||
batch_config?: {
|
||||
id: number;
|
||||
batch_name: string;
|
||||
description?: string | null;
|
||||
cron_schedule: string;
|
||||
is_active?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
// 배치관리 타입 정의
|
||||
// 작성일: 2024-12-24
|
||||
|
||||
// 배치 타입 정의
|
||||
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 키
|
||||
|
||||
// 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 {
|
||||
type: 'internal' | 'external';
|
||||
id?: number;
|
||||
name: string;
|
||||
db_type?: string;
|
||||
}
|
||||
|
||||
export interface TableInfo {
|
||||
table_name: string;
|
||||
columns: ColumnInfo[];
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface ColumnInfo {
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable?: string;
|
||||
column_default?: string | null;
|
||||
}
|
||||
|
||||
export interface BatchMappingRequest {
|
||||
from_connection_type: 'internal' | 'external' | 'restapi';
|
||||
from_connection_id?: number;
|
||||
from_table_name: string;
|
||||
from_column_name: string;
|
||||
from_column_type?: string;
|
||||
from_api_url?: string;
|
||||
from_api_key?: string;
|
||||
from_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
to_connection_type: 'internal' | 'external' | 'restapi';
|
||||
to_connection_id?: number;
|
||||
to_table_name: string;
|
||||
to_column_name: string;
|
||||
to_column_type?: string;
|
||||
to_api_url?: string;
|
||||
to_api_key?: string;
|
||||
to_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
to_api_body?: string; // Request Body 템플릿 (DB → REST API 배치용)
|
||||
mapping_order?: number;
|
||||
}
|
||||
|
||||
export interface CreateBatchConfigRequest {
|
||||
batchName: string;
|
||||
description?: string;
|
||||
cronSchedule: string;
|
||||
mappings: BatchMappingRequest[];
|
||||
}
|
||||
|
||||
export interface UpdateBatchConfigRequest {
|
||||
batchName?: string;
|
||||
description?: string;
|
||||
cronSchedule?: string;
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
|
@ -53,14 +53,26 @@ export interface DiscordSettings extends ExternalCallConfig {
|
|||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
// 인증 설정 타입
|
||||
export interface AuthenticationSettings {
|
||||
type: "none" | "api-key" | "basic" | "bearer" | "custom";
|
||||
apiKey?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
token?: string;
|
||||
headerName?: string;
|
||||
headerValue?: string;
|
||||
}
|
||||
|
||||
// 일반 REST API 설정
|
||||
export interface GenericApiSettings extends ExternalCallConfig {
|
||||
callType: "rest-api";
|
||||
apiType: "generic";
|
||||
url: string;
|
||||
method: "GET" | "POST" | "PUT" | "DELETE";
|
||||
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD";
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
authentication?: AuthenticationSettings;
|
||||
}
|
||||
|
||||
// 이메일 설정
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
declare module 'oracledb' {
|
||||
export interface Connection {
|
||||
execute(sql: string, bindParams?: any, options?: any): Promise<any>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ConnectionConfig {
|
||||
user: string;
|
||||
password: string;
|
||||
connectString: string;
|
||||
}
|
||||
|
||||
export function getConnection(config: ConnectionConfig): Promise<Connection>;
|
||||
export function createPool(config: any): Promise<any>;
|
||||
export function getPool(): any;
|
||||
export function close(): Promise<void>;
|
||||
}
|
||||
|
||||
|
|
@ -33,6 +33,6 @@
|
|||
"@/validators/*": ["src/validators/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"include": ["src/**/*", "src/types/**/*.d.ts"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,5 +16,5 @@ COPY . .
|
|||
# 포트 노출
|
||||
EXPOSE 3000
|
||||
|
||||
# 개발 서버 시작
|
||||
CMD ["npm", "run", "dev"]
|
||||
# 개발 서버 시작 (Docker에서는 포트 3000 사용)
|
||||
CMD ["npm", "run", "dev", "--", "-p", "3000"]
|
||||
|
|
@ -0,0 +1,585 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>배치관리 매핑 시스템</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Malgun Gothic', Arial, sans-serif;
|
||||
margin: 20px;
|
||||
background-color: #f8f9fa;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.input-section {
|
||||
padding: 20px;
|
||||
background-color: #f8f9fa;
|
||||
border-bottom: 2px solid #e9ecef;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: bold;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.input-group input, .input-group textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.input-group textarea {
|
||||
height: 60px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.mapping-container {
|
||||
display: flex;
|
||||
padding: 20px;
|
||||
gap: 20px;
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.db-section {
|
||||
flex: 1;
|
||||
border: 2px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.db-header {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
padding: 15px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.from-section .db-header {
|
||||
background-color: #28a745;
|
||||
}
|
||||
|
||||
.to-section .db-header {
|
||||
background-color: #dc3545;
|
||||
}
|
||||
|
||||
.selection-area {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.select-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.select-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: bold;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.select-group select {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.columns-area {
|
||||
margin-top: 20px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.table-info {
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.table-name {
|
||||
font-weight: bold;
|
||||
color: #007bff;
|
||||
margin-bottom: 10px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.column-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.column-item {
|
||||
padding: 10px 15px;
|
||||
background-color: white;
|
||||
border: 2px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.column-item:hover {
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 2px 4px rgba(0,123,255,0.2);
|
||||
}
|
||||
|
||||
.column-item.selected {
|
||||
border-color: #007bff;
|
||||
background-color: #e3f2fd;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.column-item.mapped {
|
||||
border-color: #28a745;
|
||||
background-color: #d4edda;
|
||||
}
|
||||
|
||||
.column-type {
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.mapping-display {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background-color: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.mapping-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.mapping-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mapping-arrow {
|
||||
color: #007bff;
|
||||
font-weight: bold;
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
.remove-mapping {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.save-button {
|
||||
width: 100%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 15px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.save-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.instruction {
|
||||
background-color: #d1ecf1;
|
||||
border: 1px solid #bee5eb;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
margin-bottom: 15px;
|
||||
font-size: 14px;
|
||||
color: #0c5460;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-container">
|
||||
<div class="header">
|
||||
배치관리 매핑 시스템
|
||||
</div>
|
||||
|
||||
<div class="input-section">
|
||||
<div class="input-group">
|
||||
<label for="cronSchedule">실행주기 (크론탭 형식)</label>
|
||||
<input type="text" id="cronSchedule" placeholder="예: 0 12 * * * (매일 12시)" value="1 11 3 * *">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="description">비고</label>
|
||||
<textarea id="description" placeholder="하루한번 12시에 실행하는 인사정보 배치 등등...">하루한번 12시에 실행하는 인사정보 배치</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mapping-container">
|
||||
<div class="db-section from-section">
|
||||
<div class="db-header">FROM (원본 데이터베이스)</div>
|
||||
<div class="selection-area">
|
||||
<div class="instruction">
|
||||
1단계: 컨넥션을 선택하세요 → 2단계: 테이블을 선택하세요 → 3단계: 컬럼을 클릭해서 매핑하세요
|
||||
</div>
|
||||
|
||||
<div class="select-group">
|
||||
<label for="fromConnection">컨넥션 선택</label>
|
||||
<select id="fromConnection">
|
||||
<option value="">컨넥션을 선택하세요</option>
|
||||
<option value="oracle_db">Oracle_DB</option>
|
||||
<option value="mes_db">MES_DB</option>
|
||||
<option value="plm_db">PLM_DB</option>
|
||||
<option value="erp_db">ERP_DB</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="select-group">
|
||||
<label for="fromTable">테이블 선택</label>
|
||||
<select id="fromTable" disabled>
|
||||
<option value="">먼저 컨넥션을 선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="columns-area" id="fromColumns">
|
||||
<!-- 동적으로 컬럼들이 표시될 영역 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="db-section to-section">
|
||||
<div class="db-header">TO (대상 데이터베이스)</div>
|
||||
<div class="selection-area">
|
||||
<div class="instruction">
|
||||
FROM에서 컬럼을 선택한 후, 여기서 대상 컬럼을 클릭하면 매핑됩니다
|
||||
</div>
|
||||
|
||||
<div class="select-group">
|
||||
<label for="toConnection">컨넥션 선택</label>
|
||||
<select id="toConnection">
|
||||
<option value="">컨넥션을 선택하세요</option>
|
||||
<option value="oracle_db">Oracle_DB</option>
|
||||
<option value="mes_db">MES_DB</option>
|
||||
<option value="plm_db">PLM_DB</option>
|
||||
<option value="erp_db">ERP_DB</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="select-group">
|
||||
<label for="toTable">테이블 선택</label>
|
||||
<select id="toTable" disabled>
|
||||
<option value="">먼저 컨넥션을 선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="columns-area" id="toColumns">
|
||||
<!-- 동적으로 컬럼들이 표시될 영역 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mapping-display" id="mappingDisplay" style="margin: 20px; display: none;">
|
||||
<h4>컬럼 매핑 현황</h4>
|
||||
<div id="mappingList">
|
||||
<!-- 매핑된 컬럼들이 표시될 영역 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="save-button" onclick="saveMapping()">
|
||||
배치 매핑 저장
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 샘플 데이터 - 실제로는 서버에서 가져올 데이터
|
||||
const sampleData = {
|
||||
oracle_db: {
|
||||
employee: [
|
||||
{name: 'user_id', type: 'VARCHAR2(20)'},
|
||||
{name: 'user_name', type: 'VARCHAR2(100)'},
|
||||
{name: 'department', type: 'VARCHAR2(50)'},
|
||||
{name: 'email', type: 'VARCHAR2(200)'},
|
||||
{name: 'created_date', type: 'DATE'}
|
||||
],
|
||||
department: [
|
||||
{name: 'dept_id', type: 'VARCHAR2(10)'},
|
||||
{name: 'dept_name', type: 'VARCHAR2(100)'},
|
||||
{name: 'manager_id', type: 'VARCHAR2(20)'}
|
||||
]
|
||||
},
|
||||
mes_db: {
|
||||
user_info: [
|
||||
{name: 'user_id', type: 'VARCHAR(20)'},
|
||||
{name: 'user_name', type: 'VARCHAR(100)'},
|
||||
{name: 'position', type: 'VARCHAR(50)'},
|
||||
{name: 'phone', type: 'VARCHAR(20)'},
|
||||
{name: 'hire_date', type: 'DATETIME'}
|
||||
],
|
||||
project: [
|
||||
{name: 'project_id', type: 'VARCHAR(20)'},
|
||||
{name: 'project_name', type: 'VARCHAR(200)'},
|
||||
{name: 'start_date', type: 'DATETIME'},
|
||||
{name: 'end_date', type: 'DATETIME'}
|
||||
]
|
||||
},
|
||||
plm_db: {
|
||||
product: [
|
||||
{name: 'product_id', type: 'VARCHAR(30)'},
|
||||
{name: 'product_name', type: 'VARCHAR(200)'},
|
||||
{name: 'category', type: 'VARCHAR(50)'},
|
||||
{name: 'price', type: 'DECIMAL(10,2)'}
|
||||
]
|
||||
},
|
||||
erp_db: {
|
||||
customer: [
|
||||
{name: 'customer_id', type: 'VARCHAR(20)'},
|
||||
{name: 'customer_name', type: 'VARCHAR(200)'},
|
||||
{name: 'address', type: 'TEXT'},
|
||||
{name: 'contact', type: 'VARCHAR(100)'}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
let selectedFromColumn = null;
|
||||
let mappings = [];
|
||||
|
||||
// 컨넥션 선택 이벤트 처리
|
||||
document.getElementById('fromConnection').addEventListener('change', function() {
|
||||
loadTables('from', this.value);
|
||||
});
|
||||
|
||||
document.getElementById('toConnection').addEventListener('change', function() {
|
||||
loadTables('to', this.value);
|
||||
});
|
||||
|
||||
// 테이블 선택 이벤트 처리
|
||||
document.getElementById('fromTable').addEventListener('change', function() {
|
||||
loadColumns('from', document.getElementById('fromConnection').value, this.value);
|
||||
});
|
||||
|
||||
document.getElementById('toTable').addEventListener('change', function() {
|
||||
loadColumns('to', document.getElementById('toConnection').value, this.value);
|
||||
});
|
||||
|
||||
// 테이블 목록 로드
|
||||
function loadTables(side, connectionValue) {
|
||||
const tableSelect = document.getElementById(side + 'Table');
|
||||
tableSelect.innerHTML = '<option value="">테이블을 선택하세요</option>';
|
||||
tableSelect.disabled = false;
|
||||
|
||||
if (connectionValue && sampleData[connectionValue]) {
|
||||
Object.keys(sampleData[connectionValue]).forEach(tableName => {
|
||||
const option = document.createElement('option');
|
||||
option.value = tableName;
|
||||
option.textContent = tableName.toUpperCase();
|
||||
tableSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
// 컬럼 영역 초기화
|
||||
document.getElementById(side + 'Columns').innerHTML = '';
|
||||
}
|
||||
|
||||
// 컬럼 목록 로드
|
||||
function loadColumns(side, connectionValue, tableName) {
|
||||
const columnsArea = document.getElementById(side + 'Columns');
|
||||
|
||||
if (!connectionValue || !tableName || !sampleData[connectionValue] || !sampleData[connectionValue][tableName]) {
|
||||
columnsArea.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const columns = sampleData[connectionValue][tableName];
|
||||
|
||||
columnsArea.innerHTML = `
|
||||
<div class="table-info">
|
||||
<div class="table-name">${tableName.toUpperCase()} 테이블</div>
|
||||
<div class="column-list">
|
||||
${columns.map(col => `
|
||||
<div class="column-item" onclick="handleColumnClick('${side}', '${connectionValue}', '${tableName}', '${col.name}', '${col.type}')">
|
||||
<div>${col.name}</div>
|
||||
<div class="column-type">${col.type}</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 컬럼 클릭 처리
|
||||
function handleColumnClick(side, connection, table, columnName, columnType) {
|
||||
if (side === 'from') {
|
||||
// FROM 컬럼 선택
|
||||
document.querySelectorAll('#fromColumns .column-item').forEach(item => {
|
||||
item.classList.remove('selected');
|
||||
});
|
||||
|
||||
event.target.closest('.column-item').classList.add('selected');
|
||||
selectedFromColumn = {
|
||||
side: 'from',
|
||||
connection: connection,
|
||||
table: table,
|
||||
column: columnName,
|
||||
type: columnType
|
||||
};
|
||||
|
||||
} else if (side === 'to' && selectedFromColumn) {
|
||||
// TO 컬럼 선택하여 매핑 생성
|
||||
const mapping = {
|
||||
from: selectedFromColumn,
|
||||
to: {
|
||||
side: 'to',
|
||||
connection: connection,
|
||||
table: table,
|
||||
column: columnName,
|
||||
type: columnType
|
||||
}
|
||||
};
|
||||
|
||||
// 중복 매핑 체크
|
||||
const existingMapping = mappings.find(m =>
|
||||
m.from.column === mapping.from.column &&
|
||||
m.to.column === mapping.to.column
|
||||
);
|
||||
|
||||
if (!existingMapping) {
|
||||
mappings.push(mapping);
|
||||
updateMappingDisplay();
|
||||
updateColumnStyles();
|
||||
}
|
||||
|
||||
// FROM 선택 해제
|
||||
document.querySelectorAll('#fromColumns .column-item').forEach(item => {
|
||||
item.classList.remove('selected');
|
||||
});
|
||||
selectedFromColumn = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 매핑 표시 업데이트
|
||||
function updateMappingDisplay() {
|
||||
const mappingDisplay = document.getElementById('mappingDisplay');
|
||||
const mappingList = document.getElementById('mappingList');
|
||||
|
||||
if (mappings.length === 0) {
|
||||
mappingDisplay.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
mappingDisplay.style.display = 'block';
|
||||
mappingList.innerHTML = mappings.map((mapping, index) => `
|
||||
<div class="mapping-item">
|
||||
<span>${mapping.from.table}.${mapping.from.column} (${mapping.from.type})</span>
|
||||
<span class="mapping-arrow">→</span>
|
||||
<span>${mapping.to.table}.${mapping.to.column} (${mapping.to.type})</span>
|
||||
<button class="remove-mapping" onclick="removeMapping(${index})">삭제</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 컬럼 스타일 업데이트
|
||||
function updateColumnStyles() {
|
||||
// 모든 컬럼 아이템에서 mapped 클래스 제거
|
||||
document.querySelectorAll('.column-item').forEach(item => {
|
||||
item.classList.remove('mapped');
|
||||
});
|
||||
|
||||
// 매핑된 컬럼들에 스타일 적용
|
||||
mappings.forEach(mapping => {
|
||||
const fromColumns = document.querySelectorAll('#fromColumns .column-item');
|
||||
const toColumns = document.querySelectorAll('#toColumns .column-item');
|
||||
|
||||
fromColumns.forEach(item => {
|
||||
if (item.textContent.includes(mapping.from.column)) {
|
||||
item.classList.add('mapped');
|
||||
}
|
||||
});
|
||||
|
||||
toColumns.forEach(item => {
|
||||
if (item.textContent.includes(mapping.to.column)) {
|
||||
item.classList.add('mapped');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 매핑 삭제
|
||||
function removeMapping(index) {
|
||||
mappings.splice(index, 1);
|
||||
updateMappingDisplay();
|
||||
updateColumnStyles();
|
||||
}
|
||||
|
||||
// 매핑 저장
|
||||
function saveMapping() {
|
||||
const cronSchedule = document.getElementById('cronSchedule').value;
|
||||
const description = document.getElementById('description').value;
|
||||
|
||||
if (!cronSchedule) {
|
||||
alert('실행주기를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappings.length === 0) {
|
||||
alert('최소 하나 이상의 컬럼 매핑을 설정해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const batchConfig = {
|
||||
cronSchedule: cronSchedule,
|
||||
description: description,
|
||||
mappings: mappings,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
// 실제로는 서버로 전송
|
||||
console.log('저장될 배치 설정:', batchConfig);
|
||||
alert('배치 매핑이 성공적으로 저장되었습니다!\n\n' +
|
||||
`실행주기: ${cronSchedule}\n` +
|
||||
`매핑 개수: ${mappings.length}개\n` +
|
||||
`설명: ${description}`);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -34,13 +35,17 @@ import {
|
|||
Trash2,
|
||||
Play,
|
||||
RefreshCw,
|
||||
BarChart3
|
||||
BarChart3,
|
||||
ArrowRight,
|
||||
Database,
|
||||
Globe
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { BatchAPI, BatchJob } from "@/lib/api/batch";
|
||||
import BatchJobModal from "@/components/admin/BatchJobModal";
|
||||
|
||||
export default function BatchManagementPage() {
|
||||
const router = useRouter();
|
||||
const [jobs, setJobs] = useState<BatchJob[]>([]);
|
||||
const [filteredJobs, setFilteredJobs] = useState<BatchJob[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
|
@ -52,6 +57,7 @@ export default function BatchManagementPage() {
|
|||
// 모달 상태
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedJob, setSelectedJob] = useState<BatchJob | null>(null);
|
||||
const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadJobs();
|
||||
|
|
@ -109,8 +115,23 @@ export default function BatchManagementPage() {
|
|||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setSelectedJob(null);
|
||||
setIsModalOpen(true);
|
||||
setIsBatchTypeModalOpen(true);
|
||||
};
|
||||
|
||||
const handleBatchTypeSelect = (type: 'db-to-db' | 'restapi-to-db') => {
|
||||
console.log("배치 타입 선택:", type);
|
||||
setIsBatchTypeModalOpen(false);
|
||||
|
||||
if (type === 'db-to-db') {
|
||||
// 기존 배치 생성 모달 열기
|
||||
console.log("DB → DB 배치 모달 열기");
|
||||
setSelectedJob(null);
|
||||
setIsModalOpen(true);
|
||||
} else if (type === 'restapi-to-db') {
|
||||
// 새로운 REST API 배치 페이지로 이동
|
||||
console.log("REST API → DB 페이지로 이동:", '/admin/batch-management-new');
|
||||
router.push('/admin/batch-management-new');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (job: BatchJob) => {
|
||||
|
|
@ -185,12 +206,11 @@ export default function BatchManagementPage() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">배치 관리</h1>
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">배치 관리</h1>
|
||||
<p className="text-muted-foreground">
|
||||
스케줄된 배치 작업을 관리하고 실행 상태를 모니터링합니다.
|
||||
</p>
|
||||
|
|
@ -422,6 +442,61 @@ export default function BatchManagementPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 배치 타입 선택 모달 */}
|
||||
{isBatchTypeModalOpen && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<Card className="w-full max-w-2xl mx-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center">배치 타입 선택</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* DB → DB */}
|
||||
<div
|
||||
className="p-6 border rounded-lg cursor-pointer transition-all hover:border-blue-500 hover:bg-blue-50"
|
||||
onClick={() => handleBatchTypeSelect('db-to-db')}
|
||||
>
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<Database className="w-8 h-8 text-blue-600 mr-2" />
|
||||
<ArrowRight className="w-6 h-6 text-gray-400 mr-2" />
|
||||
<Database className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-medium text-lg mb-2">DB → DB</div>
|
||||
<div className="text-sm text-gray-500">데이터베이스 간 데이터 동기화</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* REST API → DB */}
|
||||
<div
|
||||
className="p-6 border rounded-lg cursor-pointer transition-all hover:border-green-500 hover:bg-green-50"
|
||||
onClick={() => handleBatchTypeSelect('restapi-to-db')}
|
||||
>
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<Globe className="w-8 h-8 text-green-600 mr-2" />
|
||||
<ArrowRight className="w-6 h-6 text-gray-400 mr-2" />
|
||||
<Database className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-medium text-lg mb-2">REST API → DB</div>
|
||||
<div className="text-sm text-gray-500">REST API에서 데이터베이스로 데이터 수집</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsBatchTypeModalOpen(false)}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 배치 작업 모달 */}
|
||||
<BatchJobModal
|
||||
isOpen={isModalOpen}
|
||||
|
|
@ -429,7 +504,6 @@ export default function BatchManagementPage() {
|
|||
onSave={handleModalSave}
|
||||
job={selectedJob}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,544 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
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 { Badge } from "@/components/ui/badge";
|
||||
import { ArrowLeft, Save, RefreshCw, ArrowRight, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
BatchAPI,
|
||||
BatchMapping,
|
||||
ConnectionInfo,
|
||||
ColumnInfo,
|
||||
BatchMappingRequest,
|
||||
} from "@/lib/api/batch";
|
||||
|
||||
export default function BatchCreatePage() {
|
||||
const router = useRouter();
|
||||
|
||||
// 기본 정보
|
||||
const [batchName, setBatchName] = useState("");
|
||||
const [cronSchedule, setCronSchedule] = useState("0 12 * * *");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
// 커넥션 및 데이터
|
||||
const [connections, setConnections] = useState<ConnectionInfo[]>([]);
|
||||
const [fromConnection, setFromConnection] = useState<ConnectionInfo | null>(null);
|
||||
const [toConnection, setToConnection] = useState<ConnectionInfo | null>(null);
|
||||
const [fromTables, setFromTables] = useState<string[]>([]);
|
||||
const [toTables, setToTables] = useState<string[]>([]);
|
||||
const [fromTable, setFromTable] = useState("");
|
||||
const [toTable, setToTable] = useState("");
|
||||
const [fromColumns, setFromColumns] = useState<ColumnInfo[]>([]);
|
||||
const [toColumns, setToColumns] = useState<ColumnInfo[]>([]);
|
||||
|
||||
// 매핑 상태
|
||||
const [selectedFromColumn, setSelectedFromColumn] = useState<ColumnInfo | null>(null);
|
||||
const [mappings, setMappings] = useState<BatchMapping[]>([]);
|
||||
|
||||
// 로딩 상태
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingConnections, setLoadingConnections] = useState(false);
|
||||
|
||||
// 커넥션 목록 로드
|
||||
useEffect(() => {
|
||||
loadConnections();
|
||||
}, []);
|
||||
|
||||
const loadConnections = async () => {
|
||||
setLoadingConnections(true);
|
||||
try {
|
||||
const data = await BatchAPI.getConnections();
|
||||
setConnections(Array.isArray(data) ? data : []);
|
||||
} catch (error) {
|
||||
console.error("커넥션 로드 실패:", error);
|
||||
toast.error("커넥션 목록을 불러오는데 실패했습니다.");
|
||||
setConnections([]);
|
||||
} finally {
|
||||
setLoadingConnections(false);
|
||||
}
|
||||
};
|
||||
|
||||
// FROM 커넥션 변경
|
||||
const handleFromConnectionChange = async (connectionId: string) => {
|
||||
if (connectionId === 'unknown') return;
|
||||
|
||||
const connection = connections.find(conn => {
|
||||
if (conn.type === 'internal') {
|
||||
return connectionId === 'internal';
|
||||
}
|
||||
return conn.id ? conn.id.toString() === connectionId : false;
|
||||
});
|
||||
|
||||
if (!connection) return;
|
||||
|
||||
setFromConnection(connection);
|
||||
setFromTable("");
|
||||
setFromTables([]);
|
||||
setFromColumns([]);
|
||||
setSelectedFromColumn(null);
|
||||
|
||||
try {
|
||||
const tables = await BatchAPI.getTablesFromConnection(connection);
|
||||
setFromTables(Array.isArray(tables) ? tables : []);
|
||||
} catch (error) {
|
||||
console.error("FROM 테이블 목록 로드 실패:", error);
|
||||
toast.error("테이블 목록을 불러오는데 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// TO 커넥션 변경
|
||||
const handleToConnectionChange = async (connectionId: string) => {
|
||||
if (connectionId === 'unknown') return;
|
||||
|
||||
const connection = connections.find(conn => {
|
||||
if (conn.type === 'internal') {
|
||||
return connectionId === 'internal';
|
||||
}
|
||||
return conn.id ? conn.id.toString() === connectionId : false;
|
||||
});
|
||||
|
||||
if (!connection) return;
|
||||
|
||||
setToConnection(connection);
|
||||
setToTable("");
|
||||
setToTables([]);
|
||||
setToColumns([]);
|
||||
|
||||
try {
|
||||
const tables = await BatchAPI.getTablesFromConnection(connection);
|
||||
setToTables(Array.isArray(tables) ? tables : []);
|
||||
} catch (error) {
|
||||
console.error("TO 테이블 목록 로드 실패:", error);
|
||||
toast.error("테이블 목록을 불러오는데 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// FROM 테이블 변경
|
||||
const handleFromTableChange = async (tableName: string) => {
|
||||
setFromTable(tableName);
|
||||
setFromColumns([]);
|
||||
setSelectedFromColumn(null);
|
||||
|
||||
if (!fromConnection || !tableName) return;
|
||||
|
||||
try {
|
||||
const columns = await BatchAPI.getTableColumns(fromConnection, tableName);
|
||||
setFromColumns(Array.isArray(columns) ? columns : []);
|
||||
} catch (error) {
|
||||
console.error("FROM 컬럼 목록 로드 실패:", error);
|
||||
toast.error("컬럼 목록을 불러오는데 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// TO 테이블 변경
|
||||
const handleToTableChange = async (tableName: string) => {
|
||||
setToTable(tableName);
|
||||
setToColumns([]);
|
||||
|
||||
if (!toConnection || !tableName) return;
|
||||
|
||||
try {
|
||||
const columns = await BatchAPI.getTableColumns(toConnection, tableName);
|
||||
setToColumns(Array.isArray(columns) ? columns : []);
|
||||
} catch (error) {
|
||||
console.error("TO 컬럼 목록 로드 실패:", error);
|
||||
toast.error("컬럼 목록을 불러오는데 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// FROM 컬럼 선택
|
||||
const handleFromColumnClick = (column: ColumnInfo) => {
|
||||
setSelectedFromColumn(column);
|
||||
toast.info(`FROM 컬럼 선택됨: ${column.column_name}`);
|
||||
};
|
||||
|
||||
// TO 컬럼 선택 (매핑 생성)
|
||||
const handleToColumnClick = (toColumn: ColumnInfo) => {
|
||||
if (!selectedFromColumn || !fromConnection || !toConnection) {
|
||||
toast.error("먼저 FROM 컬럼을 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// n:1 매핑 검사
|
||||
const toKey = `${toConnection.type}:${toConnection.id || 'internal'}:${toTable}:${toColumn.column_name}`;
|
||||
const existingMapping = mappings.find(mapping => {
|
||||
const existingToKey = `${mapping.to_connection_type}:${mapping.to_connection_id || 'internal'}:${mapping.to_table_name}:${mapping.to_column_name}`;
|
||||
return existingToKey === toKey;
|
||||
});
|
||||
|
||||
if (existingMapping) {
|
||||
toast.error("동일한 TO 컬럼에 중복 매핑할 수 없습니다. (n:1 매핑 방지)");
|
||||
return;
|
||||
}
|
||||
|
||||
const newMapping: BatchMapping = {
|
||||
from_connection_type: fromConnection.type,
|
||||
from_connection_id: fromConnection.id || null,
|
||||
from_table_name: fromTable,
|
||||
from_column_name: selectedFromColumn.column_name,
|
||||
from_column_type: selectedFromColumn.data_type || '',
|
||||
to_connection_type: toConnection.type,
|
||||
to_connection_id: toConnection.id || null,
|
||||
to_table_name: toTable,
|
||||
to_column_name: toColumn.column_name,
|
||||
to_column_type: toColumn.data_type || '',
|
||||
mapping_order: mappings.length + 1,
|
||||
};
|
||||
|
||||
setMappings([...mappings, newMapping]);
|
||||
setSelectedFromColumn(null);
|
||||
toast.success(`매핑 생성: ${selectedFromColumn.column_name} → ${toColumn.column_name}`);
|
||||
};
|
||||
|
||||
// 매핑 삭제
|
||||
const removeMapping = (index: number) => {
|
||||
const newMappings = mappings.filter((_, i) => i !== index);
|
||||
const reorderedMappings = newMappings.map((mapping, i) => ({
|
||||
...mapping,
|
||||
mapping_order: i + 1
|
||||
}));
|
||||
setMappings(reorderedMappings);
|
||||
toast.success("매핑이 삭제되었습니다.");
|
||||
};
|
||||
|
||||
// 배치 설정 저장
|
||||
const saveBatchConfig = async () => {
|
||||
if (!batchName.trim()) {
|
||||
toast.error("배치명을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cronSchedule.trim()) {
|
||||
toast.error("실행 스케줄을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappings.length === 0) {
|
||||
toast.error("최소 하나 이상의 매핑을 추가해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const request = {
|
||||
batchName: batchName,
|
||||
description: description || undefined,
|
||||
cronSchedule: cronSchedule,
|
||||
mappings: mappings,
|
||||
isActive: true
|
||||
};
|
||||
|
||||
await BatchAPI.createBatchConfig(request);
|
||||
toast.success("배치 설정이 성공적으로 저장되었습니다!");
|
||||
|
||||
// 목록 페이지로 이동
|
||||
router.push("/admin/batchmng");
|
||||
} catch (error) {
|
||||
console.error("배치 설정 저장 실패:", error);
|
||||
toast.error("배치 설정 저장에 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push("/admin/batchmng")}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span>목록으로</span>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">배치관리 매핑 시스템</h1>
|
||||
<p className="text-muted-foreground">새로운 배치 매핑을 생성합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="batchName">배치명 *</Label>
|
||||
<Input
|
||||
id="batchName"
|
||||
value={batchName}
|
||||
onChange={(e) => setBatchName(e.target.value)}
|
||||
placeholder="배치명을 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cronSchedule">실행 스케줄 (Cron) *</Label>
|
||||
<Input
|
||||
id="cronSchedule"
|
||||
value={cronSchedule}
|
||||
onChange={(e) => setCronSchedule(e.target.value)}
|
||||
placeholder="0 12 * * * (매일 12시)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="배치에 대한 설명을 입력하세요"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 매핑 설정 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* FROM 섹션 */}
|
||||
<Card className="border-green-200">
|
||||
<CardHeader className="bg-green-50">
|
||||
<CardTitle className="text-green-700">FROM (원본 데이터베이스)</CardTitle>
|
||||
<p className="text-sm text-green-600">
|
||||
1단계: 커넥션을 선택하세요 → 2단계: 테이블을 선택하세요 → 3단계: 컬럼을 클릭해서 매핑하세요
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>커넥션 선택</Label>
|
||||
<Select
|
||||
value={fromConnection?.type === 'internal' ? 'internal' : fromConnection?.id?.toString() || ""}
|
||||
onValueChange={handleFromConnectionChange}
|
||||
disabled={loadingConnections}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={loadingConnections ? "로딩 중..." : "커넥션을 선택하세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.isArray(connections) && connections.map((conn) => (
|
||||
<SelectItem
|
||||
key={conn.type === 'internal' ? 'internal' : conn.id?.toString() || conn.name}
|
||||
value={conn.type === 'internal' ? 'internal' : conn.id?.toString() || 'unknown'}
|
||||
>
|
||||
{conn.name} ({conn.type})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>테이블 선택</Label>
|
||||
<Select
|
||||
value={fromTable}
|
||||
onValueChange={handleFromTableChange}
|
||||
disabled={!fromConnection}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="테이블을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fromTables.map((table) => (
|
||||
<SelectItem key={table} value={table}>
|
||||
{table}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* FROM 컬럼 목록 */}
|
||||
{fromTable && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-blue-600 font-semibold">{fromTable} 테이블</Label>
|
||||
<div className="border rounded-lg p-4 max-h-80 overflow-y-auto space-y-2">
|
||||
{fromColumns.map((column) => (
|
||||
<div
|
||||
key={column.column_name}
|
||||
onClick={() => handleFromColumnClick(column)}
|
||||
className={`p-3 border rounded cursor-pointer transition-colors ${
|
||||
selectedFromColumn?.column_name === column.column_name
|
||||
? 'bg-green-100 border-green-300'
|
||||
: 'hover:bg-gray-50 border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{column.column_name}</div>
|
||||
<div className="text-sm text-gray-500">{column.data_type}</div>
|
||||
</div>
|
||||
))}
|
||||
{fromColumns.length === 0 && fromTable && (
|
||||
<div className="text-center text-gray-500 py-4">
|
||||
컬럼을 불러오는 중...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* TO 섹션 */}
|
||||
<Card className="border-red-200">
|
||||
<CardHeader className="bg-red-50">
|
||||
<CardTitle className="text-red-700">TO (대상 데이터베이스)</CardTitle>
|
||||
<p className="text-sm text-red-600">
|
||||
FROM에서 컬럼을 선택한 후, 여기서 대상 컬럼을 클릭하면 매핑됩니다
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>커넥션 선택</Label>
|
||||
<Select
|
||||
value={toConnection?.type === 'internal' ? 'internal' : toConnection?.id?.toString() || ""}
|
||||
onValueChange={handleToConnectionChange}
|
||||
disabled={loadingConnections}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={loadingConnections ? "로딩 중..." : "커넥션을 선택하세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.isArray(connections) && connections.map((conn) => (
|
||||
<SelectItem
|
||||
key={conn.type === 'internal' ? 'internal' : conn.id?.toString() || conn.name}
|
||||
value={conn.type === 'internal' ? 'internal' : conn.id?.toString() || 'unknown'}
|
||||
>
|
||||
{conn.name} ({conn.type})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>테이블 선택</Label>
|
||||
<Select
|
||||
value={toTable}
|
||||
onValueChange={handleToTableChange}
|
||||
disabled={!toConnection}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="테이블을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{toTables.map((table) => (
|
||||
<SelectItem key={table} value={table}>
|
||||
{table}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* TO 컬럼 목록 */}
|
||||
{toTable && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-blue-600 font-semibold">{toTable} 테이블</Label>
|
||||
<div className="border rounded-lg p-4 max-h-80 overflow-y-auto space-y-2">
|
||||
{toColumns.map((column) => (
|
||||
<div
|
||||
key={column.column_name}
|
||||
onClick={() => handleToColumnClick(column)}
|
||||
className={`p-3 border rounded cursor-pointer transition-colors ${
|
||||
selectedFromColumn
|
||||
? 'hover:bg-red-50 border-gray-200'
|
||||
: 'bg-gray-100 border-gray-300 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{column.column_name}</div>
|
||||
<div className="text-sm text-gray-500">{column.data_type}</div>
|
||||
</div>
|
||||
))}
|
||||
{toColumns.length === 0 && toTable && (
|
||||
<div className="text-center text-gray-500 py-4">
|
||||
컬럼을 불러오는 중...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 매핑 현황 */}
|
||||
{mappings.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>컬럼 매핑 현황 ({mappings.length}개)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{mappings.map((mapping, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-4 border rounded-lg bg-yellow-50">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">
|
||||
{mapping.from_table_name}.{mapping.from_column_name}
|
||||
</div>
|
||||
<div className="text-gray-500">
|
||||
{mapping.from_column_type}
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-gray-400" />
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">
|
||||
{mapping.to_table_name}.{mapping.to_column_name}
|
||||
</div>
|
||||
<div className="text-gray-500">
|
||||
{mapping.to_column_type}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeMapping(index)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 저장 버튼 */}
|
||||
<div className="flex justify-end space-x-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push("/admin/batchmng")}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={saveBatchConfig}
|
||||
disabled={loading || mappings.length === 0}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
{loading ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
<span>{loading ? "저장 중..." : "배치 매핑 저장"}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,833 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
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 { 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";
|
||||
|
||||
interface BatchColumnInfo {
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable: string;
|
||||
}
|
||||
|
||||
// 배치 타입 감지 함수
|
||||
const detectBatchType = (mapping: BatchMapping): 'db-to-db' | 'restapi-to-db' | 'db-to-restapi' => {
|
||||
const fromType = mapping.from_connection_type;
|
||||
const toType = mapping.to_connection_type;
|
||||
|
||||
if (fromType === 'restapi' && (toType === 'internal' || toType === 'external')) {
|
||||
return 'restapi-to-db';
|
||||
} else if ((fromType === 'internal' || fromType === 'external') && toType === 'restapi') {
|
||||
return 'db-to-restapi';
|
||||
} else {
|
||||
return 'db-to-db';
|
||||
}
|
||||
};
|
||||
|
||||
export default function BatchEditPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const batchId = parseInt(params.id as string);
|
||||
|
||||
// 기본 상태
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [batchConfig, setBatchConfig] = useState<BatchConfig | null>(null);
|
||||
const [batchName, setBatchName] = useState("");
|
||||
const [cronSchedule, setCronSchedule] = useState("0 12 * * *");
|
||||
const [description, setDescription] = useState("");
|
||||
const [isActive, setIsActive] = useState("Y");
|
||||
|
||||
// 연결 정보
|
||||
const [connections, setConnections] = useState<ConnectionInfo[]>([]);
|
||||
const [fromConnection, setFromConnection] = useState<ConnectionInfo | null>(null);
|
||||
const [toConnection, setToConnection] = useState<ConnectionInfo | null>(null);
|
||||
|
||||
// 테이블 및 컬럼 정보
|
||||
const [fromTables, setFromTables] = useState<string[]>([]);
|
||||
const [toTables, setToTables] = useState<string[]>([]);
|
||||
const [fromTable, setFromTable] = useState("");
|
||||
const [toTable, setToTable] = useState("");
|
||||
const [fromColumns, setFromColumns] = useState<BatchColumnInfo[]>([]);
|
||||
const [toColumns, setToColumns] = useState<BatchColumnInfo[]>([]);
|
||||
|
||||
// 매핑 정보
|
||||
const [mappings, setMappings] = useState<BatchMapping[]>([]);
|
||||
|
||||
// 배치 타입 감지
|
||||
const [batchType, setBatchType] = useState<'db-to-db' | 'restapi-to-db' | 'db-to-restapi' | null>(null);
|
||||
|
||||
// 페이지 로드 시 배치 정보 조회
|
||||
useEffect(() => {
|
||||
if (batchId) {
|
||||
loadBatchConfig();
|
||||
loadConnections();
|
||||
}
|
||||
}, [batchId]);
|
||||
|
||||
// 연결 정보가 로드된 후 배치 설정의 연결 정보 설정
|
||||
useEffect(() => {
|
||||
if (batchConfig && connections.length > 0 && batchConfig.batch_mappings && batchConfig.batch_mappings.length > 0) {
|
||||
const firstMapping = batchConfig.batch_mappings[0];
|
||||
console.log("🔗 연결 정보 설정 시작:", firstMapping);
|
||||
|
||||
// FROM 연결 정보 설정
|
||||
if (firstMapping.from_connection_type === 'internal') {
|
||||
setFromConnection({ type: 'internal', name: '내부 DB' });
|
||||
// 내부 DB 테이블 목록 로드
|
||||
BatchAPI.getTablesFromConnection({ type: 'internal', name: '내부 DB' }).then(tables => {
|
||||
console.log("📋 FROM 테이블 목록:", tables);
|
||||
setFromTables(tables);
|
||||
|
||||
// 컬럼 정보도 로드
|
||||
if (firstMapping.from_table_name) {
|
||||
BatchAPI.getTableColumns({ type: 'internal', name: '내부 DB' }, firstMapping.from_table_name).then(columns => {
|
||||
console.log("📊 FROM 컬럼 목록:", columns);
|
||||
setFromColumns(columns);
|
||||
});
|
||||
}
|
||||
});
|
||||
} else if (firstMapping.from_connection_id) {
|
||||
const fromConn = connections.find(c => c.id === firstMapping.from_connection_id);
|
||||
if (fromConn) {
|
||||
setFromConnection(fromConn);
|
||||
// 외부 DB 테이블 목록 로드
|
||||
BatchAPI.getTablesFromConnection(fromConn).then(tables => {
|
||||
console.log("📋 FROM 테이블 목록:", tables);
|
||||
setFromTables(tables);
|
||||
|
||||
// 컬럼 정보도 로드
|
||||
if (firstMapping.from_table_name) {
|
||||
BatchAPI.getTableColumns(fromConn, firstMapping.from_table_name).then(columns => {
|
||||
console.log("📊 FROM 컬럼 목록:", columns);
|
||||
setFromColumns(columns);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// TO 연결 정보 설정
|
||||
if (firstMapping.to_connection_type === 'internal') {
|
||||
setToConnection({ type: 'internal', name: '내부 DB' });
|
||||
// 내부 DB 테이블 목록 로드
|
||||
BatchAPI.getTablesFromConnection({ type: 'internal', name: '내부 DB' }).then(tables => {
|
||||
console.log("📋 TO 테이블 목록:", tables);
|
||||
setToTables(tables);
|
||||
|
||||
// 컬럼 정보도 로드
|
||||
if (firstMapping.to_table_name) {
|
||||
BatchAPI.getTableColumns({ type: 'internal', name: '내부 DB' }, firstMapping.to_table_name).then(columns => {
|
||||
console.log("📊 TO 컬럼 목록:", columns);
|
||||
setToColumns(columns);
|
||||
});
|
||||
}
|
||||
});
|
||||
} else if (firstMapping.to_connection_id) {
|
||||
const toConn = connections.find(c => c.id === firstMapping.to_connection_id);
|
||||
if (toConn) {
|
||||
setToConnection(toConn);
|
||||
// 외부 DB 테이블 목록 로드
|
||||
BatchAPI.getTablesFromConnection(toConn).then(tables => {
|
||||
console.log("📋 TO 테이블 목록:", tables);
|
||||
setToTables(tables);
|
||||
|
||||
// 컬럼 정보도 로드
|
||||
if (firstMapping.to_table_name) {
|
||||
BatchAPI.getTableColumns(toConn, firstMapping.to_table_name).then(columns => {
|
||||
console.log("📊 TO 컬럼 목록:", columns);
|
||||
setToColumns(columns);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [batchConfig, connections]);
|
||||
|
||||
// 배치 설정 조회
|
||||
const loadBatchConfig = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
console.log("🔍 배치 설정 조회 시작:", batchId);
|
||||
|
||||
const config = await BatchAPI.getBatchConfig(batchId);
|
||||
console.log("📋 조회된 배치 설정:", config);
|
||||
|
||||
setBatchConfig(config);
|
||||
setBatchName(config.batch_name);
|
||||
setCronSchedule(config.cron_schedule);
|
||||
setDescription(config.description || "");
|
||||
setIsActive(config.is_active || "Y");
|
||||
|
||||
if (config.batch_mappings && config.batch_mappings.length > 0) {
|
||||
console.log("📊 매핑 정보:", config.batch_mappings);
|
||||
console.log("📊 매핑 개수:", config.batch_mappings.length);
|
||||
config.batch_mappings.forEach((mapping, idx) => {
|
||||
console.log(`📊 매핑 #${idx + 1}:`, {
|
||||
from: `${mapping.from_column_name} (${mapping.from_column_type})`,
|
||||
to: `${mapping.to_column_name} (${mapping.to_column_type})`,
|
||||
type: mapping.mapping_type
|
||||
});
|
||||
});
|
||||
setMappings(config.batch_mappings);
|
||||
|
||||
// 첫 번째 매핑에서 연결 및 테이블 정보 추출
|
||||
const firstMapping = config.batch_mappings[0];
|
||||
setFromTable(firstMapping.from_table_name);
|
||||
setToTable(firstMapping.to_table_name);
|
||||
|
||||
// 배치 타입 감지
|
||||
const detectedBatchType = detectBatchType(firstMapping);
|
||||
setBatchType(detectedBatchType);
|
||||
console.log("🎯 감지된 배치 타입:", detectedBatchType);
|
||||
|
||||
// FROM 연결 정보 설정
|
||||
if (firstMapping.from_connection_type === 'internal') {
|
||||
setFromConnection({ type: 'internal', name: '내부 DB' });
|
||||
} else if (firstMapping.from_connection_id) {
|
||||
// 외부 연결은 connections 로드 후 설정
|
||||
setTimeout(() => {
|
||||
const fromConn = connections.find(c => c.id === firstMapping.from_connection_id);
|
||||
if (fromConn) {
|
||||
setFromConnection(fromConn);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// TO 연결 정보 설정
|
||||
if (firstMapping.to_connection_type === 'internal') {
|
||||
setToConnection({ type: 'internal', name: '내부 DB' });
|
||||
} else if (firstMapping.to_connection_id) {
|
||||
// 외부 연결은 connections 로드 후 설정
|
||||
setTimeout(() => {
|
||||
const toConn = connections.find(c => c.id === firstMapping.to_connection_id);
|
||||
if (toConn) {
|
||||
setToConnection(toConn);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
console.log("🔗 테이블 정보 설정:", {
|
||||
fromTable: firstMapping.from_table_name,
|
||||
toTable: firstMapping.to_table_name,
|
||||
fromConnectionType: firstMapping.from_connection_type,
|
||||
toConnectionType: firstMapping.to_connection_type
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("❌ 배치 설정 조회 오류:", error);
|
||||
toast.error("배치 설정을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 연결 정보 조회
|
||||
const loadConnections = async () => {
|
||||
try {
|
||||
const connectionList = await BatchAPI.getConnections();
|
||||
setConnections(connectionList);
|
||||
} catch (error) {
|
||||
console.error("연결 정보 조회 오류:", error);
|
||||
toast.error("연결 정보를 불러오는데 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// FROM 연결 변경 시
|
||||
const handleFromConnectionChange = async (connectionId: string) => {
|
||||
const connection = connections.find(c => c.id?.toString() === connectionId) ||
|
||||
(connectionId === 'internal' ? { type: 'internal' as const, name: '내부 DB' } : null);
|
||||
|
||||
if (connection) {
|
||||
setFromConnection(connection);
|
||||
|
||||
try {
|
||||
const tables = await BatchAPI.getTablesFromConnection(connection);
|
||||
setFromTables(tables);
|
||||
setFromTable("");
|
||||
setFromColumns([]);
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 조회 오류:", error);
|
||||
toast.error("테이블 목록을 불러오는데 실패했습니다.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// TO 연결 변경 시
|
||||
const handleToConnectionChange = async (connectionId: string) => {
|
||||
const connection = connections.find(c => c.id?.toString() === connectionId) ||
|
||||
(connectionId === 'internal' ? { type: 'internal' as const, name: '내부 DB' } : null);
|
||||
|
||||
if (connection) {
|
||||
setToConnection(connection);
|
||||
|
||||
try {
|
||||
const tables = await BatchAPI.getTablesFromConnection(connection);
|
||||
setToTables(tables);
|
||||
setToTable("");
|
||||
setToColumns([]);
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 조회 오류:", error);
|
||||
toast.error("테이블 목록을 불러오는데 실패했습니다.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// FROM 테이블 변경 시
|
||||
const handleFromTableChange = async (tableName: string) => {
|
||||
setFromTable(tableName);
|
||||
|
||||
if (fromConnection && tableName) {
|
||||
try {
|
||||
const columns = await BatchAPI.getTableColumns(fromConnection, tableName);
|
||||
setFromColumns(columns);
|
||||
} catch (error) {
|
||||
console.error("컬럼 정보 조회 오류:", error);
|
||||
toast.error("컬럼 정보를 불러오는데 실패했습니다.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// TO 테이블 변경 시
|
||||
const handleToTableChange = async (tableName: string) => {
|
||||
setToTable(tableName);
|
||||
|
||||
if (toConnection && tableName) {
|
||||
try {
|
||||
const columns = await BatchAPI.getTableColumns(toConnection, tableName);
|
||||
setToColumns(columns);
|
||||
} catch (error) {
|
||||
console.error("컬럼 정보 조회 오류:", error);
|
||||
toast.error("컬럼 정보를 불러오는데 실패했습니다.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 매핑 추가
|
||||
const addMapping = () => {
|
||||
const newMapping: BatchMapping = {
|
||||
from_connection_type: fromConnection?.type === 'internal' ? 'internal' : 'external',
|
||||
from_connection_id: fromConnection?.type === 'internal' ? undefined : fromConnection?.id,
|
||||
from_table_name: fromTable,
|
||||
from_column_name: '',
|
||||
from_column_type: '',
|
||||
to_connection_type: toConnection?.type === 'internal' ? 'internal' : 'external',
|
||||
to_connection_id: toConnection?.type === 'internal' ? undefined : toConnection?.id,
|
||||
to_table_name: toTable,
|
||||
to_column_name: '',
|
||||
to_column_type: '',
|
||||
mapping_type: 'direct',
|
||||
mapping_order: mappings.length + 1
|
||||
};
|
||||
|
||||
setMappings([...mappings, newMapping]);
|
||||
};
|
||||
|
||||
// 매핑 삭제
|
||||
const removeMapping = (index: number) => {
|
||||
const updatedMappings = mappings.filter((_, i) => i !== index);
|
||||
setMappings(updatedMappings);
|
||||
};
|
||||
|
||||
// 매핑 업데이트
|
||||
const updateMapping = (index: number, field: keyof BatchMapping, value: any) => {
|
||||
const updatedMappings = [...mappings];
|
||||
updatedMappings[index] = { ...updatedMappings[index], [field]: value };
|
||||
setMappings(updatedMappings);
|
||||
};
|
||||
|
||||
// 배치 설정 저장
|
||||
const saveBatchConfig = async () => {
|
||||
if (!batchName || !cronSchedule || mappings.length === 0) {
|
||||
toast.error("필수 항목을 모두 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
await BatchAPI.updateBatchConfig(batchId, {
|
||||
batchName,
|
||||
description,
|
||||
cronSchedule,
|
||||
isActive,
|
||||
mappings
|
||||
});
|
||||
|
||||
toast.success("배치 설정이 성공적으로 수정되었습니다.");
|
||||
router.push("/admin/batchmng");
|
||||
|
||||
} catch (error) {
|
||||
console.error("배치 설정 수정 실패:", error);
|
||||
toast.error("배치 설정 수정에 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && !batchConfig) {
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="w-8 h-8 animate-spin" />
|
||||
<span className="ml-2">배치 설정을 불러오는 중...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push("/admin/batchmng")}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
목록으로
|
||||
</Button>
|
||||
<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>
|
||||
<Button onClick={saveBatchConfig} disabled={loading}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="batchName">배치명 *</Label>
|
||||
<Input
|
||||
id="batchName"
|
||||
value={batchName}
|
||||
onChange={(e) => setBatchName(e.target.value)}
|
||||
placeholder="배치명을 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="cronSchedule">실행 스케줄 (Cron) *</Label>
|
||||
<Input
|
||||
id="cronSchedule"
|
||||
value={cronSchedule}
|
||||
onChange={(e) => setCronSchedule(e.target.value)}
|
||||
placeholder="0 12 * * *"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="배치에 대한 설명을 입력하세요"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="isActive"
|
||||
checked={isActive === 'Y'}
|
||||
onCheckedChange={(checked) => setIsActive(checked ? 'Y' : 'N')}
|
||||
/>
|
||||
<Label htmlFor="isActive">활성화</Label>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 배치 타입 표시 */}
|
||||
{batchType && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<span>배치 타입</span>
|
||||
<Badge variant="outline">
|
||||
{batchType === 'db-to-db' && 'DB → DB'}
|
||||
{batchType === 'restapi-to-db' && 'REST API → DB'}
|
||||
{batchType === 'db-to-restapi' && 'DB → REST API'}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 연결 설정 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>연결 설정</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{batchType === 'db-to-db' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* FROM 설정 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">FROM (소스)</h3>
|
||||
|
||||
<div>
|
||||
<Label>연결</Label>
|
||||
<Select
|
||||
value={fromConnection?.type === 'internal' ? 'internal' : fromConnection?.id?.toString() || ''}
|
||||
onValueChange={handleFromConnectionChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="소스 연결을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="internal">내부 DB</SelectItem>
|
||||
{connections.filter(conn => conn.id).map((conn) => (
|
||||
<SelectItem key={conn.id} value={conn.id!.toString()}>
|
||||
{conn.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>테이블</Label>
|
||||
<Select value={fromTable} onValueChange={handleFromTableChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="소스 테이블을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fromTables.map((table) => (
|
||||
<SelectItem key={table} value={table}>
|
||||
{table}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TO 설정 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">TO (대상)</h3>
|
||||
|
||||
<div>
|
||||
<Label>연결</Label>
|
||||
<Select
|
||||
value={toConnection?.type === 'internal' ? 'internal' : toConnection?.id?.toString() || ''}
|
||||
onValueChange={handleToConnectionChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="대상 연결을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="internal">내부 DB</SelectItem>
|
||||
{connections.filter(conn => conn.id).map((conn) => (
|
||||
<SelectItem key={conn.id} value={conn.id!.toString()}>
|
||||
{conn.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>테이블</Label>
|
||||
<Select value={toTable} onValueChange={handleToTableChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="대상 테이블을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{toTables.map((table) => (
|
||||
<SelectItem key={table} value={table}>
|
||||
{table}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{batchType === 'restapi-to-db' && (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center py-4 bg-blue-50 rounded-lg">
|
||||
<h3 className="text-lg font-semibold text-blue-800">REST API → DB 배치</h3>
|
||||
<p className="text-sm text-blue-600">외부 REST API에서 데이터를 가져와 데이터베이스에 저장합니다.</p>
|
||||
</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>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{batchType === 'db-to-restapi' && (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center py-4 bg-green-50 rounded-lg">
|
||||
<h3 className="text-lg font-semibold text-green-800">DB → REST API 배치</h3>
|
||||
<p className="text-sm text-green-600">데이터베이스에서 데이터를 가져와 외부 REST API로 전송합니다.</p>
|
||||
</div>
|
||||
|
||||
{mappings.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>소스 테이블</Label>
|
||||
<Input value={mappings[0]?.from_table_name || ''} readOnly />
|
||||
</div>
|
||||
<div>
|
||||
<Label>API URL</Label>
|
||||
<Input value={mappings[0]?.to_api_url || ''} readOnly />
|
||||
</div>
|
||||
<div>
|
||||
<Label>API 엔드포인트</Label>
|
||||
<Input value={mappings[0]?.to_table_name || ''} readOnly />
|
||||
</div>
|
||||
<div>
|
||||
<Label>HTTP 메서드</Label>
|
||||
<Input value={mappings[0]?.to_api_method || 'POST'} readOnly />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 컬럼 매핑 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
{batchType === 'db-to-db' && '컬럼 매핑'}
|
||||
{batchType === 'restapi-to-db' && 'API 필드 → DB 컬럼 매핑'}
|
||||
{batchType === 'db-to-restapi' && 'DB 컬럼 → API 필드 매핑'}
|
||||
{batchType === 'db-to-db' && (
|
||||
<Button onClick={addMapping} size="sm">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
매핑 추가
|
||||
</Button>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{mappings.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
{batchType === 'db-to-db' && '매핑을 추가해주세요.'}
|
||||
{batchType === 'restapi-to-db' && 'API 필드와 DB 컬럼 매핑 정보가 없습니다.'}
|
||||
{batchType === 'db-to-restapi' && 'DB 컬럼과 API 필드 매핑 정보가 없습니다.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{batchType === 'db-to-db' && mappings.map((mapping, index) => (
|
||||
<div key={index} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h4 className="font-medium">매핑 #{index + 1}</h4>
|
||||
{mapping.from_column_name && mapping.to_column_name && (
|
||||
<p className="text-sm text-gray-600">
|
||||
{mapping.from_column_name} → {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>FROM 컬럼</Label>
|
||||
<Select
|
||||
value={mapping.from_column_name || ''}
|
||||
onValueChange={(value) => {
|
||||
console.log(`📝 FROM 컬럼 변경: ${value}`);
|
||||
updateMapping(index, 'from_column_name', value);
|
||||
// 컬럼 타입도 함께 업데이트
|
||||
const selectedColumn = fromColumns.find(col => col.column_name === value);
|
||||
if (selectedColumn) {
|
||||
updateMapping(index, 'from_column_type', selectedColumn.data_type);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="소스 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fromColumns.map((column) => (
|
||||
<SelectItem key={column.column_name} value={column.column_name}>
|
||||
{column.column_name} ({column.data_type})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{fromColumns.length === 0 && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
소스 테이블을 선택하면 컬럼 목록이 표시됩니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>TO 컬럼</Label>
|
||||
<Select
|
||||
value={mapping.to_column_name || ''}
|
||||
onValueChange={(value) => {
|
||||
console.log(`📝 TO 컬럼 변경: ${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>
|
||||
))}
|
||||
|
||||
{batchType === 'restapi-to-db' && mappings.map((mapping, index) => (
|
||||
<div key={index} className="border rounded-lg p-4">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>API 필드명</Label>
|
||||
<Input value={mapping.from_column_name || ''} readOnly />
|
||||
</div>
|
||||
<div>
|
||||
<Label>DB 컬럼명</Label>
|
||||
<Input value={mapping.to_column_name || ''} readOnly />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{batchType === 'db-to-restapi' && mappings.map((mapping, index) => (
|
||||
<div key={index} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h4 className="font-medium">매핑 #{index + 1}</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
DB 컬럼: {mapping.from_column_name} → API 필드: {mapping.to_column_name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>DB 컬럼명</Label>
|
||||
<Input value={mapping.from_column_name || ''} readOnly />
|
||||
</div>
|
||||
<div>
|
||||
<Label>API 필드명</Label>
|
||||
<Input value={mapping.to_column_name || ''} readOnly />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mapping.to_api_body && (
|
||||
<div className="mt-4">
|
||||
<Label>Request Body 템플릿</Label>
|
||||
<Textarea
|
||||
value={mapping.to_api_body}
|
||||
readOnly
|
||||
rows={3}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 저장 버튼 */}
|
||||
<div className="flex justify-end space-x-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push("/admin/batchmng")}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={saveBatchConfig}
|
||||
disabled={loading || mappings.length === 0}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
{loading ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
<span>{loading ? "저장 중..." : "배치 설정 저장"}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,450 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Play,
|
||||
Pause,
|
||||
Edit,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Database,
|
||||
ArrowRight,
|
||||
Globe
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
BatchAPI,
|
||||
BatchConfig,
|
||||
BatchMapping,
|
||||
} from "@/lib/api/batch";
|
||||
|
||||
export default function BatchManagementPage() {
|
||||
const router = useRouter();
|
||||
|
||||
// 상태 관리
|
||||
const [batchConfigs, setBatchConfigs] = useState<BatchConfig[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [executingBatch, setExecutingBatch] = useState<number | null>(null);
|
||||
const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false);
|
||||
|
||||
// 페이지 로드 시 배치 목록 조회
|
||||
useEffect(() => {
|
||||
loadBatchConfigs();
|
||||
}, [currentPage, searchTerm]);
|
||||
|
||||
// 배치 설정 목록 조회
|
||||
const loadBatchConfigs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await BatchAPI.getBatchConfigs({
|
||||
page: currentPage,
|
||||
limit: 10,
|
||||
search: searchTerm || undefined,
|
||||
});
|
||||
|
||||
if (response.success && response.data) {
|
||||
setBatchConfigs(response.data);
|
||||
if (response.pagination) {
|
||||
setTotalPages(response.pagination.totalPages);
|
||||
}
|
||||
} else {
|
||||
setBatchConfigs([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("배치 목록 조회 실패:", error);
|
||||
toast.error("배치 목록을 불러오는데 실패했습니다.");
|
||||
setBatchConfigs([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 배치 수동 실행
|
||||
const executeBatch = async (batchId: number) => {
|
||||
setExecutingBatch(batchId);
|
||||
try {
|
||||
const response = await BatchAPI.executeBatchConfig(batchId);
|
||||
if (response.success) {
|
||||
toast.success(`배치가 성공적으로 실행되었습니다! (처리: ${response.data?.totalRecords}개, 성공: ${response.data?.successRecords}개)`);
|
||||
} else {
|
||||
toast.error("배치 실행에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("배치 실행 실패:", error);
|
||||
toast.error("배치 실행 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setExecutingBatch(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 배치 활성화/비활성화 토글
|
||||
const toggleBatchStatus = async (batchId: number, currentStatus: string) => {
|
||||
console.log("🔄 배치 상태 변경 시작:", { batchId, currentStatus });
|
||||
|
||||
try {
|
||||
const newStatus = currentStatus === 'Y' ? 'N' : 'Y';
|
||||
console.log("📝 새로운 상태:", newStatus);
|
||||
|
||||
const result = await BatchAPI.updateBatchConfig(batchId, { isActive: newStatus });
|
||||
console.log("✅ API 호출 성공:", result);
|
||||
|
||||
toast.success(`배치가 ${newStatus === 'Y' ? '활성화' : '비활성화'}되었습니다.`);
|
||||
loadBatchConfigs(); // 목록 새로고침
|
||||
} catch (error) {
|
||||
console.error("❌ 배치 상태 변경 실패:", error);
|
||||
toast.error("배치 상태 변경에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 배치 삭제
|
||||
const deleteBatch = async (batchId: number, batchName: string) => {
|
||||
if (!confirm(`'${batchName}' 배치를 삭제하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await BatchAPI.deleteBatchConfig(batchId);
|
||||
toast.success("배치가 삭제되었습니다.");
|
||||
loadBatchConfigs(); // 목록 새로고침
|
||||
} catch (error) {
|
||||
console.error("배치 삭제 실패:", error);
|
||||
toast.error("배치 삭제에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 검색 처리
|
||||
const handleSearch = (value: string) => {
|
||||
setSearchTerm(value);
|
||||
setCurrentPage(1); // 검색 시 첫 페이지로 이동
|
||||
};
|
||||
|
||||
// 매핑 정보 요약 생성
|
||||
const getMappingSummary = (mappings: BatchMapping[]) => {
|
||||
if (!mappings || mappings.length === 0) {
|
||||
return "매핑 없음";
|
||||
}
|
||||
|
||||
const tableGroups = new Map<string, number>();
|
||||
mappings.forEach(mapping => {
|
||||
const key = `${mapping.from_table_name} → ${mapping.to_table_name}`;
|
||||
tableGroups.set(key, (tableGroups.get(key) || 0) + 1);
|
||||
});
|
||||
|
||||
const summaries = Array.from(tableGroups.entries()).map(([key, count]) =>
|
||||
`${key} (${count}개 컬럼)`
|
||||
);
|
||||
|
||||
return summaries.join(", ");
|
||||
};
|
||||
|
||||
// 배치 추가 버튼 클릭 핸들러
|
||||
const handleCreateBatch = () => {
|
||||
setIsBatchTypeModalOpen(true);
|
||||
};
|
||||
|
||||
// 배치 타입 선택 핸들러
|
||||
const handleBatchTypeSelect = (type: 'db-to-db' | 'restapi-to-db') => {
|
||||
console.log("배치 타입 선택:", type);
|
||||
setIsBatchTypeModalOpen(false);
|
||||
|
||||
if (type === 'db-to-db') {
|
||||
// 기존 DB → DB 배치 생성 페이지로 이동
|
||||
console.log("DB → DB 페이지로 이동:", '/admin/batchmng/create');
|
||||
router.push('/admin/batchmng/create');
|
||||
} else if (type === 'restapi-to-db') {
|
||||
// 새로운 REST API 배치 페이지로 이동
|
||||
console.log("REST API → DB 페이지로 이동:", '/admin/batch-management-new');
|
||||
try {
|
||||
router.push('/admin/batch-management-new');
|
||||
console.log("라우터 push 실행 완료");
|
||||
} catch (error) {
|
||||
console.error("라우터 push 오류:", error);
|
||||
// 대안: window.location 사용
|
||||
window.location.href = '/admin/batch-management-new';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">배치 관리</h1>
|
||||
<p className="text-muted-foreground">데이터베이스 간 배치 작업을 관리합니다.</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCreateBatch}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>배치 추가</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 검색 및 필터 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
||||
<Input
|
||||
placeholder="배치명 또는 설명으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadBatchConfigs}
|
||||
disabled={loading}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
<span>새로고침</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 배치 목록 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>배치 목록 ({batchConfigs.length}개)</span>
|
||||
{loading && <RefreshCw className="h-4 w-4 animate-spin" />}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{batchConfigs.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Database className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">배치가 없습니다</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{searchTerm ? "검색 결과가 없습니다." : "새로운 배치를 추가해보세요."}
|
||||
</p>
|
||||
{!searchTerm && (
|
||||
<Button
|
||||
onClick={handleCreateBatch}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>첫 번째 배치 추가</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{batchConfigs.map((batch) => (
|
||||
<div key={batch.id} className="border rounded-lg p-6 space-y-4">
|
||||
{/* 배치 기본 정보 */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-3">
|
||||
<h3 className="text-lg font-semibold">{batch.batch_name}</h3>
|
||||
<Badge variant={batch.is_active === 'Y' ? 'default' : 'secondary'}>
|
||||
{batch.is_active === 'Y' ? '활성' : '비활성'}
|
||||
</Badge>
|
||||
</div>
|
||||
{batch.description && (
|
||||
<p className="text-muted-foreground">{batch.description}</p>
|
||||
)}
|
||||
<div className="flex items-center space-x-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>{batch.cron_schedule}</span>
|
||||
</div>
|
||||
<div>
|
||||
생성일: {new Date(batch.created_date).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼들 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => executeBatch(batch.id)}
|
||||
disabled={executingBatch === batch.id}
|
||||
className="flex items-center space-x-1"
|
||||
>
|
||||
{executingBatch === batch.id ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
<span>실행</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
console.log("🖱️ 비활성화/활성화 버튼 클릭:", { batchId: batch.id, currentStatus: batch.is_active });
|
||||
toggleBatchStatus(batch.id, batch.is_active);
|
||||
}}
|
||||
className="flex items-center space-x-1"
|
||||
>
|
||||
{batch.is_active === 'Y' ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
<span>{batch.is_active === 'Y' ? '비활성화' : '활성화'}</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/admin/batchmng/edit/${batch.id}`)}
|
||||
className="flex items-center space-x-1"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
<span>수정</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => deleteBatch(batch.id, batch.batch_name)}
|
||||
className="flex items-center space-x-1 text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span>삭제</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 매핑 정보 */}
|
||||
{batch.batch_mappings && batch.batch_mappings.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">
|
||||
매핑 정보 ({batch.batch_mappings.length}개)
|
||||
</h4>
|
||||
<div className="text-sm">
|
||||
{getMappingSummary(batch.batch_mappings)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
이전
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
const pageNum = i + 1;
|
||||
return (
|
||||
<Button
|
||||
key={pageNum}
|
||||
variant={currentPage === pageNum ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(pageNum)}
|
||||
>
|
||||
{pageNum}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
다음
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 배치 타입 선택 모달 */}
|
||||
{isBatchTypeModalOpen && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<Card className="w-full max-w-2xl mx-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center">배치 타입 선택</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* DB → DB */}
|
||||
<div
|
||||
className="p-6 border rounded-lg cursor-pointer transition-all hover:border-blue-500 hover:bg-blue-50"
|
||||
onClick={() => handleBatchTypeSelect('db-to-db')}
|
||||
>
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<Database className="w-8 h-8 text-blue-600 mr-2" />
|
||||
<ArrowRight className="w-6 h-6 text-gray-400 mr-2" />
|
||||
<Database className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-medium text-lg mb-2">DB → DB</div>
|
||||
<div className="text-sm text-gray-500">데이터베이스 간 데이터 동기화</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* REST API → DB */}
|
||||
<div
|
||||
className="p-6 border rounded-lg cursor-pointer transition-all hover:border-green-500 hover:bg-green-50"
|
||||
onClick={() => handleBatchTypeSelect('restapi-to-db')}
|
||||
>
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<Globe className="w-8 h-8 text-green-600 mr-2" />
|
||||
<ArrowRight className="w-6 h-6 text-gray-400 mr-2" />
|
||||
<Database className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-medium text-lg mb-2">REST API → DB</div>
|
||||
<div className="text-sm text-gray-500">REST API에서 데이터베이스로 데이터 수집</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsBatchTypeModalOpen(false)}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,10 +4,12 @@ import { useState } from "react";
|
|||
import { useRouter } from "next/navigation";
|
||||
import { DataFlowDesigner } from "@/components/dataflow/DataFlowDesigner";
|
||||
import DataFlowList from "@/components/dataflow/DataFlowList";
|
||||
// 🎨 새로운 UI 컴포넌트 import
|
||||
import DataConnectionDesigner from "@/components/dataflow/connection/redesigned/DataConnectionDesigner";
|
||||
import { TableRelationship, DataFlowDiagram } from "@/lib/api/dataflow";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { loadDataflowRelationship } from "@/lib/api/dataflowSave";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type Step = "list" | "design";
|
||||
|
||||
|
|
@ -16,6 +18,8 @@ export default function DataFlowPage() {
|
|||
const router = useRouter();
|
||||
const [currentStep, setCurrentStep] = useState<Step>("list");
|
||||
const [stepHistory, setStepHistory] = useState<Step[]>(["list"]);
|
||||
const [editingDiagram, setEditingDiagram] = useState<DataFlowDiagram | null>(null);
|
||||
const [loadedRelationshipData, setLoadedRelationshipData] = useState<any>(null);
|
||||
|
||||
// 단계별 제목과 설명
|
||||
const stepConfig = {
|
||||
|
|
@ -62,61 +66,70 @@ export default function DataFlowPage() {
|
|||
// 저장 후 목록으로 돌아가기 - 다음 렌더링 사이클로 지연
|
||||
setTimeout(() => {
|
||||
goToStep("list");
|
||||
setEditingDiagram(null);
|
||||
setLoadedRelationshipData(null);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleDesignDiagram = (diagram: DataFlowDiagram | null) => {
|
||||
// 관계도 수정 핸들러
|
||||
const handleDesignDiagram = async (diagram: DataFlowDiagram | null) => {
|
||||
if (diagram) {
|
||||
// 기존 관계도 편집 - 새로운 URL로 이동
|
||||
router.push(`/admin/dataflow/edit/${diagram.diagramId}`);
|
||||
// 기존 관계도 수정 - 저장된 관계 정보 로드
|
||||
try {
|
||||
console.log("📖 관계도 수정 모드:", diagram);
|
||||
|
||||
// 저장된 관계 정보 로드
|
||||
const relationshipData = await loadDataflowRelationship(diagram.diagramId);
|
||||
console.log("✅ 관계 정보 로드 완료:", relationshipData);
|
||||
|
||||
setEditingDiagram(diagram);
|
||||
setLoadedRelationshipData(relationshipData);
|
||||
goToNextStep("design");
|
||||
|
||||
toast.success(`"${diagram.diagramName}" 관계를 불러왔습니다.`);
|
||||
} catch (error: any) {
|
||||
console.error("❌ 관계 정보 로드 실패:", error);
|
||||
toast.error(error.message || "관계 정보를 불러오는데 실패했습니다.");
|
||||
}
|
||||
} else {
|
||||
// 새 관계도 생성 - 현재 페이지에서 처리
|
||||
setEditingDiagram(null);
|
||||
setLoadedRelationshipData(null);
|
||||
goToNextStep("design");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||
<div className="container mx-auto space-y-4 p-4">
|
||||
{/* 페이지 제목 */}
|
||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
||||
<div className="flex items-center justify-between rounded-lg border bg-white p-4 shadow-sm">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">데이터 흐름 관리</h1>
|
||||
<p className="mt-2 text-gray-600">테이블 간 데이터 관계를 시각적으로 설계하고 관리합니다</p>
|
||||
</div>
|
||||
{currentStep !== "list" && (
|
||||
<Button variant="outline" onClick={goToPreviousStep} className="flex items-center shadow-sm">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
이전
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 단계별 내용 */}
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-6">
|
||||
{/* 관계도 목록 단계 */}
|
||||
{currentStep === "list" && (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-4">
|
||||
<h2 className="text-xl font-semibold text-gray-800">{stepConfig.list.title}</h2>
|
||||
</div>
|
||||
<DataFlowList onDesignDiagram={handleDesignDiagram} />
|
||||
</div>
|
||||
)}
|
||||
{currentStep === "list" && <DataFlowList onDesignDiagram={handleDesignDiagram} />}
|
||||
|
||||
{/* 관계도 설계 단계 */}
|
||||
{/* 관계도 설계 단계 - 🎨 새로운 UI 사용 */}
|
||||
{currentStep === "design" && (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-4">
|
||||
<h2 className="text-xl font-semibold text-gray-800">{stepConfig.design.title}</h2>
|
||||
</div>
|
||||
<DataFlowDesigner
|
||||
companyCode={user?.company_code || "COMP001"}
|
||||
onSave={handleSave}
|
||||
selectedDiagram={null}
|
||||
onBackToList={() => goToStep("list")}
|
||||
/>
|
||||
</div>
|
||||
<DataConnectionDesigner
|
||||
onClose={() => {
|
||||
goToStep("list");
|
||||
setEditingDiagram(null);
|
||||
setLoadedRelationshipData(null);
|
||||
}}
|
||||
initialData={
|
||||
loadedRelationshipData || {
|
||||
connectionType: "data_save",
|
||||
}
|
||||
}
|
||||
showBackButton={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import {
|
|||
Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { GlobalFileViewer } from "@/components/GlobalFileViewer";
|
||||
|
||||
/**
|
||||
* 관리자 메인 페이지
|
||||
|
|
@ -199,6 +200,16 @@ export default function AdminPage() {
|
|||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 전역 파일 관리 */}
|
||||
<div className="mx-auto max-w-7xl space-y-6">
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">전역 파일 관리</h2>
|
||||
<p className="text-gray-600">모든 페이지에서 업로드된 파일들을 관리합니다</p>
|
||||
</div>
|
||||
<GlobalFileViewer />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { useRouter } from "next/navigation";
|
|||
import { toast } from "sonner";
|
||||
import { initializeComponents } from "@/lib/registry/components";
|
||||
import { EditModal } from "@/components/screen/EditModal";
|
||||
import { isFileComponent, getComponentWebType } from "@/lib/utils/componentTypeUtils";
|
||||
// import { ResponsiveScreenContainer } from "@/components/screen/ResponsiveScreenContainer"; // 컨테이너 제거
|
||||
|
||||
export default function ScreenViewPage() {
|
||||
|
|
@ -116,10 +117,10 @@ export default function ScreenViewPage() {
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full min-h-[400px] w-full items-center justify-center bg-white">
|
||||
<div className="text-center">
|
||||
<Loader2 className="mx-auto h-8 w-8 animate-spin text-blue-600" />
|
||||
<p className="mt-2 text-gray-600">화면을 불러오는 중...</p>
|
||||
<div className="flex h-full min-h-[400px] w-full items-center justify-center bg-gradient-to-br from-gray-50 to-slate-100">
|
||||
<div className="text-center bg-white rounded-xl border border-gray-200/60 shadow-lg p-8">
|
||||
<Loader2 className="mx-auto h-10 w-10 animate-spin text-blue-600" />
|
||||
<p className="mt-4 text-gray-700 font-medium">화면을 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -127,14 +128,14 @@ export default function ScreenViewPage() {
|
|||
|
||||
if (error || !screen) {
|
||||
return (
|
||||
<div className="flex h-full min-h-[400px] w-full items-center justify-center bg-white">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-100">
|
||||
<span className="text-2xl">⚠️</span>
|
||||
<div className="flex h-full min-h-[400px] w-full items-center justify-center bg-gradient-to-br from-gray-50 to-slate-100">
|
||||
<div className="text-center bg-white rounded-xl border border-gray-200/60 shadow-lg p-8 max-w-md">
|
||||
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br from-red-100 to-orange-100 shadow-sm">
|
||||
<span className="text-3xl">⚠️</span>
|
||||
</div>
|
||||
<h2 className="mb-2 text-xl font-semibold text-gray-900">화면을 찾을 수 없습니다</h2>
|
||||
<p className="mb-4 text-gray-600">{error || "요청하신 화면이 존재하지 않습니다."}</p>
|
||||
<Button onClick={() => router.back()} variant="outline">
|
||||
<h2 className="mb-3 text-xl font-bold text-gray-900">화면을 찾을 수 없습니다</h2>
|
||||
<p className="mb-6 text-gray-600 leading-relaxed">{error || "요청하신 화면이 존재하지 않습니다."}</p>
|
||||
<Button onClick={() => router.back()} variant="outline" className="rounded-lg">
|
||||
이전으로 돌아가기
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -147,17 +148,17 @@ export default function ScreenViewPage() {
|
|||
const screenHeight = layout?.screenResolution?.height || 800;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-auto bg-white pt-10">
|
||||
<div className="h-full w-full overflow-auto bg-gradient-to-br from-gray-50 to-slate-100 pt-10">
|
||||
{layout && layout.components.length > 0 ? (
|
||||
// 캔버스 컴포넌트들을 정확한 해상도로 표시
|
||||
<div
|
||||
className="relative bg-white"
|
||||
className="relative bg-white rounded-xl border border-gray-200/60 shadow-lg shadow-gray-900/5 mx-auto"
|
||||
style={{
|
||||
width: `${screenWidth}px`,
|
||||
height: `${screenHeight}px`,
|
||||
minWidth: `${screenWidth}px`,
|
||||
minHeight: `${screenHeight}px`,
|
||||
margin: "0", // mx-auto 제거하여 사이드바 오프셋 방지
|
||||
margin: "0 auto 40px auto", // 하단 여백 추가
|
||||
}}
|
||||
>
|
||||
{layout.components
|
||||
|
|
@ -177,15 +178,16 @@ export default function ScreenViewPage() {
|
|||
width: `${component.size.width}px`,
|
||||
height: `${component.size.height}px`,
|
||||
zIndex: component.position.z || 1,
|
||||
backgroundColor: (component as any).backgroundColor || "rgba(59, 130, 246, 0.1)",
|
||||
border: (component as any).border || "2px dashed #3b82f6",
|
||||
borderRadius: (component as any).borderRadius || "8px",
|
||||
padding: "16px",
|
||||
backgroundColor: (component as any).backgroundColor || "rgba(59, 130, 246, 0.05)",
|
||||
border: (component as any).border || "1px solid rgba(59, 130, 246, 0.2)",
|
||||
borderRadius: (component as any).borderRadius || "12px",
|
||||
padding: "20px",
|
||||
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)",
|
||||
}}
|
||||
>
|
||||
{/* 그룹 제목 */}
|
||||
{(component as any).title && (
|
||||
<div className="mb-2 text-sm font-medium text-blue-700">{(component as any).title}</div>
|
||||
<div className="mb-3 text-sm font-semibold text-blue-700 bg-blue-50 px-3 py-1 rounded-lg inline-block">{(component as any).title}</div>
|
||||
)}
|
||||
|
||||
{/* 그룹 내 자식 컴포넌트들 렌더링 */}
|
||||
|
|
@ -324,7 +326,19 @@ export default function ScreenViewPage() {
|
|||
/>
|
||||
) : (
|
||||
<DynamicWebTypeRenderer
|
||||
webType={component.webType || "text"}
|
||||
webType={(() => {
|
||||
// 유틸리티 함수로 파일 컴포넌트 감지
|
||||
if (isFileComponent(component)) {
|
||||
console.log(`🎯 page.tsx - 파일 컴포넌트 감지 → webType: "file"`, {
|
||||
componentId: component.id,
|
||||
componentType: component.type,
|
||||
originalWebType: component.webType
|
||||
});
|
||||
return "file";
|
||||
}
|
||||
// 다른 컴포넌트는 유틸리티 함수로 webType 결정
|
||||
return getComponentWebType(component) || "text";
|
||||
})()}
|
||||
config={component.webTypeConfig}
|
||||
props={{
|
||||
component: component,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,303 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { GlobalFileManager, GlobalFileInfo } from "@/lib/api/globalFile";
|
||||
import { downloadFile } from "@/lib/api/file";
|
||||
import { FileViewerModal } from "@/lib/registry/components/file-upload/FileViewerModal";
|
||||
import { formatFileSize } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
File,
|
||||
FileText,
|
||||
Image,
|
||||
Video,
|
||||
Music,
|
||||
Archive,
|
||||
Download,
|
||||
Eye,
|
||||
Search,
|
||||
Trash2,
|
||||
Clock,
|
||||
MapPin,
|
||||
Monitor,
|
||||
RefreshCw,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
|
||||
interface GlobalFileViewerProps {
|
||||
showControls?: boolean;
|
||||
maxHeight?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const GlobalFileViewer: React.FC<GlobalFileViewerProps> = ({
|
||||
showControls = true,
|
||||
maxHeight = "600px",
|
||||
className = "",
|
||||
}) => {
|
||||
const [allFiles, setAllFiles] = useState<GlobalFileInfo[]>([]);
|
||||
const [filteredFiles, setFilteredFiles] = useState<GlobalFileInfo[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedTab, setSelectedTab] = useState("all");
|
||||
const [viewerFile, setViewerFile] = useState<GlobalFileInfo | null>(null);
|
||||
const [isViewerOpen, setIsViewerOpen] = useState(false);
|
||||
const [registryInfo, setRegistryInfo] = useState({
|
||||
totalFiles: 0,
|
||||
accessibleFiles: 0,
|
||||
pages: [] as string[],
|
||||
screens: [] as number[],
|
||||
});
|
||||
|
||||
// 파일 아이콘 가져오기
|
||||
const getFileIcon = (fileName: string, size: number = 16) => {
|
||||
const extension = fileName.split('.').pop()?.toLowerCase() || '';
|
||||
const iconProps = { size, className: "text-gray-600" };
|
||||
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(extension)) {
|
||||
return <Image {...iconProps} className="text-blue-600" />;
|
||||
}
|
||||
if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm'].includes(extension)) {
|
||||
return <Video {...iconProps} className="text-purple-600" />;
|
||||
}
|
||||
if (['mp3', 'wav', 'flac', 'aac', 'ogg'].includes(extension)) {
|
||||
return <Music {...iconProps} className="text-green-600" />;
|
||||
}
|
||||
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(extension)) {
|
||||
return <Archive {...iconProps} className="text-yellow-600" />;
|
||||
}
|
||||
if (['txt', 'md', 'doc', 'docx', 'pdf', 'rtf'].includes(extension)) {
|
||||
return <FileText {...iconProps} className="text-red-600" />;
|
||||
}
|
||||
return <File {...iconProps} />;
|
||||
};
|
||||
|
||||
// 파일 목록 새로고침
|
||||
const refreshFiles = () => {
|
||||
const files = GlobalFileManager.getAllAccessibleFiles();
|
||||
const info = GlobalFileManager.getRegistryInfo();
|
||||
|
||||
setAllFiles(files);
|
||||
setRegistryInfo(info);
|
||||
|
||||
// 탭에 따른 필터링
|
||||
filterFilesByTab(files, selectedTab, searchQuery);
|
||||
|
||||
console.log("🔄 전역 파일 목록 새로고침:", files.length + "개");
|
||||
};
|
||||
|
||||
// 탭별 파일 필터링
|
||||
const filterFilesByTab = (files: GlobalFileInfo[], tab: string, query: string) => {
|
||||
let filtered = files;
|
||||
|
||||
// 탭별 필터링
|
||||
if (tab === "images") {
|
||||
filtered = files.filter(file => {
|
||||
const ext = file.realFileName?.split('.').pop()?.toLowerCase() || '';
|
||||
return ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(ext);
|
||||
});
|
||||
} else if (tab === "documents") {
|
||||
filtered = files.filter(file => {
|
||||
const ext = file.realFileName?.split('.').pop()?.toLowerCase() || '';
|
||||
return ['txt', 'md', 'doc', 'docx', 'pdf', 'rtf', 'hwp', 'hwpx'].includes(ext);
|
||||
});
|
||||
} else if (tab === "recent") {
|
||||
filtered = files
|
||||
.sort((a, b) => new Date(b.uploadTime).getTime() - new Date(a.uploadTime).getTime())
|
||||
.slice(0, 20);
|
||||
}
|
||||
|
||||
// 검색 필터링
|
||||
if (query.trim()) {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
filtered = filtered.filter(file =>
|
||||
file.realFileName?.toLowerCase().includes(lowerQuery) ||
|
||||
file.savedFileName?.toLowerCase().includes(lowerQuery) ||
|
||||
file.uploadPage?.toLowerCase().includes(lowerQuery)
|
||||
);
|
||||
}
|
||||
|
||||
setFilteredFiles(filtered);
|
||||
};
|
||||
|
||||
// 파일 다운로드
|
||||
const handleDownload = async (file: GlobalFileInfo) => {
|
||||
try {
|
||||
await downloadFile({
|
||||
fileId: file.objid,
|
||||
originalName: file.realFileName || file.savedFileName || "download",
|
||||
});
|
||||
toast.success(`파일 다운로드 시작: ${file.realFileName}`);
|
||||
} catch (error) {
|
||||
console.error("파일 다운로드 오류:", error);
|
||||
toast.error("파일 다운로드에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 파일 뷰어 열기
|
||||
const handleView = (file: GlobalFileInfo) => {
|
||||
setViewerFile(file);
|
||||
setIsViewerOpen(true);
|
||||
};
|
||||
|
||||
// 파일 접근 불가능하게 설정 (삭제 대신)
|
||||
const handleRemove = (file: GlobalFileInfo) => {
|
||||
GlobalFileManager.setFileAccessible(file.objid, false);
|
||||
refreshFiles();
|
||||
toast.success(`파일이 목록에서 제거되었습니다: ${file.realFileName}`);
|
||||
};
|
||||
|
||||
// 초기 로드 및 검색/탭 변경 시 필터링
|
||||
useEffect(() => {
|
||||
refreshFiles();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
filterFilesByTab(allFiles, selectedTab, searchQuery);
|
||||
}, [allFiles, selectedTab, searchQuery]);
|
||||
|
||||
return (
|
||||
<div className={`w-full ${className}`}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<File className="w-5 h-5" />
|
||||
전역 파일 저장소
|
||||
</CardTitle>
|
||||
{showControls && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="flex items-center gap-1">
|
||||
<Info className="w-3 h-3" />
|
||||
{registryInfo.accessibleFiles}개 파일
|
||||
</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={refreshFiles}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showControls && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="파일명으로 검색..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<Tabs value={selectedTab} onValueChange={setSelectedTab}>
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="all">전체</TabsTrigger>
|
||||
<TabsTrigger value="recent">최근</TabsTrigger>
|
||||
<TabsTrigger value="images">이미지</TabsTrigger>
|
||||
<TabsTrigger value="documents">문서</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={selectedTab} className="mt-4">
|
||||
<div
|
||||
className="space-y-2 overflow-y-auto"
|
||||
style={{ maxHeight }}
|
||||
>
|
||||
{filteredFiles.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
{searchQuery ? "검색 결과가 없습니다." : "저장된 파일이 없습니다."}
|
||||
</div>
|
||||
) : (
|
||||
filteredFiles.map((file) => (
|
||||
<Card key={file.objid} className="p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
{getFileIcon(file.realFileName || file.savedFileName || "", 20)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">
|
||||
{file.realFileName || file.savedFileName}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 flex items-center gap-2">
|
||||
<span>{formatFileSize(file.fileSize)}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{new Date(file.uploadTime).toLocaleDateString()}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3" />
|
||||
{file.uploadPage.split('/').pop() || 'Unknown'}
|
||||
</div>
|
||||
{file.screenId && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Monitor className="w-3 h-3" />
|
||||
Screen {file.screenId}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleView(file)}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Eye className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDownload(file)}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemove(file)}
|
||||
className="flex items-center gap-1 text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 파일 뷰어 모달 */}
|
||||
{viewerFile && (
|
||||
<FileViewerModal
|
||||
file={viewerFile}
|
||||
isOpen={isViewerOpen}
|
||||
onClose={() => {
|
||||
setIsViewerOpen(false);
|
||||
setViewerFile(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -23,7 +23,7 @@ import { Switch } from "@/components/ui/switch";
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
import { BatchAPI, BatchJob } from "@/lib/api/batch";
|
||||
import { CollectionAPI } from "@/lib/api/collection";
|
||||
// import { CollectionAPI } from "@/lib/api/collection"; // 사용하지 않는 import 제거
|
||||
|
||||
interface BatchJobModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -101,12 +101,14 @@ export default function BatchJobModal({
|
|||
|
||||
const loadCollectionConfigs = async () => {
|
||||
try {
|
||||
const configs = await CollectionAPI.getCollectionConfigs({
|
||||
// 배치 설정 조회로 대체
|
||||
const configs = await BatchAPI.getBatchConfigs({
|
||||
is_active: "Y",
|
||||
});
|
||||
setCollectionConfigs(configs);
|
||||
setCollectionConfigs(configs.data || []);
|
||||
} catch (error) {
|
||||
console.error("수집 설정 조회 오류:", error);
|
||||
console.error("배치 설정 조회 오류:", error);
|
||||
setCollectionConfigs([]);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
SimpleExternalCallSettings,
|
||||
ConnectionSetupModalProps,
|
||||
} from "@/types/connectionTypes";
|
||||
import { ExternalCallConfig } from "@/types/external-call/ExternalCallTypes";
|
||||
import { isConditionalConnection } from "@/utils/connectionUtils";
|
||||
import { useConditionManager } from "@/hooks/useConditionManager";
|
||||
import { ConditionalSettings } from "./condition/ConditionalSettings";
|
||||
|
|
@ -30,6 +31,7 @@ import { ConnectionTypeSelector } from "./connection/ConnectionTypeSelector";
|
|||
import { SimpleKeySettings as SimpleKeySettingsComponent } from "./connection/SimpleKeySettings";
|
||||
import { DataSaveSettings as DataSaveSettingsComponent } from "./connection/DataSaveSettings";
|
||||
import { SimpleExternalCallSettings as ExternalCallSettingsComponent } from "./connection/SimpleExternalCallSettings";
|
||||
import ExternalCallPanel from "./external-call/ExternalCallPanel";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
||||
|
|
@ -60,6 +62,9 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
|||
message: "",
|
||||
});
|
||||
|
||||
// 새로운 외부호출 설정 상태 (분리된 컴포넌트용)
|
||||
const [externalCallConfig, setExternalCallConfig] = useState<ExternalCallConfig | null>(null);
|
||||
|
||||
// 테이블 및 컬럼 선택을 위한 상태들
|
||||
const [availableTables, setAvailableTables] = useState<TableInfo[]>([]);
|
||||
const [selectedFromTable, setSelectedFromTable] = useState<string>("");
|
||||
|
|
@ -390,14 +395,21 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
|||
}
|
||||
break;
|
||||
case "external-call":
|
||||
// 외부 호출은 plan에 저장
|
||||
plan = {
|
||||
externalCall: {
|
||||
configId: externalCallSettings.configId,
|
||||
configName: externalCallSettings.configName,
|
||||
message: externalCallSettings.message,
|
||||
},
|
||||
};
|
||||
// 새로운 외부호출 설정을 plan에 저장
|
||||
if (externalCallConfig) {
|
||||
plan = {
|
||||
externalCall: externalCallConfig,
|
||||
};
|
||||
} else {
|
||||
// 기존 설정 호환성 유지
|
||||
plan = {
|
||||
externalCall: {
|
||||
configId: externalCallSettings.configId,
|
||||
configName: externalCallSettings.configName,
|
||||
message: externalCallSettings.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
settings = {}; // 외부 호출은 settings에 저장하지 않음
|
||||
break;
|
||||
}
|
||||
|
|
@ -507,6 +519,9 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
|||
|
||||
// 연결 종류별 설정 패널 렌더링
|
||||
const renderConnectionTypeSettings = () => {
|
||||
console.log("🔍 [ConnectionSetupModal] renderConnectionTypeSettings - connectionType:", config.connectionType);
|
||||
console.log("🔍 [ConnectionSetupModal] externalCallConfig:", externalCallConfig);
|
||||
|
||||
switch (config.connectionType) {
|
||||
case "simple-key":
|
||||
return (
|
||||
|
|
@ -540,8 +555,13 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
|||
);
|
||||
|
||||
case "external-call":
|
||||
console.log("🚀 [ConnectionSetupModal] Rendering ExternalCallPanel");
|
||||
return (
|
||||
<ExternalCallSettingsComponent settings={externalCallSettings} onSettingsChange={setExternalCallSettings} />
|
||||
<ExternalCallPanel
|
||||
relationshipId={connection?.id || `temp-${Date.now()}`}
|
||||
initialSettings={externalCallConfig}
|
||||
onSettingsChange={setExternalCallConfig}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
|
|
@ -631,8 +651,12 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
|||
return !hasActions || !allActionsHaveMappings || !allMappingsComplete || !allRequiredConditionsMet;
|
||||
|
||||
case "external-call":
|
||||
// 외부 호출: 설정 ID와 메시지가 있어야 함
|
||||
return !externalCallSettings.configId || !externalCallSettings.message?.trim();
|
||||
// 외부 호출: 새로운 설정이 있으면 API URL 검증, 없으면 기존 설정 검증
|
||||
if (externalCallConfig) {
|
||||
return !externalCallConfig.restApiSettings?.apiUrl?.trim();
|
||||
} else {
|
||||
return !externalCallSettings.configId || !externalCallSettings.message?.trim();
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -52,22 +52,45 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
|
|||
const response = await DataFlowAPI.getJsonDataFlowDiagrams(currentPage, 20, searchTerm, companyCode);
|
||||
|
||||
// JSON API 응답을 기존 형식으로 변환
|
||||
const convertedDiagrams = response.diagrams.map((diagram) => ({
|
||||
diagramId: diagram.diagram_id,
|
||||
relationshipId: diagram.diagram_id, // 호환성을 위해 추가
|
||||
diagramName: diagram.diagram_name,
|
||||
connectionType: "json-based", // 새로운 JSON 기반 타입
|
||||
relationshipType: "multi-relationship", // 다중 관계 타입
|
||||
relationshipCount: diagram.relationships?.relationships?.length || 0,
|
||||
tableCount: diagram.relationships?.tables?.length || 0,
|
||||
tables: diagram.relationships?.tables || [],
|
||||
companyCode: diagram.company_code, // 회사 코드 추가
|
||||
createdAt: new Date(diagram.created_at || new Date()),
|
||||
createdBy: diagram.created_by || "SYSTEM",
|
||||
updatedAt: new Date(diagram.updated_at || diagram.created_at || new Date()),
|
||||
updatedBy: diagram.updated_by || "SYSTEM",
|
||||
lastUpdated: diagram.updated_at || diagram.created_at || new Date().toISOString(),
|
||||
}));
|
||||
const convertedDiagrams = response.diagrams.map((diagram) => {
|
||||
// relationships 구조 분석
|
||||
const relationships = diagram.relationships || {};
|
||||
|
||||
// 테이블 정보 추출
|
||||
const tables: string[] = [];
|
||||
if (relationships.fromTable?.tableName) {
|
||||
tables.push(relationships.fromTable.tableName);
|
||||
}
|
||||
if (
|
||||
relationships.toTable?.tableName &&
|
||||
relationships.toTable.tableName !== relationships.fromTable?.tableName
|
||||
) {
|
||||
tables.push(relationships.toTable.tableName);
|
||||
}
|
||||
|
||||
// 관계 수 계산 (actionGroups 기준)
|
||||
const actionGroups = relationships.actionGroups || [];
|
||||
const relationshipCount = actionGroups.reduce((count: number, group: any) => {
|
||||
return count + (group.actions?.length || 0);
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
diagramId: diagram.diagram_id,
|
||||
relationshipId: diagram.diagram_id, // 호환성을 위해 추가
|
||||
diagramName: diagram.diagram_name,
|
||||
connectionType: relationships.connectionType || "data_save", // 실제 연결 타입 사용
|
||||
relationshipType: "multi-relationship", // 다중 관계 타입
|
||||
relationshipCount: relationshipCount || 1, // 최소 1개는 있다고 가정
|
||||
tableCount: tables.length,
|
||||
tables: tables,
|
||||
companyCode: diagram.company_code, // 회사 코드 추가
|
||||
createdAt: new Date(diagram.created_at || new Date()),
|
||||
createdBy: diagram.created_by || "SYSTEM",
|
||||
updatedAt: new Date(diagram.updated_at || diagram.created_at || new Date()),
|
||||
updatedBy: diagram.updated_by || "SYSTEM",
|
||||
lastUpdated: diagram.updated_at || diagram.created_at || new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
setDiagrams(convertedDiagrams);
|
||||
setTotal(response.pagination.total || 0);
|
||||
|
|
@ -240,7 +263,7 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
|
|||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onDesignDiagram(diagram)}>
|
||||
<Network className="mr-2 h-4 w-4" />
|
||||
관계도 설계
|
||||
수정
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleCopy(diagram)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -47,7 +47,10 @@ export const ConnectionTypeSelector: React.FC<ConnectionTypeSelectorProps> = ({
|
|||
? "border-orange-500 bg-orange-50"
|
||||
: "border-gray-200 hover:border-gray-300"
|
||||
}`}
|
||||
onClick={() => onConfigChange({ ...config, connectionType: "external-call" })}
|
||||
onClick={() => {
|
||||
console.log("🔄 [ConnectionTypeSelector] External call selected");
|
||||
onConfigChange({ ...config, connectionType: "external-call" });
|
||||
}}
|
||||
>
|
||||
<Globe className="mx-auto h-6 w-6 text-orange-500" />
|
||||
<div className="mt-1 text-xs font-medium">외부 호출</div>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Plus, Save, Trash2 } from "lucide-react";
|
||||
import { TableInfo, ColumnInfo } from "@/lib/api/dataflow";
|
||||
import { DataSaveSettings as DataSaveSettingsType } from "@/types/connectionTypes";
|
||||
import { ActionConditionsSection } from "./ActionConditionsSection";
|
||||
import { ActionFieldMappings } from "./ActionFieldMappings";
|
||||
import { ActionSplitConfig } from "./ActionSplitConfig";
|
||||
|
||||
// 🎨 새로운 UI 컴포넌트 import
|
||||
import DataConnectionDesigner from "./redesigned/DataConnectionDesigner";
|
||||
|
||||
interface DataSaveSettingsProps {
|
||||
settings: DataSaveSettingsType;
|
||||
|
|
@ -23,6 +18,11 @@ interface DataSaveSettingsProps {
|
|||
tableColumnsCache: { [tableName: string]: ColumnInfo[] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎨 데이터 저장 설정 컴포넌트
|
||||
* - 항상 새로운 UI (DataConnectionDesigner) 사용
|
||||
* - 기존 UI는 더 이상 사용하지 않음
|
||||
*/
|
||||
export const DataSaveSettings: React.FC<DataSaveSettingsProps> = ({
|
||||
settings,
|
||||
onSettingsChange,
|
||||
|
|
@ -33,195 +33,13 @@ export const DataSaveSettings: React.FC<DataSaveSettingsProps> = ({
|
|||
toTableName,
|
||||
tableColumnsCache,
|
||||
}) => {
|
||||
const addAction = () => {
|
||||
const newAction = {
|
||||
id: `action_${settings.actions.length + 1}`,
|
||||
name: `액션 ${settings.actions.length + 1}`,
|
||||
actionType: "insert" as const,
|
||||
// 첫 번째 액션이 아니면 기본적으로 AND 연산자 추가
|
||||
...(settings.actions.length > 0 && { logicalOperator: "AND" as const }),
|
||||
fieldMappings: [],
|
||||
conditions: [],
|
||||
splitConfig: {
|
||||
sourceField: "",
|
||||
delimiter: "",
|
||||
targetField: "",
|
||||
},
|
||||
};
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
actions: [...settings.actions, newAction],
|
||||
});
|
||||
};
|
||||
|
||||
const updateAction = (actionIndex: number, field: string, value: any) => {
|
||||
const newActions = [...settings.actions];
|
||||
(newActions[actionIndex] as any)[field] = value;
|
||||
onSettingsChange({ ...settings, actions: newActions });
|
||||
};
|
||||
|
||||
const removeAction = (actionIndex: number) => {
|
||||
const newActions = settings.actions.filter((_, i) => i !== actionIndex);
|
||||
|
||||
// 첫 번째 액션을 삭제했다면, 새로운 첫 번째 액션의 logicalOperator 제거
|
||||
if (actionIndex === 0 && newActions.length > 0) {
|
||||
delete newActions[0].logicalOperator;
|
||||
}
|
||||
|
||||
onSettingsChange({ ...settings, actions: newActions });
|
||||
};
|
||||
|
||||
// 🎨 항상 새로운 UI 사용
|
||||
return (
|
||||
<div className="rounded-lg border border-l-4 border-l-green-500 bg-green-50/30 p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Save className="h-4 w-4 text-green-500" />
|
||||
<span className="text-sm font-medium">데이터 저장 설정</span>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{/* 액션 목록 */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">저장 액션</Label>
|
||||
<Button size="sm" variant="outline" onClick={addAction} className="h-7 text-xs">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
액션 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{settings.actions.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed p-3 text-center text-xs text-gray-500">
|
||||
저장 액션을 추가하여 데이터를 어떻게 저장할지 설정하세요.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{settings.actions.map((action, actionIndex) => (
|
||||
<div key={action.id}>
|
||||
{/* 첫 번째 액션이 아닌 경우 논리 연산자 표시 */}
|
||||
{actionIndex > 0 && (
|
||||
<div className="mb-2 flex items-center justify-center">
|
||||
<div className="flex items-center gap-2 rounded-lg bg-gray-100 px-3 py-1">
|
||||
<span className="text-xs text-gray-600">이전 액션과의 관계:</span>
|
||||
<Select
|
||||
value={action.logicalOperator || "AND"}
|
||||
onValueChange={(value: "AND" | "OR") => updateAction(actionIndex, "logicalOperator", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-20 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="AND">AND</SelectItem>
|
||||
<SelectItem value="OR">OR</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded border bg-white p-3">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<Input
|
||||
value={action.name}
|
||||
onChange={(e) => updateAction(actionIndex, "name", e.target.value)}
|
||||
className="h-7 flex-1 text-xs font-medium"
|
||||
placeholder="액션 이름"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeAction(actionIndex)}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{/* 액션 타입 */}
|
||||
<div>
|
||||
<Label className="text-xs">액션 타입</Label>
|
||||
<Select
|
||||
value={action.actionType}
|
||||
onValueChange={(value: "insert" | "update" | "delete" | "upsert") =>
|
||||
updateAction(actionIndex, "actionType", value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="insert">INSERT</SelectItem>
|
||||
<SelectItem value="update">UPDATE</SelectItem>
|
||||
<SelectItem value="delete">DELETE</SelectItem>
|
||||
<SelectItem value="upsert">UPSERT</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 액션별 개별 실행 조건 */}
|
||||
<ActionConditionsSection
|
||||
action={action}
|
||||
actionIndex={actionIndex}
|
||||
settings={settings}
|
||||
onSettingsChange={onSettingsChange}
|
||||
fromTableColumns={fromTableColumns}
|
||||
toTableColumns={toTableColumns}
|
||||
fromTableName={fromTableName}
|
||||
toTableName={toTableName}
|
||||
/>
|
||||
|
||||
{/* 데이터 분할 설정 - DELETE 액션은 제외 */}
|
||||
{action.actionType !== "delete" && (
|
||||
<ActionSplitConfig
|
||||
action={action}
|
||||
actionIndex={actionIndex}
|
||||
settings={settings}
|
||||
onSettingsChange={onSettingsChange}
|
||||
fromTableColumns={fromTableColumns}
|
||||
toTableColumns={toTableColumns}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 필드 매핑 - DELETE 액션은 제외 */}
|
||||
{action.actionType !== "delete" && (
|
||||
<ActionFieldMappings
|
||||
action={action}
|
||||
actionIndex={actionIndex}
|
||||
settings={settings}
|
||||
onSettingsChange={onSettingsChange}
|
||||
availableTables={availableTables}
|
||||
tableColumnsCache={tableColumnsCache}
|
||||
fromTableColumns={fromTableColumns}
|
||||
toTableColumns={toTableColumns}
|
||||
fromTableName={fromTableName}
|
||||
toTableName={toTableName}
|
||||
enableMultiConnection={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* DELETE 액션일 때 다중 커넥션 지원 */}
|
||||
{action.actionType === "delete" && (
|
||||
<ActionFieldMappings
|
||||
action={action}
|
||||
actionIndex={actionIndex}
|
||||
settings={settings}
|
||||
onSettingsChange={onSettingsChange}
|
||||
availableTables={availableTables}
|
||||
tableColumnsCache={tableColumnsCache}
|
||||
fromTableColumns={fromTableColumns}
|
||||
toTableColumns={toTableColumns}
|
||||
fromTableName={fromTableName}
|
||||
toTableName={toTableName}
|
||||
enableMultiConnection={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DataConnectionDesigner
|
||||
onClose={undefined} // 닫기 버튼 제거 (항상 새 UI 사용)
|
||||
initialData={{
|
||||
connectionType: "data_save",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,721 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { X, ArrowLeft } from "lucide-react";
|
||||
|
||||
// API import
|
||||
import { saveDataflowRelationship, checkRelationshipNameDuplicate } from "@/lib/api/dataflowSave";
|
||||
import { getColumnsFromConnection } from "@/lib/api/multiConnection";
|
||||
|
||||
// 타입 import
|
||||
import {
|
||||
DataConnectionState,
|
||||
DataConnectionActions,
|
||||
DataConnectionDesignerProps,
|
||||
FieldMapping,
|
||||
ValidationResult,
|
||||
TestResult,
|
||||
MappingStats,
|
||||
ActionGroup,
|
||||
SingleAction,
|
||||
} from "./types/redesigned";
|
||||
import { ColumnInfo, Connection, TableInfo } from "@/lib/types/multiConnection";
|
||||
|
||||
// 컴포넌트 import
|
||||
import LeftPanel from "./LeftPanel/LeftPanel";
|
||||
import RightPanel from "./RightPanel/RightPanel";
|
||||
|
||||
/**
|
||||
* 🎨 데이터 연결 설정 메인 디자이너
|
||||
* - 좌우 분할 레이아웃 (30% + 70%)
|
||||
* - 상태 관리 및 액션 처리
|
||||
* - 기존 모달 기능을 메인 화면으로 통합
|
||||
*/
|
||||
const DataConnectionDesigner: React.FC<DataConnectionDesignerProps> = ({
|
||||
onClose,
|
||||
initialData,
|
||||
showBackButton = false,
|
||||
}) => {
|
||||
// 🔄 상태 관리
|
||||
const [state, setState] = useState<DataConnectionState>(() => ({
|
||||
connectionType: "data_save",
|
||||
currentStep: 1,
|
||||
fieldMappings: [],
|
||||
mappingStats: {
|
||||
totalMappings: 0,
|
||||
validMappings: 0,
|
||||
invalidMappings: 0,
|
||||
missingRequiredFields: 0,
|
||||
estimatedRows: 0,
|
||||
actionType: "INSERT",
|
||||
},
|
||||
// 제어 실행 조건 초기값
|
||||
controlConditions: [],
|
||||
|
||||
// 액션 그룹 초기값 (멀티 액션)
|
||||
actionGroups: [
|
||||
{
|
||||
id: "group_1",
|
||||
name: "기본 액션 그룹",
|
||||
logicalOperator: "AND" as const,
|
||||
actions: [
|
||||
{
|
||||
id: "action_1",
|
||||
name: "액션 1",
|
||||
actionType: "insert" as const,
|
||||
conditions: [],
|
||||
fieldMappings: [],
|
||||
isEnabled: true,
|
||||
},
|
||||
],
|
||||
isEnabled: true,
|
||||
},
|
||||
],
|
||||
groupsLogicalOperator: "AND" as "AND" | "OR",
|
||||
|
||||
// 기존 호환성 필드들 (deprecated)
|
||||
actionType: "insert",
|
||||
actionConditions: [],
|
||||
actionFieldMappings: [],
|
||||
isLoading: false,
|
||||
validationErrors: [],
|
||||
|
||||
// 컬럼 정보 초기화
|
||||
fromColumns: [],
|
||||
toColumns: [],
|
||||
...initialData,
|
||||
}));
|
||||
|
||||
// 🔧 수정 모드 감지 (initialData에 diagramId가 있으면 수정 모드)
|
||||
const diagramId = initialData?.diagramId;
|
||||
|
||||
// 🔄 초기 데이터 로드
|
||||
useEffect(() => {
|
||||
if (initialData && Object.keys(initialData).length > 1) {
|
||||
console.log("🔄 초기 데이터 로드:", initialData);
|
||||
|
||||
// 로드된 데이터로 state 업데이트
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
connectionType: initialData.connectionType || prev.connectionType,
|
||||
|
||||
// 🔧 관계 정보 로드
|
||||
relationshipName: initialData.relationshipName || prev.relationshipName,
|
||||
description: initialData.description || prev.description,
|
||||
groupsLogicalOperator: initialData.groupsLogicalOperator || prev.groupsLogicalOperator,
|
||||
|
||||
fromConnection: initialData.fromConnection || prev.fromConnection,
|
||||
toConnection: initialData.toConnection || prev.toConnection,
|
||||
fromTable: initialData.fromTable || prev.fromTable,
|
||||
toTable: initialData.toTable || prev.toTable,
|
||||
controlConditions: initialData.controlConditions || prev.controlConditions,
|
||||
fieldMappings: initialData.fieldMappings || prev.fieldMappings,
|
||||
|
||||
// 🔧 외부호출 설정 로드
|
||||
externalCallConfig: initialData.externalCallConfig || prev.externalCallConfig,
|
||||
|
||||
// 🔧 액션 그룹 데이터 로드 (기존 호환성 포함)
|
||||
actionGroups:
|
||||
initialData.actionGroups ||
|
||||
// 기존 단일 액션 데이터를 그룹으로 변환
|
||||
(initialData.actionType || initialData.actionConditions
|
||||
? [
|
||||
{
|
||||
id: "group_1",
|
||||
name: "기본 액션 그룹",
|
||||
logicalOperator: "AND" as const,
|
||||
actions: [
|
||||
{
|
||||
id: "action_1",
|
||||
name: "액션 1",
|
||||
actionType: initialData.actionType || ("insert" as const),
|
||||
conditions: initialData.actionConditions || [],
|
||||
fieldMappings: initialData.actionFieldMappings || [],
|
||||
isEnabled: true,
|
||||
},
|
||||
],
|
||||
isEnabled: true,
|
||||
},
|
||||
]
|
||||
: prev.actionGroups),
|
||||
|
||||
// 기존 호환성 필드들
|
||||
actionType: initialData.actionType || prev.actionType,
|
||||
actionConditions: initialData.actionConditions || prev.actionConditions,
|
||||
actionFieldMappings: initialData.actionFieldMappings || prev.actionFieldMappings,
|
||||
|
||||
currentStep: initialData.fromConnection && initialData.toConnection ? 4 : 1, // 연결 정보가 있으면 4단계부터 시작
|
||||
}));
|
||||
|
||||
console.log("✅ 초기 데이터 로드 완료");
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
// 🎯 액션 핸들러들
|
||||
const actions: DataConnectionActions = {
|
||||
// 연결 타입 설정
|
||||
setConnectionType: useCallback((type: "data_save" | "external_call") => {
|
||||
console.log("🔄 [DataConnectionDesigner] setConnectionType 호출됨:", type);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
connectionType: type,
|
||||
// 타입 변경 시 상태 초기화
|
||||
currentStep: 1,
|
||||
fromConnection: undefined,
|
||||
toConnection: undefined,
|
||||
fromTable: undefined,
|
||||
toTable: undefined,
|
||||
fieldMappings: [],
|
||||
validationErrors: [],
|
||||
}));
|
||||
toast.success(`연결 타입이 ${type === "data_save" ? "데이터 저장" : "외부 호출"}로 변경되었습니다.`);
|
||||
}, []),
|
||||
|
||||
// 🔧 관계 정보 설정
|
||||
setRelationshipName: useCallback((name: string) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
relationshipName: name,
|
||||
}));
|
||||
}, []),
|
||||
|
||||
setDescription: useCallback((description: string) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
description: description,
|
||||
}));
|
||||
}, []),
|
||||
|
||||
setGroupsLogicalOperator: useCallback((operator: "AND" | "OR") => {
|
||||
setState((prev) => ({ ...prev, groupsLogicalOperator: operator }));
|
||||
console.log("🔄 그룹 간 논리 연산자 변경:", operator);
|
||||
}, []),
|
||||
|
||||
// 단계 이동
|
||||
goToStep: useCallback((step: 1 | 2 | 3 | 4) => {
|
||||
setState((prev) => ({ ...prev, currentStep: step }));
|
||||
}, []),
|
||||
|
||||
// 연결 선택
|
||||
selectConnection: useCallback((type: "from" | "to", connection: Connection) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
[type === "from" ? "fromConnection" : "toConnection"]: connection,
|
||||
// 연결 변경 시 테이블과 매핑 초기화
|
||||
[type === "from" ? "fromTable" : "toTable"]: undefined,
|
||||
fieldMappings: [],
|
||||
}));
|
||||
toast.success(`${type === "from" ? "소스" : "대상"} 연결이 선택되었습니다: ${connection.name}`);
|
||||
}, []),
|
||||
|
||||
// 테이블 선택
|
||||
selectTable: useCallback((type: "from" | "to", table: TableInfo) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
[type === "from" ? "fromTable" : "toTable"]: table,
|
||||
// 테이블 변경 시 매핑과 컬럼 정보 초기화
|
||||
fieldMappings: [],
|
||||
fromColumns: type === "from" ? [] : prev.fromColumns,
|
||||
toColumns: type === "to" ? [] : prev.toColumns,
|
||||
}));
|
||||
toast.success(
|
||||
`${type === "from" ? "소스" : "대상"} 테이블이 선택되었습니다: ${table.displayName || table.tableName}`,
|
||||
);
|
||||
}, []),
|
||||
|
||||
// 컬럼 정보 로드 (중앙 관리)
|
||||
loadColumns: useCallback(async () => {
|
||||
if (!state.fromConnection || !state.toConnection || !state.fromTable || !state.toTable) {
|
||||
console.log("❌ 컬럼 로드: 필수 정보 누락");
|
||||
return;
|
||||
}
|
||||
|
||||
// 이미 로드된 경우 스킵 (배열 길이로 확인)
|
||||
if (state.fromColumns && state.toColumns && state.fromColumns.length > 0 && state.toColumns.length > 0) {
|
||||
console.log("✅ 컬럼 정보 이미 로드됨, 스킵", {
|
||||
fromColumns: state.fromColumns.length,
|
||||
toColumns: state.toColumns.length,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("🔄 중앙 컬럼 로드 시작:", {
|
||||
from: `${state.fromConnection.id}/${state.fromTable.tableName}`,
|
||||
to: `${state.toConnection.id}/${state.toTable.tableName}`,
|
||||
});
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isLoading: true,
|
||||
fromColumns: [],
|
||||
toColumns: [],
|
||||
}));
|
||||
|
||||
try {
|
||||
const [fromCols, toCols] = await Promise.all([
|
||||
getColumnsFromConnection(state.fromConnection.id, state.fromTable.tableName),
|
||||
getColumnsFromConnection(state.toConnection.id, state.toTable.tableName),
|
||||
]);
|
||||
|
||||
console.log("✅ 중앙 컬럼 로드 완료:", {
|
||||
fromColumns: fromCols.length,
|
||||
toColumns: toCols.length,
|
||||
});
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
fromColumns: Array.isArray(fromCols) ? fromCols : [],
|
||||
toColumns: Array.isArray(toCols) ? toCols : [],
|
||||
isLoading: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("❌ 중앙 컬럼 로드 실패:", error);
|
||||
setState((prev) => ({ ...prev, isLoading: false }));
|
||||
toast.error("컬럼 정보를 불러오는데 실패했습니다.");
|
||||
}
|
||||
}, [state.fromConnection, state.toConnection, state.fromTable, state.toTable, state.fromColumns, state.toColumns]),
|
||||
|
||||
// 필드 매핑 생성 (호환성용 - 실제로는 각 액션에서 직접 관리)
|
||||
createMapping: useCallback((fromField: ColumnInfo, toField: ColumnInfo) => {
|
||||
const newMapping: FieldMapping = {
|
||||
id: `${fromField.columnName}_to_${toField.columnName}_${Date.now()}`,
|
||||
fromField,
|
||||
toField,
|
||||
isValid: true,
|
||||
validationMessage: undefined,
|
||||
};
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
fieldMappings: [...prev.fieldMappings, newMapping],
|
||||
}));
|
||||
|
||||
console.log("🔗 전역 매핑 생성 (호환성):", {
|
||||
newMapping,
|
||||
fieldName: `${fromField.columnName} → ${toField.columnName}`,
|
||||
});
|
||||
|
||||
toast.success(`매핑이 생성되었습니다: ${fromField.columnName} → ${toField.columnName}`);
|
||||
}, []),
|
||||
|
||||
// 필드 매핑 업데이트
|
||||
updateMapping: useCallback((mappingId: string, updates: Partial<FieldMapping>) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
fieldMappings: prev.fieldMappings.map((mapping) =>
|
||||
mapping.id === mappingId ? { ...mapping, ...updates } : mapping,
|
||||
),
|
||||
}));
|
||||
}, []),
|
||||
|
||||
// 필드 매핑 삭제 (호환성용 - 실제로는 각 액션에서 직접 관리)
|
||||
deleteMapping: useCallback((mappingId: string) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
fieldMappings: prev.fieldMappings.filter((mapping) => mapping.id !== mappingId),
|
||||
}));
|
||||
|
||||
console.log("🗑️ 전역 매핑 삭제 (호환성):", { mappingId });
|
||||
toast.success("매핑이 삭제되었습니다.");
|
||||
}, []),
|
||||
|
||||
// 매핑 검증
|
||||
validateMappings: useCallback(async (): Promise<ValidationResult> => {
|
||||
setState((prev) => ({ ...prev, isLoading: true }));
|
||||
|
||||
try {
|
||||
// TODO: 실제 검증 로직 구현
|
||||
const result: ValidationResult = {
|
||||
isValid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
validationErrors: result.errors,
|
||||
isLoading: false,
|
||||
}));
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
setState((prev) => ({ ...prev, isLoading: false }));
|
||||
throw error;
|
||||
}
|
||||
}, []),
|
||||
|
||||
// 제어 조건 관리 (전체 실행 조건)
|
||||
addControlCondition: useCallback(() => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
controlConditions: [
|
||||
...prev.controlConditions,
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
type: "condition",
|
||||
field: "",
|
||||
operator: "=",
|
||||
value: "",
|
||||
dataType: "string",
|
||||
},
|
||||
],
|
||||
}));
|
||||
}, []),
|
||||
|
||||
updateControlCondition: useCallback((index: number, condition: any) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
controlConditions: prev.controlConditions.map((cond, i) => (i === index ? { ...cond, ...condition } : cond)),
|
||||
}));
|
||||
}, []),
|
||||
|
||||
deleteControlCondition: useCallback((index: number) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
controlConditions: prev.controlConditions.filter((_, i) => i !== index),
|
||||
}));
|
||||
toast.success("제어 조건이 삭제되었습니다.");
|
||||
}, []),
|
||||
|
||||
// 외부호출 설정 업데이트
|
||||
updateExternalCallConfig: useCallback((config: any) => {
|
||||
console.log("🔄 외부호출 설정 업데이트:", config);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
externalCallConfig: config,
|
||||
}));
|
||||
}, []),
|
||||
|
||||
// 액션 설정 관리
|
||||
setActionType: useCallback((type: "insert" | "update" | "delete" | "upsert") => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionType: type,
|
||||
// INSERT가 아닌 경우 조건 초기화
|
||||
actionConditions: type === "insert" ? [] : prev.actionConditions,
|
||||
}));
|
||||
toast.success(`액션 타입이 ${type.toUpperCase()}로 변경되었습니다.`);
|
||||
}, []),
|
||||
|
||||
addActionCondition: useCallback(() => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionConditions: [
|
||||
...prev.actionConditions,
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
type: "condition",
|
||||
field: "",
|
||||
operator: "=",
|
||||
value: "",
|
||||
dataType: "string",
|
||||
},
|
||||
],
|
||||
}));
|
||||
}, []),
|
||||
|
||||
updateActionCondition: useCallback((index: number, condition: any) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionConditions: prev.actionConditions.map((cond, i) => (i === index ? { ...cond, ...condition } : cond)),
|
||||
}));
|
||||
}, []),
|
||||
|
||||
// 🔧 액션 조건 배열 전체 업데이트 (ActionConditionBuilder용)
|
||||
setActionConditions: useCallback((conditions: any[]) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionConditions: conditions,
|
||||
}));
|
||||
}, []),
|
||||
|
||||
deleteActionCondition: useCallback((index: number) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionConditions: prev.actionConditions.filter((_, i) => i !== index),
|
||||
}));
|
||||
toast.success("조건이 삭제되었습니다.");
|
||||
}, []),
|
||||
|
||||
// 🎯 액션 그룹 관리 (멀티 액션)
|
||||
addActionGroup: useCallback(() => {
|
||||
const newGroupId = `group_${Date.now()}`;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionGroups: [
|
||||
...prev.actionGroups,
|
||||
{
|
||||
id: newGroupId,
|
||||
name: `액션 그룹 ${prev.actionGroups.length + 1}`,
|
||||
logicalOperator: "AND" as const,
|
||||
actions: [
|
||||
{
|
||||
id: `action_${Date.now()}`,
|
||||
name: "액션 1",
|
||||
actionType: "insert" as const,
|
||||
conditions: [],
|
||||
fieldMappings: [],
|
||||
isEnabled: true,
|
||||
},
|
||||
],
|
||||
isEnabled: true,
|
||||
},
|
||||
],
|
||||
}));
|
||||
toast.success("새 액션 그룹이 추가되었습니다.");
|
||||
}, []),
|
||||
|
||||
updateActionGroup: useCallback((groupId: string, updates: Partial<ActionGroup>) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionGroups: prev.actionGroups.map((group) => (group.id === groupId ? { ...group, ...updates } : group)),
|
||||
}));
|
||||
}, []),
|
||||
|
||||
deleteActionGroup: useCallback((groupId: string) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionGroups: prev.actionGroups.filter((group) => group.id !== groupId),
|
||||
}));
|
||||
toast.success("액션 그룹이 삭제되었습니다.");
|
||||
}, []),
|
||||
|
||||
addActionToGroup: useCallback((groupId: string) => {
|
||||
const newActionId = `action_${Date.now()}`;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionGroups: prev.actionGroups.map((group) =>
|
||||
group.id === groupId
|
||||
? {
|
||||
...group,
|
||||
actions: [
|
||||
...group.actions,
|
||||
{
|
||||
id: newActionId,
|
||||
name: `액션 ${group.actions.length + 1}`,
|
||||
actionType: "insert" as const,
|
||||
conditions: [],
|
||||
fieldMappings: [],
|
||||
isEnabled: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
: group,
|
||||
),
|
||||
}));
|
||||
toast.success("새 액션이 추가되었습니다.");
|
||||
}, []),
|
||||
|
||||
updateActionInGroup: useCallback((groupId: string, actionId: string, updates: Partial<SingleAction>) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionGroups: prev.actionGroups.map((group) =>
|
||||
group.id === groupId
|
||||
? {
|
||||
...group,
|
||||
actions: group.actions.map((action) => (action.id === actionId ? { ...action, ...updates } : action)),
|
||||
}
|
||||
: group,
|
||||
),
|
||||
}));
|
||||
}, []),
|
||||
|
||||
deleteActionFromGroup: useCallback((groupId: string, actionId: string) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionGroups: prev.actionGroups.map((group) =>
|
||||
group.id === groupId
|
||||
? {
|
||||
...group,
|
||||
actions: group.actions.filter((action) => action.id !== actionId),
|
||||
}
|
||||
: group,
|
||||
),
|
||||
}));
|
||||
toast.success("액션이 삭제되었습니다.");
|
||||
}, []),
|
||||
|
||||
// 매핑 저장 (직접 저장)
|
||||
saveMappings: useCallback(async () => {
|
||||
// 관계명과 설명이 없으면 저장할 수 없음
|
||||
if (!state.relationshipName?.trim()) {
|
||||
toast.error("관계 이름을 입력해주세요.");
|
||||
actions.goToStep(1); // 첫 번째 단계로 이동
|
||||
return;
|
||||
}
|
||||
|
||||
// 외부호출인 경우 API URL만 확인 (테이블 검증 제외)
|
||||
if (state.connectionType === "external_call") {
|
||||
if (!state.externalCallConfig?.restApiSettings?.apiUrl) {
|
||||
toast.error("API URL을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
// 외부호출은 테이블 정보 검증 건너뛰기
|
||||
}
|
||||
|
||||
// 중복 체크 (수정 모드가 아닌 경우에만)
|
||||
if (!diagramId) {
|
||||
try {
|
||||
const duplicateCheck = await checkRelationshipNameDuplicate(state.relationshipName, diagramId);
|
||||
if (duplicateCheck.isDuplicate) {
|
||||
toast.error(`"${state.relationshipName}" 이름이 이미 사용 중입니다. 다른 이름을 사용해주세요.`);
|
||||
actions.goToStep(1); // 첫 번째 단계로 이동
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("중복 체크 실패:", error);
|
||||
toast.error("관계명 중복 체크 중 오류가 발생했습니다.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, isLoading: true }));
|
||||
|
||||
try {
|
||||
// 실제 저장 로직 구현
|
||||
const saveData = {
|
||||
relationshipName: state.relationshipName,
|
||||
description: state.description,
|
||||
connectionType: state.connectionType,
|
||||
// 외부호출인 경우 테이블 정보는 선택사항
|
||||
fromConnection: state.connectionType === "external_call" ? null : state.fromConnection,
|
||||
toConnection: state.connectionType === "external_call" ? null : state.toConnection,
|
||||
fromTable: state.connectionType === "external_call" ? null : state.fromTable,
|
||||
toTable: state.connectionType === "external_call" ? null : state.toTable,
|
||||
// 🔧 멀티 액션 그룹 데이터 포함
|
||||
actionGroups: state.connectionType === "external_call" ? [] : state.actionGroups,
|
||||
groupsLogicalOperator: state.groupsLogicalOperator,
|
||||
// 외부호출 설정 포함
|
||||
externalCallConfig: state.externalCallConfig,
|
||||
// 기존 호환성을 위한 필드들 (첫 번째 액션 그룹의 첫 번째 액션에서 추출)
|
||||
actionType:
|
||||
state.connectionType === "external_call"
|
||||
? "external_call"
|
||||
: state.actionGroups[0]?.actions[0]?.actionType || state.actionType || "insert",
|
||||
controlConditions: state.connectionType === "external_call" ? [] : state.controlConditions,
|
||||
actionConditions:
|
||||
state.connectionType === "external_call"
|
||||
? []
|
||||
: state.actionGroups[0]?.actions[0]?.conditions || state.actionConditions || [],
|
||||
fieldMappings:
|
||||
state.connectionType === "external_call"
|
||||
? []
|
||||
: state.actionGroups[0]?.actions[0]?.fieldMappings || state.fieldMappings || [],
|
||||
};
|
||||
|
||||
console.log("💾 직접 저장 시작:", { saveData, diagramId, isEdit: !!diagramId });
|
||||
|
||||
// 외부호출인 경우 external-call-configs에 설정 저장
|
||||
if (state.connectionType === "external_call" && state.externalCallConfig) {
|
||||
try {
|
||||
const { ExternalCallConfigAPI } = await import("@/lib/api/externalCallConfig");
|
||||
|
||||
const configData = {
|
||||
config_name: state.relationshipName || "외부호출 설정",
|
||||
call_type: "rest-api",
|
||||
api_type: "generic",
|
||||
config_data: state.externalCallConfig.restApiSettings,
|
||||
description: state.description || "",
|
||||
company_code: "*", // 기본값
|
||||
};
|
||||
|
||||
const configResult = await ExternalCallConfigAPI.createConfig(configData);
|
||||
|
||||
if (!configResult.success) {
|
||||
throw new Error(configResult.error || "외부호출 설정 저장 실패");
|
||||
}
|
||||
|
||||
console.log("✅ 외부호출 설정 저장 완료:", configResult.data);
|
||||
} catch (configError) {
|
||||
console.error("❌ 외부호출 설정 저장 실패:", configError);
|
||||
// 외부호출 설정 저장 실패해도 관계는 저장하도록 함
|
||||
toast.error("외부호출 설정 저장에 실패했지만 관계는 저장되었습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
// 백엔드 API 호출 (수정 모드인 경우 diagramId 전달)
|
||||
const result = await saveDataflowRelationship(saveData, diagramId);
|
||||
|
||||
console.log("✅ 저장 완료:", result);
|
||||
|
||||
setState((prev) => ({ ...prev, isLoading: false }));
|
||||
toast.success(`"${state.relationshipName}" 관계가 성공적으로 저장되었습니다.`);
|
||||
|
||||
// 저장 후 닫기
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("❌ 저장 실패:", error);
|
||||
setState((prev) => ({ ...prev, isLoading: false }));
|
||||
toast.error(error.message || "저장 중 오류가 발생했습니다.");
|
||||
}
|
||||
}, [state, diagramId, onClose]),
|
||||
|
||||
// 테스트 실행
|
||||
testExecution: useCallback(async (): Promise<TestResult> => {
|
||||
setState((prev) => ({ ...prev, isLoading: true }));
|
||||
|
||||
try {
|
||||
// TODO: 실제 테스트 로직 구현
|
||||
const result: TestResult = {
|
||||
success: true,
|
||||
message: "테스트가 성공적으로 완료되었습니다.",
|
||||
affectedRows: 10,
|
||||
executionTime: 250,
|
||||
};
|
||||
|
||||
setState((prev) => ({ ...prev, isLoading: false }));
|
||||
toast.success(result.message);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
setState((prev) => ({ ...prev, isLoading: false }));
|
||||
toast.error("테스트 실행 중 오류가 발생했습니다.");
|
||||
throw error;
|
||||
}
|
||||
}, []),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border bg-white shadow-sm">
|
||||
{/* 상단 네비게이션 */}
|
||||
{showBackButton && (
|
||||
<div className="flex-shrink-0 border-b bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="outline" onClick={onClose} className="flex items-center gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
목록으로
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">🔗 데이터 연결 설정</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{state.connectionType === "data_save" ? "데이터 저장" : "외부 호출"} 연결 설정
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 메인 컨텐츠 - 좌우 분할 레이아웃 */}
|
||||
<div className="flex h-[calc(100vh-200px)] min-h-[700px] overflow-hidden">
|
||||
{/* 좌측 패널 (30%) - 항상 표시 */}
|
||||
<div className="flex w-[30%] flex-col border-r bg-white">
|
||||
<LeftPanel state={state} actions={actions} />
|
||||
</div>
|
||||
|
||||
{/* 우측 패널 (70%) */}
|
||||
<div className="flex w-[70%] flex-col bg-gray-50">
|
||||
<RightPanel key={state.connectionType} state={state} actions={actions} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataConnectionDesigner;
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Save, Eye, TestTube, Copy, RotateCcw, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// 타입 import
|
||||
import { DataConnectionState, DataConnectionActions } from "../types/redesigned";
|
||||
|
||||
interface ActionButtonsProps {
|
||||
state: DataConnectionState;
|
||||
actions: DataConnectionActions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎯 액션 버튼들
|
||||
* - 저장, 미리보기, 테스트 실행
|
||||
* - 설정 복사, 초기화
|
||||
*/
|
||||
const ActionButtons: React.FC<ActionButtonsProps> = ({ state, actions }) => {
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await actions.saveMappings();
|
||||
} catch (error) {
|
||||
console.error("저장 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreview = () => {
|
||||
// TODO: 미리보기 모달 열기
|
||||
toast.info("미리보기 기능은 곧 구현될 예정입니다.");
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
try {
|
||||
await actions.testExecution();
|
||||
} catch (error) {
|
||||
console.error("테스트 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopySettings = () => {
|
||||
// TODO: 설정 복사 기능
|
||||
toast.info("설정 복사 기능은 곧 구현될 예정입니다.");
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (confirm("모든 설정을 초기화하시겠습니까?")) {
|
||||
// TODO: 상태 초기화
|
||||
toast.success("설정이 초기화되었습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const canSave = state.fieldMappings.length > 0 && !state.isLoading;
|
||||
const canTest = state.fieldMappings.length > 0 && !state.isLoading;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 주요 액션 */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button onClick={handleSave} disabled={!canSave} className="h-8 text-xs">
|
||||
{state.isLoading ? <Loader2 className="mr-1 h-3 w-3 animate-spin" /> : <Save className="mr-1 h-3 w-3" />}
|
||||
저장
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" onClick={handlePreview} className="h-8 text-xs">
|
||||
<Eye className="mr-1 h-3 w-3" />
|
||||
미리보기
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 테스트 실행 */}
|
||||
<Button variant="secondary" onClick={handleTest} disabled={!canTest} className="h-8 w-full text-xs">
|
||||
{state.isLoading ? <Loader2 className="mr-1 h-3 w-3 animate-spin" /> : <TestTube className="mr-1 h-3 w-3" />}
|
||||
테스트 실행
|
||||
</Button>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 보조 액션 */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={handleCopySettings} className="h-7 text-xs">
|
||||
<Copy className="mr-1 h-3 w-3" />
|
||||
설정 복사
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
className="text-destructive hover:text-destructive h-7 text-xs"
|
||||
>
|
||||
<RotateCcw className="mr-1 h-3 w-3" />
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 상태 정보 */}
|
||||
{state.fieldMappings.length > 0 && (
|
||||
<div className="text-muted-foreground border-t pt-2 text-center text-xs">
|
||||
{state.fieldMappings.length}개 매핑 설정됨
|
||||
{state.validationErrors.length > 0 && (
|
||||
<span className="ml-1 text-orange-600">({state.validationErrors.length}개 오류)</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionButtons;
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Settings, CheckCircle, AlertCircle } from "lucide-react";
|
||||
|
||||
// 타입 import
|
||||
import { DataConnectionState } from "../types/redesigned";
|
||||
|
||||
interface ActionSummaryPanelProps {
|
||||
state: DataConnectionState;
|
||||
}
|
||||
|
||||
/**
|
||||
* 📋 액션 설정 요약 패널
|
||||
* - 액션 타입 표시
|
||||
* - 실행 조건 요약
|
||||
* - 설정 완료 상태
|
||||
*/
|
||||
const ActionSummaryPanel: React.FC<ActionSummaryPanelProps> = ({ state }) => {
|
||||
const { actionType, actionConditions } = state;
|
||||
|
||||
const isConfigured = actionType && (actionType === "insert" || actionConditions.length > 0);
|
||||
|
||||
const actionTypeLabels = {
|
||||
insert: "INSERT",
|
||||
update: "UPDATE",
|
||||
delete: "DELETE",
|
||||
upsert: "UPSERT",
|
||||
};
|
||||
|
||||
const actionTypeDescriptions = {
|
||||
insert: "새 데이터 삽입",
|
||||
update: "기존 데이터 수정",
|
||||
delete: "데이터 삭제",
|
||||
upsert: "있으면 수정, 없으면 삽입",
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Settings className="h-4 w-4" />
|
||||
액션 설정
|
||||
{isConfigured ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<AlertCircle className="h-4 w-4 text-orange-500" />
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3 px-4 pt-0 pb-4">
|
||||
{/* 액션 타입 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium">액션 타입</span>
|
||||
{actionType ? (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{actionTypeLabels[actionType]}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">미설정</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{actionType && <p className="text-muted-foreground text-xs">{actionTypeDescriptions[actionType]}</p>}
|
||||
</div>
|
||||
|
||||
{/* 실행 조건 */}
|
||||
{actionType && actionType !== "insert" && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium">실행 조건</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{actionConditions.length > 0 ? `${actionConditions.length}개 조건` : "조건 없음"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{actionConditions.length === 0 && (
|
||||
<p className="text-xs text-orange-600">⚠️ {actionType.toUpperCase()} 액션은 실행 조건이 필요합니다</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* INSERT 액션 안내 */}
|
||||
{actionType === "insert" && (
|
||||
<div className="rounded-md border border-green-200 bg-green-50 p-2">
|
||||
<p className="text-xs text-green-700">✅ INSERT 액션은 별도 조건 없이 모든 매핑된 데이터를 삽입합니다</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 설정 상태 */}
|
||||
<div className="border-t pt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{isConfigured ? (
|
||||
<>
|
||||
<CheckCircle className="h-3 w-3 text-green-600" />
|
||||
<span className="text-xs font-medium text-green-600">설정 완료</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="h-3 w-3 text-orange-500" />
|
||||
<span className="text-xs font-medium text-orange-600">설정 필요</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionSummaryPanel;
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { ChevronDown, Settings } from "lucide-react";
|
||||
|
||||
interface AdvancedSettingsProps {
|
||||
connectionType: "data_save" | "external_call";
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚙️ 고급 설정 패널
|
||||
* - 트랜잭션 설정
|
||||
* - 배치 처리 설정
|
||||
* - 로깅 설정
|
||||
*/
|
||||
const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({ connectionType }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [settings, setSettings] = useState({
|
||||
batchSize: 1000,
|
||||
timeout: 30,
|
||||
retryCount: 3,
|
||||
logLevel: "INFO",
|
||||
});
|
||||
|
||||
const handleSettingChange = (key: string, value: string | number) => {
|
||||
setSettings((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" className="h-auto w-full justify-between p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
<span className="font-medium">고급 설정</span>
|
||||
</div>
|
||||
<ChevronDown className={`h-4 w-4 transition-transform ${isOpen ? "rotate-180" : ""}`} />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<CardContent className="space-y-3 px-4 pt-0 pb-3">
|
||||
{connectionType === "data_save" && (
|
||||
<>
|
||||
{/* 트랜잭션 설정 - 컴팩트 */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-medium text-gray-600">🔄 트랜잭션 설정</h4>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<Label htmlFor="batchSize" className="text-xs text-gray-500">
|
||||
배치
|
||||
</Label>
|
||||
<Input
|
||||
id="batchSize"
|
||||
type="number"
|
||||
value={settings.batchSize}
|
||||
onChange={(e) => handleSettingChange("batchSize", parseInt(e.target.value))}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="timeout" className="text-xs text-gray-500">
|
||||
타임아웃
|
||||
</Label>
|
||||
<Input
|
||||
id="timeout"
|
||||
type="number"
|
||||
value={settings.timeout}
|
||||
onChange={(e) => handleSettingChange("timeout", parseInt(e.target.value))}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="retryCount" className="text-xs text-gray-500">
|
||||
재시도
|
||||
</Label>
|
||||
<Input
|
||||
id="retryCount"
|
||||
type="number"
|
||||
value={settings.retryCount}
|
||||
onChange={(e) => handleSettingChange("retryCount", parseInt(e.target.value))}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{connectionType === "external_call" && (
|
||||
<>
|
||||
{/* API 호출 설정 - 컴팩트 */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-medium text-gray-600">🌐 API 호출 설정</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label htmlFor="timeout" className="text-xs text-gray-500">
|
||||
타임아웃 (초)
|
||||
</Label>
|
||||
<Input
|
||||
id="timeout"
|
||||
type="number"
|
||||
value={settings.timeout}
|
||||
onChange={(e) => handleSettingChange("timeout", parseInt(e.target.value))}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="retryCount" className="text-xs text-gray-500">
|
||||
재시도 횟수
|
||||
</Label>
|
||||
<Input
|
||||
id="retryCount"
|
||||
type="number"
|
||||
value={settings.retryCount}
|
||||
onChange={(e) => handleSettingChange("retryCount", parseInt(e.target.value))}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 로깅 설정 - 컴팩트 */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-medium text-gray-600">📝 로깅 설정</h4>
|
||||
<div>
|
||||
<Select value={settings.logLevel} onValueChange={(value) => handleSettingChange("logLevel", value)}>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="로그 레벨 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="DEBUG">DEBUG</SelectItem>
|
||||
<SelectItem value="INFO">INFO</SelectItem>
|
||||
<SelectItem value="WARN">WARN</SelectItem>
|
||||
<SelectItem value="ERROR">ERROR</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 설정 요약 - 더 컴팩트 */}
|
||||
<div className="border-t pt-2">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
배치: {settings.batchSize.toLocaleString()} | 타임아웃: {settings.timeout}s | 재시도:{" "}
|
||||
{settings.retryCount} | 로그: {settings.logLevel}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdvancedSettings;
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Database, Globe } from "lucide-react";
|
||||
|
||||
// 타입 import
|
||||
import { ConnectionType, ConnectionTypeSelectorProps } from "../types/redesigned";
|
||||
|
||||
/**
|
||||
* 🔘 연결 타입 선택 컴포넌트
|
||||
* - 데이터 저장 (INSERT/UPDATE/DELETE)
|
||||
* - 외부 호출 (API/Webhook)
|
||||
*/
|
||||
const ConnectionTypeSelector: React.FC<ConnectionTypeSelectorProps> = ({ selectedType, onTypeChange }) => {
|
||||
const connectionTypes: ConnectionType[] = [
|
||||
{
|
||||
id: "data_save",
|
||||
label: "데이터 저장",
|
||||
description: "INSERT/UPDATE/DELETE 작업",
|
||||
icon: <Database className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
id: "external_call",
|
||||
label: "외부 호출",
|
||||
description: "API/Webhook 호출",
|
||||
icon: <Globe className="h-4 w-4" />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<RadioGroup
|
||||
value={selectedType}
|
||||
onValueChange={(value) => {
|
||||
console.log("🔘 [ConnectionTypeSelector] 라디오 버튼 변경:", value);
|
||||
onTypeChange(value as "data_save" | "external_call");
|
||||
}}
|
||||
className="space-y-3"
|
||||
>
|
||||
{connectionTypes.map((type) => (
|
||||
<div key={type.id} className="flex items-start space-x-3">
|
||||
<RadioGroupItem value={type.id} id={type.id} className="mt-1" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<Label htmlFor={type.id} className="flex cursor-pointer items-center gap-2 font-medium">
|
||||
{type.icon}
|
||||
{type.label}
|
||||
</Label>
|
||||
<p className="text-muted-foreground mt-1 text-xs">{type.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectionTypeSelector;
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
// 타입 import
|
||||
import { LeftPanelProps } from "../types/redesigned";
|
||||
|
||||
// 컴포넌트 import
|
||||
import ConnectionTypeSelector from "./ConnectionTypeSelector";
|
||||
import MappingDetailList from "./MappingDetailList";
|
||||
import ActionSummaryPanel from "./ActionSummaryPanel";
|
||||
|
||||
/**
|
||||
* 📋 좌측 패널 (30% 너비)
|
||||
* - 연결 타입 선택
|
||||
* - 매핑 정보 모니터링
|
||||
* - 상세 설정 목록
|
||||
* - 액션 버튼들
|
||||
*/
|
||||
const LeftPanel: React.FC<LeftPanelProps> = ({ state, actions }) => {
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<ScrollArea className="flex-1 p-3 pb-0">
|
||||
<div className="space-y-3 pb-3">
|
||||
{/* 0단계: 연결 타입 선택 */}
|
||||
<div>
|
||||
<h3 className="text-muted-foreground mb-2 text-sm font-medium">0단계: 연결 타입</h3>
|
||||
<ConnectionTypeSelector selectedType={state.connectionType} onTypeChange={actions.setConnectionType} />
|
||||
</div>
|
||||
|
||||
{/* 외부호출이 아닐 때만 매핑과 액션 설정 표시 */}
|
||||
{state.connectionType !== "external_call" && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
{/* 매핑 상세 목록 */}
|
||||
{(() => {
|
||||
// 액션 그룹에서 모든 매핑 수집
|
||||
const allMappings = state.actionGroups.flatMap((group) =>
|
||||
group.actions.flatMap((action) => action.fieldMappings || []),
|
||||
);
|
||||
|
||||
// 기존 fieldMappings와 병합 (중복 제거)
|
||||
const combinedMappings = [...state.fieldMappings, ...allMappings];
|
||||
const uniqueMappings = combinedMappings.filter(
|
||||
(mapping, index, arr) => arr.findIndex((m) => m.id === mapping.id) === index,
|
||||
);
|
||||
|
||||
console.log("🔍 LeftPanel - 매핑 데이터 수집:", {
|
||||
stateFieldMappings: state.fieldMappings,
|
||||
actionGroupMappings: allMappings,
|
||||
combinedMappings: uniqueMappings,
|
||||
});
|
||||
|
||||
return (
|
||||
uniqueMappings.length > 0 && (
|
||||
<>
|
||||
<div>
|
||||
<h3 className="text-muted-foreground mb-2 text-sm font-medium">매핑 상세 목록</h3>
|
||||
<MappingDetailList
|
||||
mappings={uniqueMappings}
|
||||
selectedMapping={state.selectedMapping}
|
||||
onSelectMapping={(mappingId) => {
|
||||
// TODO: 선택된 매핑 상태 업데이트
|
||||
}}
|
||||
onUpdateMapping={actions.updateMapping}
|
||||
onDeleteMapping={actions.deleteMapping}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
</>
|
||||
)
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 액션 설정 요약 */}
|
||||
<div>
|
||||
<h3 className="text-muted-foreground mb-2 text-sm font-medium">액션 설정</h3>
|
||||
<ActionSummaryPanel state={state} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 외부호출일 때는 간단한 설명만 표시 */}
|
||||
{state.connectionType === "external_call" && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="rounded-md bg-blue-50 p-3">
|
||||
<h3 className="mb-1 text-sm font-medium text-blue-800">외부 호출 모드</h3>
|
||||
<p className="text-xs text-blue-600">우측 패널에서 REST API 설정을 구성하세요.</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LeftPanel;
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { CheckCircle, AlertTriangle, Edit, Trash2 } from "lucide-react";
|
||||
|
||||
// 타입 import
|
||||
import { MappingDetailListProps } from "../types/redesigned";
|
||||
|
||||
/**
|
||||
* 📝 매핑 상세 목록
|
||||
* - 각 매핑별 상세 정보
|
||||
* - 타입 변환 정보
|
||||
* - 개별 수정/삭제 기능
|
||||
*/
|
||||
const MappingDetailList: React.FC<MappingDetailListProps> = ({
|
||||
mappings,
|
||||
selectedMapping,
|
||||
onSelectMapping,
|
||||
onUpdateMapping,
|
||||
onDeleteMapping,
|
||||
}) => {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<ScrollArea className="h-[300px]">
|
||||
<div className="space-y-3 p-4">
|
||||
{(() => {
|
||||
console.log("🔍 MappingDetailList - 전체 매핑 데이터:", mappings);
|
||||
|
||||
const validMappings = mappings.filter((mapping) => {
|
||||
const isValid =
|
||||
mapping.fromField && mapping.toField && mapping.fromField.columnName && mapping.toField.columnName;
|
||||
console.log(`🔍 매핑 유효성 검사:`, {
|
||||
mapping,
|
||||
isValid,
|
||||
hasFromField: !!mapping.fromField,
|
||||
hasToField: !!mapping.toField,
|
||||
fromColumnName: mapping.fromField?.columnName,
|
||||
toColumnName: mapping.toField?.columnName,
|
||||
});
|
||||
return isValid;
|
||||
});
|
||||
|
||||
if (validMappings.length === 0) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex h-[200px] items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-sm">매핑된 필드가 없습니다</p>
|
||||
<p className="text-xs">INSERT 액션이 있을 때 필드 매핑을 설정하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return validMappings.map((mapping, index) => (
|
||||
<div
|
||||
key={mapping.id}
|
||||
className={`cursor-pointer rounded-lg border p-3 transition-colors ${
|
||||
selectedMapping === mapping.id ? "border-primary bg-primary/5" : "border-border hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => onSelectMapping(mapping.id)}
|
||||
>
|
||||
{/* 매핑 헤더 */}
|
||||
<div className="mb-2 flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="truncate text-sm font-medium">
|
||||
{index + 1}. {mapping.fromField?.displayName || mapping.fromField?.columnName || "Unknown"} →{" "}
|
||||
{mapping.toField?.displayName || mapping.toField?.columnName || "Unknown"}
|
||||
</h4>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
{mapping.isValid ? (
|
||||
<Badge variant="outline" className="text-xs text-green-600">
|
||||
<CheckCircle className="mr-1 h-3 w-3" />
|
||||
{mapping.fromField?.webType || "Unknown"} → {mapping.toField?.webType || "Unknown"}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs text-orange-600">
|
||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||
타입 불일치
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-2 flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// TODO: 매핑 편집 모달 열기
|
||||
}}
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteMapping(mapping.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 변환 규칙 */}
|
||||
{mapping.transformRule && (
|
||||
<div className="text-muted-foreground text-xs">변환: {mapping.transformRule}</div>
|
||||
)}
|
||||
|
||||
{/* 검증 메시지 */}
|
||||
{mapping.validationMessage && (
|
||||
<div className="mt-1 text-xs text-orange-600">{mapping.validationMessage}</div>
|
||||
)}
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default MappingDetailList;
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CheckCircle, AlertTriangle, XCircle, Info } from "lucide-react";
|
||||
|
||||
// 타입 import
|
||||
import { MappingInfoPanelProps } from "../types/redesigned";
|
||||
|
||||
/**
|
||||
* 📊 매핑 정보 패널
|
||||
* - 실시간 매핑 통계
|
||||
* - 검증 상태 표시
|
||||
* - 예상 처리량 정보
|
||||
*/
|
||||
const MappingInfoPanel: React.FC<MappingInfoPanelProps> = ({ stats, validationErrors }) => {
|
||||
const errorCount = validationErrors.filter((e) => e.type === "error").length;
|
||||
const warningCount = validationErrors.filter((e) => e.type === "warning").length;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="space-y-3 p-4">
|
||||
{/* 매핑 통계 */}
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">총 매핑:</span>
|
||||
<Badge variant="outline">{stats.totalMappings}개</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">유효한 매핑:</span>
|
||||
<Badge variant="outline" className="text-green-600">
|
||||
<CheckCircle className="mr-1 h-3 w-3" />
|
||||
{stats.validMappings}개
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{stats.invalidMappings > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">타입 불일치:</span>
|
||||
<Badge variant="outline" className="text-orange-600">
|
||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||
{stats.invalidMappings}개
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats.missingRequiredFields > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">필수 필드 누락:</span>
|
||||
<Badge variant="outline" className="text-red-600">
|
||||
<XCircle className="mr-1 h-3 w-3" />
|
||||
{stats.missingRequiredFields}개
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 액션 정보 */}
|
||||
{stats.totalMappings > 0 && (
|
||||
<div className="space-y-2 border-t pt-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">액션:</span>
|
||||
<Badge variant="secondary">{stats.actionType}</Badge>
|
||||
</div>
|
||||
|
||||
{stats.estimatedRows > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">예상 처리량:</span>
|
||||
<span className="font-medium">~{stats.estimatedRows.toLocaleString()} rows</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 검증 오류 요약 */}
|
||||
{validationErrors.length > 0 && (
|
||||
<div className="border-t pt-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Info className="h-4 w-4 text-blue-500" />
|
||||
<span className="text-muted-foreground">검증 결과:</span>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
{errorCount > 0 && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
오류 {errorCount}개
|
||||
</Badge>
|
||||
)}
|
||||
{warningCount > 0 && (
|
||||
<Badge variant="outline" className="ml-1 text-xs text-orange-600">
|
||||
경고 {warningCount}개
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 빈 상태 */}
|
||||
{stats.totalMappings === 0 && (
|
||||
<div className="text-muted-foreground py-4 text-center text-sm">
|
||||
<Database className="mx-auto mb-2 h-8 w-8 opacity-50" />
|
||||
<p>아직 매핑된 필드가 없습니다.</p>
|
||||
<p className="mt-1 text-xs">우측에서 연결을 설정해주세요.</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// Database 아이콘 import 추가
|
||||
import { Database } from "lucide-react";
|
||||
|
||||
export default MappingInfoPanel;
|
||||
|
|
@ -0,0 +1,787 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Plus, Trash2, Settings } from "lucide-react";
|
||||
|
||||
// 타입 import
|
||||
import { ColumnInfo } from "@/lib/types/multiConnection";
|
||||
import { getCodesForColumn, CodeItem } from "@/lib/api/codeManagement";
|
||||
|
||||
interface ActionCondition {
|
||||
id: string;
|
||||
field: string;
|
||||
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE" | "IN" | "IS NULL" | "IS NOT NULL";
|
||||
value: string;
|
||||
valueType?: "static" | "field" | "calculated"; // 값 타입 (고정값, 필드값, 계산값)
|
||||
logicalOperator?: "AND" | "OR";
|
||||
}
|
||||
|
||||
interface FieldValueMapping {
|
||||
id: string;
|
||||
targetField: string;
|
||||
valueType: "static" | "source_field" | "code" | "calculated";
|
||||
value: string;
|
||||
sourceField?: string;
|
||||
codeCategory?: string;
|
||||
}
|
||||
|
||||
interface ActionConditionBuilderProps {
|
||||
actionType: "insert" | "update" | "delete" | "upsert";
|
||||
fromColumns: ColumnInfo[];
|
||||
toColumns: ColumnInfo[];
|
||||
conditions: ActionCondition[];
|
||||
fieldMappings: FieldValueMapping[];
|
||||
columnMappings?: any[]; // 컬럼 매핑 정보 (이미 매핑된 필드들)
|
||||
onConditionsChange: (conditions: ActionCondition[]) => void;
|
||||
onFieldMappingsChange: (mappings: FieldValueMapping[]) => void;
|
||||
showFieldMappings?: boolean; // 필드 매핑 섹션 표시 여부
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎯 액션 조건 빌더
|
||||
* - 실행 조건 설정 (WHERE 절)
|
||||
* - 필드 값 매핑 설정 (SET 절)
|
||||
* - 코드 타입 필드 지원
|
||||
*/
|
||||
const ActionConditionBuilder: React.FC<ActionConditionBuilderProps> = ({
|
||||
actionType,
|
||||
fromColumns,
|
||||
toColumns,
|
||||
conditions,
|
||||
fieldMappings,
|
||||
columnMappings = [],
|
||||
onConditionsChange,
|
||||
onFieldMappingsChange,
|
||||
showFieldMappings = true,
|
||||
}) => {
|
||||
const [availableCodes, setAvailableCodes] = useState<Record<string, CodeItem[]>>({});
|
||||
|
||||
// 컬럼 매핑인지 필드값 매핑인지 구분하는 함수
|
||||
const isColumnMapping = (mapping: any): boolean => {
|
||||
return mapping.fromField && mapping.toField && mapping.fromField.columnName && mapping.toField.columnName;
|
||||
};
|
||||
|
||||
// 이미 컬럼 매핑된 필드들을 가져오는 함수
|
||||
const getMappedFieldNames = (): string[] => {
|
||||
if (!columnMappings || columnMappings.length === 0) return [];
|
||||
return columnMappings.filter((mapping) => isColumnMapping(mapping)).map((mapping) => mapping.toField.columnName);
|
||||
};
|
||||
|
||||
// 매핑되지 않은 필드들만 필터링하는 함수
|
||||
const getUnmappedToColumns = (): ColumnInfo[] => {
|
||||
const mappedFieldNames = getMappedFieldNames();
|
||||
return toColumns.filter((column) => !mappedFieldNames.includes(column.columnName));
|
||||
};
|
||||
|
||||
// 필드값 설정에서 사용 가능한 필드들 (이미 필드값 설정에서 사용된 필드 제외)
|
||||
const getAvailableFieldsForMapping = (currentIndex?: number): ColumnInfo[] => {
|
||||
const unmappedColumns = getUnmappedToColumns();
|
||||
const usedFieldNames = fieldMappings
|
||||
.filter((_, index) => index !== currentIndex) // 현재 편집 중인 항목 제외
|
||||
.map((mapping) => mapping.targetField)
|
||||
.filter((field) => field); // 빈 값 제외
|
||||
|
||||
return unmappedColumns.filter((column) => !usedFieldNames.includes(column.columnName));
|
||||
};
|
||||
|
||||
const operators = [
|
||||
{ value: "=", label: "같음 (=)" },
|
||||
{ value: "!=", label: "다름 (!=)" },
|
||||
{ value: ">", label: "큼 (>)" },
|
||||
{ value: "<", label: "작음 (<)" },
|
||||
{ value: ">=", label: "크거나 같음 (>=)" },
|
||||
{ value: "<=", label: "작거나 같음 (<=)" },
|
||||
{ value: "LIKE", label: "포함 (LIKE)" },
|
||||
{ value: "IN", label: "목록 중 하나 (IN)" },
|
||||
{ value: "IS NULL", label: "빈 값 (IS NULL)" },
|
||||
{ value: "IS NOT NULL", label: "값 있음 (IS NOT NULL)" },
|
||||
];
|
||||
|
||||
// 코드 정보 로드
|
||||
useEffect(() => {
|
||||
const loadCodes = async () => {
|
||||
const codeFields = [...fromColumns, ...toColumns].filter((col) => {
|
||||
// 메인 DB(connectionId === 0 또는 undefined)인 경우: column_labels의 input_type이 'code'인 경우만
|
||||
if (col.connectionId === 0 || col.connectionId === undefined) {
|
||||
return col.inputType === "code";
|
||||
}
|
||||
// 외부 DB인 경우: 코드 타입 없음
|
||||
return false;
|
||||
});
|
||||
|
||||
console.log(
|
||||
"🔍 ActionConditionBuilder - 모든 컬럼 정보:",
|
||||
[...fromColumns, ...toColumns].map((col) => ({
|
||||
columnName: col.columnName,
|
||||
connectionId: col.connectionId,
|
||||
inputType: col.inputType,
|
||||
webType: col.webType,
|
||||
})),
|
||||
);
|
||||
console.log("🔍 ActionConditionBuilder - 코드 타입 컬럼들:", codeFields);
|
||||
|
||||
for (const field of codeFields) {
|
||||
try {
|
||||
const codes = await getCodesForColumn(field.columnName, field.webType, field.codeCategory);
|
||||
|
||||
if (codes.length > 0) {
|
||||
setAvailableCodes((prev) => ({
|
||||
...prev,
|
||||
[field.columnName]: codes,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`코드 로드 실패: ${field.columnName}`, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (fromColumns.length > 0 || toColumns.length > 0) {
|
||||
loadCodes();
|
||||
}
|
||||
}, [fromColumns, toColumns]);
|
||||
|
||||
// 컬럼 매핑이 변경될 때 필드값 설정에서 이미 매핑된 필드들 제거
|
||||
useEffect(() => {
|
||||
const mappedFieldNames = getMappedFieldNames();
|
||||
if (mappedFieldNames.length > 0) {
|
||||
const updatedFieldMappings = fieldMappings.filter((mapping) => !mappedFieldNames.includes(mapping.targetField));
|
||||
|
||||
// 변경된 내용이 있으면 업데이트
|
||||
if (updatedFieldMappings.length !== fieldMappings.length) {
|
||||
console.log("🧹 매핑된 필드들을 필드값 설정에서 제거:", {
|
||||
removed: fieldMappings.filter((mapping) => mappedFieldNames.includes(mapping.targetField)),
|
||||
remaining: updatedFieldMappings,
|
||||
});
|
||||
onFieldMappingsChange(updatedFieldMappings);
|
||||
}
|
||||
}
|
||||
}, [columnMappings]); // columnMappings 변경 시에만 실행
|
||||
|
||||
// 조건 추가
|
||||
const addCondition = () => {
|
||||
const newCondition: ActionCondition = {
|
||||
id: Date.now().toString(),
|
||||
field: "",
|
||||
operator: "=",
|
||||
value: "",
|
||||
...(conditions.length > 0 && { logicalOperator: "AND" }),
|
||||
};
|
||||
|
||||
onConditionsChange([...conditions, newCondition]);
|
||||
};
|
||||
|
||||
// 조건 업데이트
|
||||
const updateCondition = (index: number, updates: Partial<ActionCondition>) => {
|
||||
const updatedConditions = conditions.map((condition, i) =>
|
||||
i === index ? { ...condition, ...updates } : condition,
|
||||
);
|
||||
onConditionsChange(updatedConditions);
|
||||
};
|
||||
|
||||
// 조건 삭제
|
||||
const deleteCondition = (index: number) => {
|
||||
const updatedConditions = conditions.filter((_, i) => i !== index);
|
||||
onConditionsChange(updatedConditions);
|
||||
};
|
||||
|
||||
// 필드 매핑 추가
|
||||
const addFieldMapping = () => {
|
||||
// 임시로 검증을 완화 - 매핑되지 않은 필드가 있으면 추가 허용
|
||||
const unmappedColumns = getUnmappedToColumns();
|
||||
console.log("🔍 필드 추가 시도:", {
|
||||
unmappedColumns,
|
||||
unmappedColumnsCount: unmappedColumns.length,
|
||||
fieldMappings,
|
||||
columnMappings,
|
||||
});
|
||||
|
||||
if (unmappedColumns.length === 0) {
|
||||
console.warn("매핑되지 않은 필드가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const newMapping: FieldValueMapping = {
|
||||
id: Date.now().toString(),
|
||||
targetField: "",
|
||||
valueType: "static",
|
||||
value: "",
|
||||
};
|
||||
|
||||
console.log("✅ 새 필드 매핑 추가:", newMapping);
|
||||
onFieldMappingsChange([...fieldMappings, newMapping]);
|
||||
};
|
||||
|
||||
// 필드 매핑 업데이트
|
||||
const updateFieldMapping = (index: number, updates: Partial<FieldValueMapping>) => {
|
||||
const updatedMappings = fieldMappings.map((mapping, i) => (i === index ? { ...mapping, ...updates } : mapping));
|
||||
onFieldMappingsChange(updatedMappings);
|
||||
};
|
||||
|
||||
// 필드 매핑 삭제
|
||||
const deleteFieldMapping = (index: number) => {
|
||||
const updatedMappings = fieldMappings.filter((_, i) => i !== index);
|
||||
onFieldMappingsChange(updatedMappings);
|
||||
};
|
||||
|
||||
// 필드의 값 입력 컴포넌트 렌더링
|
||||
const renderValueInput = (mapping: FieldValueMapping, index: number, targetColumn?: ColumnInfo) => {
|
||||
if (
|
||||
mapping.valueType === "code" &&
|
||||
(targetColumn?.connectionId === 0 || targetColumn?.connectionId === undefined) &&
|
||||
targetColumn?.inputType === "code"
|
||||
) {
|
||||
const codes = availableCodes[targetColumn.columnName] || [];
|
||||
|
||||
return (
|
||||
<Select value={mapping.value} onValueChange={(value) => updateFieldMapping(index, { value })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="코드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{codes.map((code) => (
|
||||
<SelectItem key={code.code} value={code.code}>
|
||||
{code.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
if (mapping.valueType === "source_field") {
|
||||
return (
|
||||
<Select
|
||||
value={mapping.sourceField || ""}
|
||||
onValueChange={(value) => updateFieldMapping(index, { sourceField: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="소스 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* FROM 테이블 필드들 */}
|
||||
{fromColumns.length > 0 && (
|
||||
<>
|
||||
<div className="text-muted-foreground px-2 py-1 text-xs font-medium">FROM 테이블</div>
|
||||
{fromColumns.map((column) => (
|
||||
<SelectItem key={`from_${column.columnName}`} value={`from.${column.columnName}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-blue-600">📤</span>
|
||||
<span>{column.displayName || column.columnName}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{column.webType || column.dataType}
|
||||
</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* TO 테이블 필드들 */}
|
||||
{toColumns.length > 0 && (
|
||||
<>
|
||||
<div className="text-muted-foreground px-2 py-1 text-xs font-medium">TO 테이블</div>
|
||||
{toColumns.map((column) => (
|
||||
<SelectItem key={`to_${column.columnName}`} value={`to.${column.columnName}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-green-600">📥</span>
|
||||
<span>{column.displayName || column.columnName}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{column.webType || column.dataType}
|
||||
</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
// 날짜 타입에 대한 특별 처리
|
||||
if (
|
||||
targetColumn?.webType === "date" ||
|
||||
targetColumn?.webType === "datetime" ||
|
||||
targetColumn?.dataType?.toLowerCase().includes("date")
|
||||
) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* 날짜 타입 선택 */}
|
||||
<Select
|
||||
value={mapping.value?.startsWith("#") ? mapping.value : "#custom"}
|
||||
onValueChange={(value) => {
|
||||
if (value === "#custom") {
|
||||
updateFieldMapping(index, { value: "" });
|
||||
} else {
|
||||
updateFieldMapping(index, { value });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="날짜 타입 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="#NOW">🕐 현재 시간 (NOW)</SelectItem>
|
||||
<SelectItem value="#TODAY">📅 오늘 날짜 (TODAY)</SelectItem>
|
||||
<SelectItem value="#YESTERDAY">📅 어제 날짜</SelectItem>
|
||||
<SelectItem value="#TOMORROW">📅 내일 날짜</SelectItem>
|
||||
<SelectItem value="#WEEK_START">📅 이번 주 시작일</SelectItem>
|
||||
<SelectItem value="#MONTH_START">📅 이번 달 시작일</SelectItem>
|
||||
<SelectItem value="#YEAR_START">📅 올해 시작일</SelectItem>
|
||||
<SelectItem value="#custom">✏️ 직접 입력</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 직접 입력이 선택된 경우 */}
|
||||
{(!mapping.value?.startsWith("#") || mapping.value === "#custom") && (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
type={targetColumn?.webType === "datetime" ? "datetime-local" : "date"}
|
||||
placeholder="날짜 입력"
|
||||
value={mapping.value?.startsWith("#") ? "" : mapping.value}
|
||||
onChange={(e) => updateFieldMapping(index, { value: e.target.value })}
|
||||
/>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
상대적 날짜: +7D (7일 후), -30D (30일 전), +1M (1개월 후), +1Y (1년 후)
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 선택된 날짜 타입에 대한 설명 */}
|
||||
{mapping.value?.startsWith("#") && mapping.value !== "#custom" && (
|
||||
<div className="text-muted-foreground rounded bg-blue-50 p-2 text-xs">
|
||||
{mapping.value === "#NOW" && "⏰ 현재 날짜와 시간이 저장됩니다"}
|
||||
{mapping.value === "#TODAY" && "📅 현재 날짜 (00:00:00)가 저장됩니다"}
|
||||
{mapping.value === "#YESTERDAY" && "📅 어제 날짜가 저장됩니다"}
|
||||
{mapping.value === "#TOMORROW" && "📅 내일 날짜가 저장됩니다"}
|
||||
{mapping.value === "#WEEK_START" && "📅 이번 주 월요일이 저장됩니다"}
|
||||
{mapping.value === "#MONTH_START" && "📅 이번 달 1일이 저장됩니다"}
|
||||
{mapping.value === "#YEAR_START" && "📅 올해 1월 1일이 저장됩니다"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 숫자 타입에 대한 특별 처리
|
||||
if (
|
||||
targetColumn?.webType === "number" ||
|
||||
targetColumn?.webType === "decimal" ||
|
||||
targetColumn?.dataType?.toLowerCase().includes("int") ||
|
||||
targetColumn?.dataType?.toLowerCase().includes("decimal") ||
|
||||
targetColumn?.dataType?.toLowerCase().includes("numeric")
|
||||
) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* 숫자 타입 선택 */}
|
||||
<Select
|
||||
value={mapping.value?.startsWith("#") ? mapping.value : "#custom"}
|
||||
onValueChange={(value) => {
|
||||
if (value === "#custom") {
|
||||
updateFieldMapping(index, { value: "" });
|
||||
} else {
|
||||
updateFieldMapping(index, { value });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="숫자 타입 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="#AUTO_INCREMENT">🔢 자동 증가 (AUTO_INCREMENT)</SelectItem>
|
||||
<SelectItem value="#RANDOM_INT">🎲 랜덤 정수 (1-1000)</SelectItem>
|
||||
<SelectItem value="#ZERO">0️⃣ 0</SelectItem>
|
||||
<SelectItem value="#ONE">1️⃣ 1</SelectItem>
|
||||
<SelectItem value="#SEQUENCE">📈 시퀀스값</SelectItem>
|
||||
<SelectItem value="#custom">✏️ 직접 입력</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 직접 입력이 선택된 경우 */}
|
||||
{(!mapping.value?.startsWith("#") || mapping.value === "#custom") && (
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="숫자 입력"
|
||||
value={mapping.value?.startsWith("#") ? "" : mapping.value}
|
||||
onChange={(e) => updateFieldMapping(index, { value: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 선택된 숫자 타입에 대한 설명 */}
|
||||
{mapping.value?.startsWith("#") && mapping.value !== "#custom" && (
|
||||
<div className="text-muted-foreground rounded bg-green-50 p-2 text-xs">
|
||||
{mapping.value === "#AUTO_INCREMENT" && "🔢 데이터베이스에서 자동으로 증가하는 값이 할당됩니다"}
|
||||
{mapping.value === "#RANDOM_INT" && "🎲 1부터 1000 사이의 랜덤한 정수가 생성됩니다"}
|
||||
{mapping.value === "#ZERO" && "0️⃣ 0 값이 저장됩니다"}
|
||||
{mapping.value === "#ONE" && "1️⃣ 1 값이 저장됩니다"}
|
||||
{mapping.value === "#SEQUENCE" && "📈 시퀀스에서 다음 값을 가져옵니다"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
placeholder="값 입력"
|
||||
value={mapping.value}
|
||||
onChange={(e) => updateFieldMapping(index, { value: e.target.value })}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 실행 조건 설정 */}
|
||||
{actionType !== "insert" && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center justify-between text-base">
|
||||
<span>실행 조건 (WHERE)</span>
|
||||
<Button variant="outline" size="sm" onClick={addCondition}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
조건 추가
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
{conditions.length === 0 ? (
|
||||
<div className="rounded-lg border-2 border-dashed p-6 text-center">
|
||||
<Settings className="text-muted-foreground mx-auto mb-2 h-6 w-6" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{actionType.toUpperCase()} 액션의 실행 조건을 설정하세요
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
conditions.map((condition, index) => (
|
||||
<div key={condition.id} className="flex items-center gap-3 rounded-lg border p-3">
|
||||
{/* 논리 연산자 */}
|
||||
{index > 0 && (
|
||||
<Select
|
||||
value={condition.logicalOperator || "AND"}
|
||||
onValueChange={(value) => updateCondition(index, { logicalOperator: value as "AND" | "OR" })}
|
||||
>
|
||||
<SelectTrigger className="w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="AND">AND</SelectItem>
|
||||
<SelectItem value="OR">OR</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{/* 필드 선택 */}
|
||||
<Select value={condition.field} onValueChange={(value) => updateCondition(index, { field: value })}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* FROM 테이블 컬럼들 */}
|
||||
{fromColumns.length > 0 && (
|
||||
<>
|
||||
<div className="text-muted-foreground px-2 py-1 text-xs font-medium">FROM 테이블</div>
|
||||
{fromColumns.map((column) => (
|
||||
<SelectItem key={`from_${column.columnName}`} value={`from.${column.columnName}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-blue-600">📤</span>
|
||||
<span>{column.displayName || column.columnName}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* TO 테이블 컬럼들 */}
|
||||
{toColumns.length > 0 && (
|
||||
<>
|
||||
<div className="text-muted-foreground px-2 py-1 text-xs font-medium">TO 테이블</div>
|
||||
{toColumns.map((column) => (
|
||||
<SelectItem key={`to_${column.columnName}`} value={`to.${column.columnName}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-green-600">📥</span>
|
||||
<span>{column.displayName || column.columnName}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 연산자 선택 */}
|
||||
<Select
|
||||
value={condition.operator}
|
||||
onValueChange={(value) => updateCondition(index, { operator: value as any })}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{operators.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 값 입력 */}
|
||||
{!["IS NULL", "IS NOT NULL"].includes(condition.operator) &&
|
||||
(() => {
|
||||
// FROM/TO 테이블 컬럼 구분
|
||||
let fieldColumn;
|
||||
let actualFieldName;
|
||||
if (condition.field?.startsWith("from.")) {
|
||||
actualFieldName = condition.field.replace("from.", "");
|
||||
fieldColumn = fromColumns.find((col) => col.columnName === actualFieldName);
|
||||
} else if (condition.field?.startsWith("to.")) {
|
||||
actualFieldName = condition.field.replace("to.", "");
|
||||
fieldColumn = toColumns.find((col) => col.columnName === actualFieldName);
|
||||
} else {
|
||||
// 기존 호환성을 위해 TO 테이블에서 먼저 찾기
|
||||
actualFieldName = condition.field;
|
||||
fieldColumn =
|
||||
toColumns.find((col) => col.columnName === condition.field) ||
|
||||
fromColumns.find((col) => col.columnName === condition.field);
|
||||
}
|
||||
|
||||
const fieldCodes = availableCodes[actualFieldName];
|
||||
|
||||
// 코드 타입 필드면 코드 선택
|
||||
if (fieldColumn?.webType === "code" && fieldCodes?.length > 0) {
|
||||
return (
|
||||
<Select value={condition.value} onValueChange={(value) => updateCondition(index, { value })}>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="코드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fieldCodes.map((code) => (
|
||||
<SelectItem key={code.code} value={code.code}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{code.code}
|
||||
</Badge>
|
||||
<span>{code.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
// 값 타입 선택 (고정값, 다른 필드 값, 계산식 등)
|
||||
return (
|
||||
<div className="flex flex-1 gap-2">
|
||||
{/* 값 타입 선택 */}
|
||||
<Select
|
||||
value={condition.valueType || "static"}
|
||||
onValueChange={(valueType) => updateCondition(index, { valueType, value: "" })}
|
||||
>
|
||||
<SelectTrigger className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="static">고정값</SelectItem>
|
||||
<SelectItem value="field">필드값</SelectItem>
|
||||
<SelectItem value="calculated">계산값</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 값 입력 */}
|
||||
{condition.valueType === "field" ? (
|
||||
<Select
|
||||
value={condition.value}
|
||||
onValueChange={(value) => updateCondition(index, { value })}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* FROM 테이블 필드들 */}
|
||||
{fromColumns.length > 0 && (
|
||||
<>
|
||||
<div className="text-muted-foreground px-2 py-1 text-xs font-medium">
|
||||
FROM 테이블
|
||||
</div>
|
||||
{fromColumns.map((column) => (
|
||||
<SelectItem key={`from_${column.columnName}`} value={`from.${column.columnName}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-blue-600">📤</span>
|
||||
<span>{column.displayName || column.columnName}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* TO 테이블 필드들 */}
|
||||
{toColumns.length > 0 && (
|
||||
<>
|
||||
<div className="text-muted-foreground px-2 py-1 text-xs font-medium">TO 테이블</div>
|
||||
{toColumns.map((column) => (
|
||||
<SelectItem key={`to_${column.columnName}`} value={`to.${column.columnName}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-green-600">📥</span>
|
||||
<span>{column.displayName || column.columnName}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
placeholder={condition.valueType === "calculated" ? "계산식 입력" : "값 입력"}
|
||||
value={condition.value}
|
||||
onChange={(e) => updateCondition(index, { value: e.target.value })}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button variant="ghost" size="sm" onClick={() => deleteCondition(index)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 필드 값 매핑 설정 */}
|
||||
{showFieldMappings && actionType !== "delete" && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center justify-between text-base">
|
||||
<div>
|
||||
<span>필드 값 설정 (SET)</span>
|
||||
<p className="text-muted-foreground mt-1 text-xs">매핑되지 않은 필드의 기본값 설정</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addFieldMapping}
|
||||
disabled={getUnmappedToColumns().length === 0}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
필드 추가
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
{/* 매핑되지 않은 필드가 없는 경우 */}
|
||||
{getUnmappedToColumns().length === 0 ? (
|
||||
<div className="rounded-lg border bg-green-50 p-4 text-center">
|
||||
<div className="mb-2 text-green-600">✅ 모든 필드가 매핑되었습니다</div>
|
||||
<p className="text-sm text-green-700">
|
||||
컬럼 매핑으로 모든 TO 테이블 필드가 처리되고 있어 별도의 기본값 설정이 필요하지 않습니다.
|
||||
</p>
|
||||
</div>
|
||||
) : fieldMappings.length === 0 ? (
|
||||
<div className="rounded-lg border-2 border-dashed p-6 text-center">
|
||||
<Settings className="text-muted-foreground mx-auto mb-2 h-6 w-6" />
|
||||
<p className="text-muted-foreground text-sm">매핑되지 않은 필드의 기본값을 설정하세요</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
컬럼 매핑으로 처리되지 않은 필드들만 여기서 설정됩니다
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
현재 {getUnmappedToColumns().length}개 필드가 매핑되지 않음
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
(() => {
|
||||
console.log("🎨 필드값 설정 렌더링:", {
|
||||
fieldMappings,
|
||||
fieldMappingsCount: fieldMappings.length,
|
||||
});
|
||||
return fieldMappings.map((mapping, index) => {
|
||||
const targetColumn = toColumns.find((col) => col.columnName === mapping.targetField);
|
||||
|
||||
return (
|
||||
<div key={mapping.id} className="flex items-center gap-3 rounded-lg border p-3">
|
||||
{/* 대상 필드 */}
|
||||
<Select
|
||||
value={mapping.targetField}
|
||||
onValueChange={(value) =>
|
||||
updateFieldMapping(index, {
|
||||
targetField: value,
|
||||
value: "", // 필드 변경 시 값 초기화
|
||||
sourceField: "", // 소스 필드도 초기화
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="대상 필드" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{getAvailableFieldsForMapping(index).map((column) => (
|
||||
<SelectItem key={column.columnName} value={column.columnName}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{column.displayName || column.columnName}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{column.webType || column.dataType}
|
||||
</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 값 타입 */}
|
||||
<Select
|
||||
value={mapping.valueType}
|
||||
onValueChange={(value) =>
|
||||
updateFieldMapping(index, {
|
||||
valueType: value as any,
|
||||
value: "", // 값 타입 변경 시 값 초기화
|
||||
sourceField: "", // 소스 필드도 초기화
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="static">고정값</SelectItem>
|
||||
<SelectItem value="source_field">소스필드</SelectItem>
|
||||
{(targetColumn?.connectionId === 0 || targetColumn?.connectionId === undefined) &&
|
||||
targetColumn?.inputType === "code" && <SelectItem value="code">코드선택</SelectItem>}
|
||||
<SelectItem value="calculated">계산식</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 값 입력 */}
|
||||
<div className="flex-1">{renderValueInput(mapping, index, targetColumn)}</div>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button variant="ghost" size="sm" onClick={() => deleteFieldMapping(index)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionConditionBuilder;
|
||||
|
|
@ -0,0 +1,226 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ArrowLeft, Settings, CheckCircle } from "lucide-react";
|
||||
|
||||
// 타입 import
|
||||
import { DataConnectionState, DataConnectionActions } from "../types/redesigned";
|
||||
import { ColumnInfo } from "@/lib/types/multiConnection";
|
||||
import { getColumnsFromConnection } from "@/lib/api/multiConnection";
|
||||
|
||||
// 컴포넌트 import
|
||||
import ActionConditionBuilder from "./ActionConfig/ActionConditionBuilder";
|
||||
|
||||
interface ActionConfigStepProps {
|
||||
state: DataConnectionState;
|
||||
actions: DataConnectionActions;
|
||||
onBack: () => void;
|
||||
onComplete: () => void;
|
||||
onSave?: () => void; // UPDATE/DELETE인 경우 저장 버튼
|
||||
showSaveButton?: boolean; // 저장 버튼 표시 여부
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎯 4단계: 액션 설정
|
||||
* - 액션 타입 선택 (INSERT/UPDATE/DELETE/UPSERT)
|
||||
* - 실행 조건 설정
|
||||
* - 액션별 상세 설정
|
||||
*/
|
||||
const ActionConfigStep: React.FC<ActionConfigStepProps> = ({
|
||||
state,
|
||||
actions,
|
||||
onBack,
|
||||
onComplete,
|
||||
onSave,
|
||||
showSaveButton = false,
|
||||
}) => {
|
||||
const { actionType, actionConditions, fromTable, toTable, fromConnection, toConnection } = state;
|
||||
|
||||
const [fromColumns, setFromColumns] = useState<ColumnInfo[]>([]);
|
||||
const [toColumns, setToColumns] = useState<ColumnInfo[]>([]);
|
||||
const [fieldMappings, setFieldMappings] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const actionTypes = [
|
||||
{ value: "insert", label: "INSERT", description: "새 데이터 삽입" },
|
||||
{ value: "update", label: "UPDATE", description: "기존 데이터 수정" },
|
||||
{ value: "delete", label: "DELETE", description: "데이터 삭제" },
|
||||
{ value: "upsert", label: "UPSERT", description: "있으면 수정, 없으면 삽입" },
|
||||
];
|
||||
|
||||
// 컬럼 정보 로드
|
||||
useEffect(() => {
|
||||
const loadColumns = async () => {
|
||||
if (!fromConnection || !toConnection || !fromTable || !toTable) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [fromCols, toCols] = await Promise.all([
|
||||
getColumnsFromConnection(fromConnection.id, fromTable.tableName),
|
||||
getColumnsFromConnection(toConnection.id, toTable.tableName),
|
||||
]);
|
||||
|
||||
setFromColumns(fromCols);
|
||||
setToColumns(toCols);
|
||||
} catch (error) {
|
||||
console.error("컬럼 정보 로드 실패:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadColumns();
|
||||
}, [fromConnection, toConnection, fromTable, toTable]);
|
||||
|
||||
const canComplete =
|
||||
actionType &&
|
||||
(actionType === "insert" || (actionConditions.length > 0 && (actionType === "delete" || fieldMappings.length > 0)));
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Settings className="h-5 w-5" />
|
||||
4단계: 액션 설정
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="max-h-[calc(100vh-400px)] min-h-[400px] space-y-6 overflow-y-auto">
|
||||
{/* 액션 타입 선택 */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-lg font-semibold">액션 타입</h3>
|
||||
<Select value={actionType} onValueChange={actions.setActionType}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="액션 타입을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{actionTypes.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div>
|
||||
<span className="font-medium">{type.label}</span>
|
||||
<p className="text-muted-foreground text-xs">{type.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{actionType && (
|
||||
<div className="bg-primary/5 border-primary/20 rounded-lg border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-primary">
|
||||
{actionTypes.find((t) => t.value === actionType)?.label}
|
||||
</Badge>
|
||||
<span className="text-sm">{actionTypes.find((t) => t.value === actionType)?.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 상세 조건 설정 */}
|
||||
{actionType && !isLoading && fromColumns.length > 0 && toColumns.length > 0 && (
|
||||
<ActionConditionBuilder
|
||||
actionType={actionType}
|
||||
fromColumns={fromColumns}
|
||||
toColumns={toColumns}
|
||||
conditions={actionConditions}
|
||||
fieldMappings={fieldMappings}
|
||||
onConditionsChange={(conditions) => {
|
||||
// 액션 조건 배열 전체 업데이트
|
||||
actions.setActionConditions(conditions);
|
||||
}}
|
||||
onFieldMappingsChange={setFieldMappings}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 로딩 상태 */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-muted-foreground">컬럼 정보를 불러오는 중...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* INSERT 액션 안내 */}
|
||||
{actionType === "insert" && (
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 p-4">
|
||||
<h4 className="mb-2 text-sm font-medium text-green-800">INSERT 액션</h4>
|
||||
<p className="text-sm text-green-700">
|
||||
INSERT 액션은 별도의 실행 조건이 필요하지 않습니다. 매핑된 모든 데이터가 새로운 레코드로 삽입됩니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 액션 요약 */}
|
||||
{actionType && (
|
||||
<div className="bg-muted/50 rounded-lg p-4">
|
||||
<h4 className="mb-3 text-sm font-medium">설정 요약</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>액션 타입:</span>
|
||||
<Badge variant="outline">{actionType.toUpperCase()}</Badge>
|
||||
</div>
|
||||
{actionType !== "insert" && (
|
||||
<>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>실행 조건:</span>
|
||||
<span className="text-muted-foreground">
|
||||
{actionConditions.length > 0 ? `${actionConditions.length}개 조건` : "조건 없음"}
|
||||
</span>
|
||||
</div>
|
||||
{actionType !== "delete" && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>필드 매핑:</span>
|
||||
<span className="text-muted-foreground">
|
||||
{fieldMappings.length > 0 ? `${fieldMappings.length}개 필드` : "필드 없음"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 하단 네비게이션 */}
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="outline" onClick={onBack} className="flex items-center gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
이전: 제어 조건
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{showSaveButton && onSave && (
|
||||
<Button onClick={onSave} disabled={!canComplete} className="flex items-center gap-2">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
저장
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!showSaveButton && (
|
||||
<Button onClick={onComplete} disabled={!canComplete} className="flex items-center gap-2">
|
||||
다음: 컬럼 매핑
|
||||
<ArrowLeft className="h-4 w-4 rotate-180" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!canComplete && (
|
||||
<p className="text-muted-foreground mt-2 text-center text-sm">
|
||||
{!actionType ? "액션 타입을 선택해주세요" : "실행 조건을 추가해주세요"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionConfigStep;
|
||||
|
|
@ -0,0 +1,416 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
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 { Badge } from "@/components/ui/badge";
|
||||
import { ArrowRight, Database, Globe, Loader2, AlertTriangle, CheckCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// API import
|
||||
import { getActiveConnections, ConnectionInfo } from "@/lib/api/multiConnection";
|
||||
import { checkRelationshipNameDuplicate } from "@/lib/api/dataflowSave";
|
||||
|
||||
// 타입 import
|
||||
import { Connection } from "@/lib/types/multiConnection";
|
||||
|
||||
interface ConnectionStepProps {
|
||||
connectionType: "data_save" | "external_call";
|
||||
fromConnection?: Connection;
|
||||
toConnection?: Connection;
|
||||
relationshipName?: string;
|
||||
description?: string;
|
||||
diagramId?: number; // 🔧 수정 모드 감지용
|
||||
onSelectConnection: (type: "from" | "to", connection: Connection) => void;
|
||||
onSetRelationshipName: (name: string) => void;
|
||||
onSetDescription: (description: string) => void;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔗 1단계: 연결 선택
|
||||
* - FROM/TO 데이터베이스 연결 선택
|
||||
* - 연결 상태 표시
|
||||
* - 지연시간 정보
|
||||
*/
|
||||
const ConnectionStep: React.FC<ConnectionStepProps> = React.memo(
|
||||
({
|
||||
connectionType,
|
||||
fromConnection,
|
||||
toConnection,
|
||||
relationshipName,
|
||||
description,
|
||||
diagramId,
|
||||
onSelectConnection,
|
||||
onSetRelationshipName,
|
||||
onSetDescription,
|
||||
onNext,
|
||||
}) => {
|
||||
const [connections, setConnections] = useState<Connection[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [nameCheckStatus, setNameCheckStatus] = useState<"idle" | "checking" | "valid" | "duplicate">("idle");
|
||||
|
||||
// API 응답을 Connection 타입으로 변환
|
||||
const convertToConnection = (connectionInfo: ConnectionInfo): Connection => ({
|
||||
id: connectionInfo.id,
|
||||
name: connectionInfo.connection_name,
|
||||
type: connectionInfo.db_type,
|
||||
host: connectionInfo.host,
|
||||
port: connectionInfo.port,
|
||||
database: connectionInfo.database_name,
|
||||
username: connectionInfo.username,
|
||||
isActive: connectionInfo.is_active === "Y",
|
||||
companyCode: connectionInfo.company_code,
|
||||
createdDate: connectionInfo.created_date,
|
||||
updatedDate: connectionInfo.updated_date,
|
||||
});
|
||||
|
||||
// 🔍 관계명 중복 체크 (디바운스 적용)
|
||||
const checkNameDuplicate = useCallback(
|
||||
async (name: string) => {
|
||||
if (!name.trim()) {
|
||||
setNameCheckStatus("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
setNameCheckStatus("checking");
|
||||
|
||||
try {
|
||||
const result = await checkRelationshipNameDuplicate(name, diagramId);
|
||||
setNameCheckStatus(result.isDuplicate ? "duplicate" : "valid");
|
||||
|
||||
if (result.isDuplicate) {
|
||||
toast.warning(`"${name}" 이름이 이미 사용 중입니다. (${result.duplicateCount}개 발견)`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("중복 체크 실패:", error);
|
||||
setNameCheckStatus("idle");
|
||||
}
|
||||
},
|
||||
[diagramId],
|
||||
);
|
||||
|
||||
// 관계명 변경 시 중복 체크 (디바운스)
|
||||
useEffect(() => {
|
||||
if (!relationshipName) {
|
||||
setNameCheckStatus("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
checkNameDuplicate(relationshipName);
|
||||
}, 500); // 500ms 디바운스
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [relationshipName, checkNameDuplicate]);
|
||||
|
||||
// 연결 목록 로드
|
||||
useEffect(() => {
|
||||
const loadConnections = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await getActiveConnections();
|
||||
|
||||
// 메인 DB 연결 추가
|
||||
const mainConnection: Connection = {
|
||||
id: 0,
|
||||
name: "메인 데이터베이스",
|
||||
type: "postgresql",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
database: "main",
|
||||
username: "main_user",
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
// API 응답을 Connection 타입으로 변환
|
||||
const convertedConnections = data.map(convertToConnection);
|
||||
|
||||
// 중복 방지: 기존에 메인 연결이 없는 경우에만 추가
|
||||
const hasMainConnection = convertedConnections.some((conn) => conn.id === 0);
|
||||
const preliminaryConnections = hasMainConnection
|
||||
? convertedConnections
|
||||
: [mainConnection, ...convertedConnections];
|
||||
|
||||
// ID 중복 제거 (Set 사용)
|
||||
const uniqueConnections = preliminaryConnections.filter(
|
||||
(conn, index, arr) => arr.findIndex((c) => c.id === conn.id) === index,
|
||||
);
|
||||
|
||||
console.log("🔗 연결 목록 로드 완료:", uniqueConnections);
|
||||
setConnections(uniqueConnections);
|
||||
} catch (error) {
|
||||
console.error("❌ 연결 목록 로드 실패:", error);
|
||||
toast.error("연결 목록을 불러오는데 실패했습니다.");
|
||||
|
||||
// 에러 시에도 메인 연결은 제공
|
||||
const mainConnection: Connection = {
|
||||
id: 0,
|
||||
name: "메인 데이터베이스",
|
||||
type: "postgresql",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
database: "main",
|
||||
username: "main_user",
|
||||
isActive: true,
|
||||
};
|
||||
setConnections([mainConnection]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadConnections();
|
||||
}, []);
|
||||
|
||||
const handleConnectionSelect = (type: "from" | "to", connectionId: string) => {
|
||||
const connection = connections.find((c) => c.id.toString() === connectionId);
|
||||
if (connection) {
|
||||
onSelectConnection(type, connection);
|
||||
}
|
||||
};
|
||||
|
||||
const canProceed = fromConnection && toConnection;
|
||||
|
||||
const getConnectionIcon = (connection: Connection) => {
|
||||
return connection.id === 0 ? <Database className="h-4 w-4" /> : <Globe className="h-4 w-4" />;
|
||||
};
|
||||
|
||||
const getConnectionBadge = (connection: Connection) => {
|
||||
if (connection.id === 0) {
|
||||
return (
|
||||
<Badge variant="default" className="text-xs">
|
||||
메인 DB
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{connection.type?.toUpperCase()}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5" />
|
||||
1단계: 연결 선택
|
||||
</CardTitle>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{connectionType === "data_save"
|
||||
? "데이터를 저장할 소스와 대상 데이터베이스를 선택하세요."
|
||||
: "외부 호출을 위한 소스와 대상 연결을 선택하세요."}
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="max-h-[calc(100vh-400px)] min-h-[400px] space-y-6 overflow-y-auto">
|
||||
{/* 관계 정보 입력 */}
|
||||
<div className="bg-muted/30 space-y-4 rounded-lg border p-4">
|
||||
<h3 className="font-medium">관계 정보</h3>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="relationshipName">관계 이름 *</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="relationshipName"
|
||||
placeholder="예: 사용자 데이터 동기화"
|
||||
value={relationshipName || ""}
|
||||
onChange={(e) => onSetRelationshipName(e.target.value)}
|
||||
className={`pr-10 ${
|
||||
nameCheckStatus === "duplicate"
|
||||
? "border-red-500 focus:border-red-500"
|
||||
: nameCheckStatus === "valid"
|
||||
? "border-green-500 focus:border-green-500"
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
<div className="absolute top-1/2 right-3 -translate-y-1/2">
|
||||
{nameCheckStatus === "checking" && (
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{nameCheckStatus === "valid" && <CheckCircle className="h-4 w-4 text-green-500" />}
|
||||
{nameCheckStatus === "duplicate" && <AlertTriangle className="h-4 w-4 text-red-500" />}
|
||||
</div>
|
||||
</div>
|
||||
{nameCheckStatus === "duplicate" && <p className="text-sm text-red-600">이미 사용 중인 이름입니다.</p>}
|
||||
{nameCheckStatus === "valid" && <p className="text-sm text-green-600">사용 가능한 이름입니다.</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="이 관계에 대한 설명을 입력하세요"
|
||||
value={description || ""}
|
||||
onChange={(e) => onSetDescription(e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="mr-2 h-6 w-6 animate-spin" />
|
||||
<span>연결 목록을 불러오는 중...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* FROM 연결 선택 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium">FROM 연결 (소스)</h3>
|
||||
{fromConnection && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-green-600">
|
||||
🟢 연결됨
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">지연시간: ~23ms</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={fromConnection?.id.toString() || ""}
|
||||
onValueChange={(value) => handleConnectionSelect("from", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="소스 연결을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{connections.length === 0 ? (
|
||||
<div className="text-muted-foreground p-4 text-center">연결 정보가 없습니다.</div>
|
||||
) : (
|
||||
connections.map((connection, index) => (
|
||||
<SelectItem key={`from_${connection.id}_${index}`} value={connection.id.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
{getConnectionIcon(connection)}
|
||||
<span>{connection.name}</span>
|
||||
{getConnectionBadge(connection)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{fromConnection && (
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
{getConnectionIcon(fromConnection)}
|
||||
<span className="font-medium">{fromConnection.name}</span>
|
||||
{getConnectionBadge(fromConnection)}
|
||||
</div>
|
||||
<div className="text-muted-foreground space-y-1 text-xs">
|
||||
<p>
|
||||
호스트: {fromConnection.host}:{fromConnection.port}
|
||||
</p>
|
||||
<p>데이터베이스: {fromConnection.database}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* TO 연결 선택 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium">TO 연결 (대상)</h3>
|
||||
{toConnection && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-green-600">
|
||||
🟢 연결됨
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">지연시간: ~45ms</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={toConnection?.id.toString() || ""}
|
||||
onValueChange={(value) => handleConnectionSelect("to", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="대상 연결을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{connections.length === 0 ? (
|
||||
<div className="text-muted-foreground p-4 text-center">연결 정보가 없습니다.</div>
|
||||
) : (
|
||||
connections.map((connection, index) => (
|
||||
<SelectItem key={`to_${connection.id}_${index}`} value={connection.id.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
{getConnectionIcon(connection)}
|
||||
<span>{connection.name}</span>
|
||||
{getConnectionBadge(connection)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{toConnection && (
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
{getConnectionIcon(toConnection)}
|
||||
<span className="font-medium">{toConnection.name}</span>
|
||||
{getConnectionBadge(toConnection)}
|
||||
</div>
|
||||
<div className="text-muted-foreground space-y-1 text-xs">
|
||||
<p>
|
||||
호스트: {toConnection.host}:{toConnection.port}
|
||||
</p>
|
||||
<p>데이터베이스: {toConnection.database}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 연결 매핑 표시 */}
|
||||
{fromConnection && toConnection && (
|
||||
<div className="bg-primary/5 border-primary/20 rounded-lg border p-4">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<div className="text-center">
|
||||
<div className="font-medium">{fromConnection.name}</div>
|
||||
<div className="text-muted-foreground text-xs">소스</div>
|
||||
</div>
|
||||
|
||||
<ArrowRight className="text-primary h-5 w-5" />
|
||||
|
||||
<div className="text-center">
|
||||
<div className="font-medium">{toConnection.name}</div>
|
||||
<div className="text-muted-foreground text-xs">대상</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-center">
|
||||
<Badge variant="outline" className="text-primary">
|
||||
💡 연결 매핑 설정 완료
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 다음 단계 버튼 */}
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button onClick={onNext} disabled={!canProceed} className="flex items-center gap-2">
|
||||
다음: 테이블 선택
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ConnectionStep.displayName = "ConnectionStep";
|
||||
|
||||
export default ConnectionStep;
|
||||
|
|
@ -0,0 +1,471 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ArrowLeft, CheckCircle, AlertCircle, Settings, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
// 타입 import
|
||||
import { DataConnectionState, DataConnectionActions } from "../types/redesigned";
|
||||
import { ColumnInfo } from "@/lib/types/multiConnection";
|
||||
// 컬럼 로드는 중앙에서 관리
|
||||
import { getCodesForColumn, CodeItem } from "@/lib/api/codeManagement";
|
||||
|
||||
// 컴포넌트 import
|
||||
|
||||
interface ControlConditionStepProps {
|
||||
state: DataConnectionState;
|
||||
actions: DataConnectionActions;
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎯 4단계: 제어 조건 설정
|
||||
* - 전체 제어가 언제 실행될지 설정
|
||||
* - INSERT/UPDATE/DELETE 트리거 조건
|
||||
*/
|
||||
const ControlConditionStep: React.FC<ControlConditionStepProps> = ({ state, actions, onBack, onNext }) => {
|
||||
const {
|
||||
controlConditions,
|
||||
fromTable,
|
||||
toTable,
|
||||
fromConnection,
|
||||
toConnection,
|
||||
fromColumns = [],
|
||||
toColumns = [],
|
||||
isLoading = false,
|
||||
} = state;
|
||||
const [availableCodes, setAvailableCodes] = useState<Record<string, CodeItem[]>>({});
|
||||
|
||||
// 컴포넌트 마운트 시 컬럼 로드
|
||||
useEffect(() => {
|
||||
if (
|
||||
fromConnection &&
|
||||
toConnection &&
|
||||
fromTable &&
|
||||
toTable &&
|
||||
fromColumns.length === 0 &&
|
||||
toColumns.length === 0 &&
|
||||
!isLoading
|
||||
) {
|
||||
console.log("🔄 ControlConditionStep: 컬럼 로드 시작");
|
||||
actions.loadColumns();
|
||||
}
|
||||
}, [
|
||||
fromConnection?.id,
|
||||
toConnection?.id,
|
||||
fromTable?.tableName,
|
||||
toTable?.tableName,
|
||||
fromColumns.length,
|
||||
toColumns.length,
|
||||
isLoading,
|
||||
]);
|
||||
|
||||
// 코드 타입 컬럼의 코드 로드
|
||||
useEffect(() => {
|
||||
const loadCodes = async () => {
|
||||
const allColumns = [...fromColumns, ...toColumns];
|
||||
const codeColumns = allColumns.filter((col) => {
|
||||
// 메인 DB(connectionId === 0 또는 undefined)인 경우: column_labels의 input_type이 'code'인 경우만
|
||||
if (col.connectionId === 0 || col.connectionId === undefined) {
|
||||
return col.inputType === "code";
|
||||
}
|
||||
// 외부 DB인 경우: 코드 타입 없음
|
||||
return false;
|
||||
});
|
||||
|
||||
if (codeColumns.length === 0) return;
|
||||
|
||||
console.log(
|
||||
"🔍 모든 컬럼 정보:",
|
||||
allColumns.map((col) => ({
|
||||
columnName: col.columnName,
|
||||
connectionId: col.connectionId,
|
||||
inputType: col.inputType,
|
||||
webType: col.webType,
|
||||
})),
|
||||
);
|
||||
console.log("🔍 코드 타입 컬럼들:", codeColumns);
|
||||
|
||||
const codePromises = codeColumns.map(async (col) => {
|
||||
try {
|
||||
const codes = await getCodesForColumn(col.columnName, col.webType, col.codeCategory);
|
||||
return { columnName: col.columnName, codes };
|
||||
} catch (error) {
|
||||
console.error(`코드 로딩 실패 (${col.columnName}):`, error);
|
||||
return { columnName: col.columnName, codes: [] };
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(codePromises);
|
||||
const codeMap: Record<string, CodeItem[]> = {};
|
||||
|
||||
results.forEach(({ columnName, codes }) => {
|
||||
codeMap[columnName] = codes;
|
||||
});
|
||||
|
||||
console.log("📋 로딩된 코드들:", codeMap);
|
||||
setAvailableCodes(codeMap);
|
||||
};
|
||||
|
||||
if (fromColumns.length > 0 || toColumns.length > 0) {
|
||||
loadCodes();
|
||||
}
|
||||
}, [fromColumns, toColumns]);
|
||||
|
||||
// 완료 가능 여부 확인
|
||||
const canProceed =
|
||||
controlConditions.length === 0 ||
|
||||
controlConditions.some(
|
||||
(condition) =>
|
||||
condition.field &&
|
||||
condition.operator &&
|
||||
(condition.value !== "" || ["IS NULL", "IS NOT NULL"].includes(condition.operator)),
|
||||
);
|
||||
|
||||
const isCompleted = canProceed;
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{isCompleted ? (
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
) : (
|
||||
<AlertCircle className="h-5 w-5 text-orange-500" />
|
||||
)}
|
||||
4단계: 제어 실행 조건
|
||||
</CardTitle>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
이 전체 제어가 언제 실행될지 설정합니다. 조건을 설정하지 않으면 항상 실행됩니다.
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex h-full flex-col overflow-hidden p-0">
|
||||
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto p-4">
|
||||
{/* 제어 실행 조건 안내 */}
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||
<h4 className="mb-2 text-sm font-medium text-blue-800">제어 실행 조건이란?</h4>
|
||||
<div className="space-y-1 text-sm text-blue-700">
|
||||
<p>
|
||||
• <strong>전체 제어의 트리거 조건</strong>을 설정합니다
|
||||
</p>
|
||||
<p>• 예: "상태가 '활성'이고 유형이 'A'인 경우에만 데이터 동기화 실행"</p>
|
||||
<p>• 조건을 설정하지 않으면 모든 경우에 실행됩니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 간단한 조건 추가 UI */}
|
||||
{!isLoading && (fromColumns.length > 0 || toColumns.length > 0 || controlConditions.length > 0) && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium">실행 조건 (WHERE)</h4>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
console.log("🔄 조건 추가 클릭");
|
||||
actions.addControlCondition();
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
조건 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{controlConditions.length === 0 ? (
|
||||
<div className="rounded-lg border-2 border-dashed p-6 text-center">
|
||||
<Settings className="text-muted-foreground mx-auto mb-2 h-6 w-6" />
|
||||
<p className="text-muted-foreground text-sm">제어 실행 조건을 설정하세요</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">"조건 추가" 버튼을 클릭하여 시작하세요</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{controlConditions.map((condition, index) => (
|
||||
<div key={`control-condition-${index}`} className="rounded-lg border p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 논리 연산자 */}
|
||||
{index > 0 && (
|
||||
<Select
|
||||
value={condition.logicalOperator || "AND"}
|
||||
onValueChange={(value) =>
|
||||
actions.updateControlCondition(index, {
|
||||
...condition,
|
||||
logicalOperator: value as "AND" | "OR",
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-16">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="AND">AND</SelectItem>
|
||||
<SelectItem value="OR">OR</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{/* 필드 선택 */}
|
||||
<Select
|
||||
value={condition.field || ""}
|
||||
onValueChange={(value) => {
|
||||
if (value !== "__placeholder__") {
|
||||
actions.updateControlCondition(index, {
|
||||
...condition,
|
||||
field: value,
|
||||
value: "", // 필드 변경 시 값 초기화
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__placeholder__" disabled>
|
||||
필드 선택
|
||||
</SelectItem>
|
||||
{[...fromColumns, ...toColumns]
|
||||
.filter(
|
||||
(col, index, array) =>
|
||||
array.findIndex((c) => c.columnName === col.columnName) === index,
|
||||
)
|
||||
.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 연산자 선택 */}
|
||||
<Select
|
||||
value={condition.operator || "="}
|
||||
onValueChange={(value) =>
|
||||
actions.updateControlCondition(index, {
|
||||
...condition,
|
||||
operator: value as any,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="=">=</SelectItem>
|
||||
<SelectItem value="!=">!=</SelectItem>
|
||||
<SelectItem value=">">{">"}</SelectItem>
|
||||
<SelectItem value="<">{"<"}</SelectItem>
|
||||
<SelectItem value=">=">{">="}</SelectItem>
|
||||
<SelectItem value="<=">{`<=`}</SelectItem>
|
||||
<SelectItem value="LIKE">LIKE</SelectItem>
|
||||
<SelectItem value="IS NULL">IS NULL</SelectItem>
|
||||
<SelectItem value="IS NOT NULL">IS NOT NULL</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 값 입력 */}
|
||||
{!["IS NULL", "IS NOT NULL"].includes(condition.operator || "") &&
|
||||
(() => {
|
||||
// 선택된 필드가 코드 타입인지 확인
|
||||
const selectedField = [...fromColumns, ...toColumns].find(
|
||||
(col) => col.columnName === condition.field,
|
||||
);
|
||||
const isCodeField =
|
||||
selectedField &&
|
||||
(selectedField.connectionId === 0 || selectedField.connectionId === undefined) && // 임시: undefined도 메인 DB로 간주
|
||||
selectedField.inputType === "code";
|
||||
const fieldCodes = condition.field ? availableCodes[condition.field] : [];
|
||||
|
||||
// 디버깅 정보 출력
|
||||
console.log("🔍 값 입력 필드 디버깅:", {
|
||||
conditionField: condition.field,
|
||||
selectedField: selectedField,
|
||||
selectedFieldKeys: selectedField ? Object.keys(selectedField) : [],
|
||||
webType: selectedField?.webType,
|
||||
inputType: selectedField?.inputType,
|
||||
connectionId: selectedField?.connectionId,
|
||||
dataType: selectedField?.dataType,
|
||||
isCodeField: isCodeField,
|
||||
fieldCodes: fieldCodes,
|
||||
availableCodesKeys: Object.keys(availableCodes),
|
||||
});
|
||||
|
||||
if (isCodeField && fieldCodes && fieldCodes.length > 0) {
|
||||
// 코드 타입 필드면 코드 선택 드롭다운
|
||||
return (
|
||||
<Select
|
||||
value={condition.value || ""}
|
||||
onValueChange={(value) => {
|
||||
if (value !== "__code_placeholder__") {
|
||||
actions.updateControlCondition(index, {
|
||||
...condition,
|
||||
value: value,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue placeholder="코드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__code_placeholder__" disabled>
|
||||
코드 선택
|
||||
</SelectItem>
|
||||
{fieldCodes.map((code, codeIndex) => {
|
||||
console.log("🎨 코드 렌더링:", {
|
||||
index: codeIndex,
|
||||
code: code,
|
||||
codeValue: code.code,
|
||||
codeName: code.name,
|
||||
hasCode: !!code.code,
|
||||
hasName: !!code.name,
|
||||
});
|
||||
|
||||
return (
|
||||
<SelectItem
|
||||
key={`code_${condition.field}_${code.code || codeIndex}_${codeIndex}`}
|
||||
value={code.code || `unknown_${codeIndex}`}
|
||||
>
|
||||
{code.name}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
} else {
|
||||
// 일반 필드면 텍스트 입력
|
||||
return (
|
||||
<Input
|
||||
placeholder="값"
|
||||
value={condition.value || ""}
|
||||
onChange={(e) =>
|
||||
actions.updateControlCondition(index, {
|
||||
...condition,
|
||||
value: e.target.value,
|
||||
})
|
||||
}
|
||||
className="w-32"
|
||||
/>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => actions.deleteControlCondition(index)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 로딩 상태 */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-muted-foreground">컬럼 정보를 불러오는 중...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 조건 없음 안내 */}
|
||||
{!isLoading && fromColumns.length > 0 && toColumns.length > 0 && controlConditions.length === 0 && (
|
||||
<div className="rounded-lg border-2 border-dashed p-8 text-center">
|
||||
<AlertCircle className="text-muted-foreground mx-auto mb-3 h-8 w-8" />
|
||||
<h3 className="mb-2 text-lg font-medium">제어 실행 조건 없음</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
현재 제어 실행 조건이 설정되지 않았습니다.
|
||||
<br />
|
||||
모든 경우에 제어가 실행됩니다.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
console.log("제어 조건 추가 버튼 클릭");
|
||||
actions.addControlCondition();
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
조건 추가하기
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 컬럼 정보 로드 실패 시 안내 */}
|
||||
{fromColumns.length === 0 && toColumns.length === 0 && controlConditions.length === 0 && (
|
||||
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4">
|
||||
<h4 className="mb-2 text-sm font-medium text-yellow-800">컬럼 정보를 불러올 수 없습니다</h4>
|
||||
<div className="space-y-2 text-sm text-yellow-700">
|
||||
<p>• 외부 데이터베이스 연결에 문제가 있을 수 있습니다</p>
|
||||
<p>• 조건 없이 진행하면 항상 실행됩니다</p>
|
||||
<p>• 나중에 수동으로 조건을 추가할 수 있습니다</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
console.log("🔄 수동 조건 추가");
|
||||
actions.addControlCondition();
|
||||
}}
|
||||
className="mt-3 flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
수동으로 조건 추가
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 설정 요약 */}
|
||||
{controlConditions.length > 0 && (
|
||||
<div className="bg-muted/50 rounded-lg p-4">
|
||||
<h4 className="mb-3 text-sm font-medium">설정 요약</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>제어 실행 조건:</span>
|
||||
<Badge variant={controlConditions.length > 0 ? "default" : "secondary"}>
|
||||
{controlConditions.length > 0 ? `${controlConditions.length}개 조건` : "조건 없음"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>실행 방식:</span>
|
||||
<span className="text-muted-foreground">
|
||||
{controlConditions.length === 0 ? "항상 실행" : "조건부 실행"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 하단 네비게이션 */}
|
||||
<div className="flex-shrink-0 border-t bg-white p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="outline" onClick={onBack} className="flex items-center gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
이전
|
||||
</Button>
|
||||
|
||||
<Button onClick={onNext} disabled={!canProceed} className="flex items-center gap-2">
|
||||
다음: 액션 설정
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ControlConditionStep;
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft, Link, Loader2, CheckCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// API import
|
||||
import { getColumnsFromConnection } from "@/lib/api/multiConnection";
|
||||
|
||||
// 타입 import
|
||||
import { Connection, TableInfo, ColumnInfo } from "@/lib/types/multiConnection";
|
||||
import { FieldMapping } from "../types/redesigned";
|
||||
|
||||
// 컴포넌트 import
|
||||
import FieldMappingCanvas from "./VisualMapping/FieldMappingCanvas";
|
||||
|
||||
interface FieldMappingStepProps {
|
||||
fromTable?: TableInfo;
|
||||
toTable?: TableInfo;
|
||||
fromConnection?: Connection;
|
||||
toConnection?: Connection;
|
||||
fieldMappings: FieldMapping[];
|
||||
onCreateMapping: (fromField: ColumnInfo, toField: ColumnInfo) => void;
|
||||
onDeleteMapping: (mappingId: string) => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎯 3단계: 시각적 필드 매핑
|
||||
* - SVG 기반 연결선 표시
|
||||
* - 드래그 앤 드롭 지원 (향후)
|
||||
* - 실시간 매핑 업데이트
|
||||
*/
|
||||
const FieldMappingStep: React.FC<FieldMappingStepProps> = ({
|
||||
fromTable,
|
||||
toTable,
|
||||
fromConnection,
|
||||
toConnection,
|
||||
fieldMappings,
|
||||
onCreateMapping,
|
||||
onDeleteMapping,
|
||||
onNext,
|
||||
onBack,
|
||||
}) => {
|
||||
const [fromColumns, setFromColumns] = useState<ColumnInfo[]>([]);
|
||||
const [toColumns, setToColumns] = useState<ColumnInfo[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 컬럼 정보 로드
|
||||
useEffect(() => {
|
||||
const loadColumns = async () => {
|
||||
console.log("🔍 컬럼 로딩 시작:", {
|
||||
fromConnection: fromConnection?.id,
|
||||
toConnection: toConnection?.id,
|
||||
fromTable: fromTable?.tableName,
|
||||
toTable: toTable?.tableName,
|
||||
});
|
||||
|
||||
if (!fromConnection || !toConnection || !fromTable || !toTable) {
|
||||
console.warn("⚠️ 필수 정보 누락:", {
|
||||
fromConnection: !!fromConnection,
|
||||
toConnection: !!toConnection,
|
||||
fromTable: !!fromTable,
|
||||
toTable: !!toTable,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
console.log("📡 API 호출 시작:", {
|
||||
fromAPI: `getColumnsFromConnection(${fromConnection.id}, "${fromTable.tableName}")`,
|
||||
toAPI: `getColumnsFromConnection(${toConnection.id}, "${toTable.tableName}")`,
|
||||
});
|
||||
|
||||
const [fromCols, toCols] = await Promise.all([
|
||||
getColumnsFromConnection(fromConnection.id, fromTable.tableName),
|
||||
getColumnsFromConnection(toConnection.id, toTable.tableName),
|
||||
]);
|
||||
|
||||
console.log("🔍 원본 API 응답 확인:", {
|
||||
fromCols: fromCols,
|
||||
toCols: toCols,
|
||||
fromType: typeof fromCols,
|
||||
toType: typeof toCols,
|
||||
fromIsArray: Array.isArray(fromCols),
|
||||
toIsArray: Array.isArray(toCols),
|
||||
});
|
||||
|
||||
// 안전한 배열 처리
|
||||
const safeFromCols = Array.isArray(fromCols) ? fromCols : [];
|
||||
const safeToCols = Array.isArray(toCols) ? toCols : [];
|
||||
|
||||
console.log("✅ 컬럼 로딩 성공:", {
|
||||
fromColumns: safeFromCols.length,
|
||||
toColumns: safeToCols.length,
|
||||
fromData: safeFromCols.slice(0, 2), // 처음 2개만 로깅
|
||||
toData: safeToCols.slice(0, 2),
|
||||
originalFromType: typeof fromCols,
|
||||
originalToType: typeof toCols,
|
||||
});
|
||||
|
||||
setFromColumns(safeFromCols);
|
||||
setToColumns(safeToCols);
|
||||
} catch (error) {
|
||||
console.error("❌ 컬럼 정보 로드 실패:", error);
|
||||
toast.error("필드 정보를 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadColumns();
|
||||
}, [fromConnection, toConnection, fromTable, toTable]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<Loader2 className="mr-2 h-6 w-6 animate-spin" />
|
||||
<span>필드 정보를 불러오는 중...</span>
|
||||
</CardContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Link className="h-5 w-5" />
|
||||
3단계: 컬럼 매핑
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex h-full flex-col p-0">
|
||||
{/* 매핑 캔버스 - 전체 영역 사용 */}
|
||||
<div className="min-h-0 flex-1 p-4">
|
||||
{isLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-muted-foreground">컬럼 정보를 불러오는 중...</div>
|
||||
</div>
|
||||
) : fromColumns.length > 0 && toColumns.length > 0 ? (
|
||||
<FieldMappingCanvas
|
||||
fromFields={fromColumns}
|
||||
toFields={toColumns}
|
||||
mappings={fieldMappings}
|
||||
onCreateMapping={onCreateMapping}
|
||||
onDeleteMapping={onDeleteMapping}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-3">
|
||||
<div className="text-muted-foreground">컬럼 정보를 찾을 수 없습니다.</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
FROM 컬럼: {fromColumns.length}개, TO 컬럼: {toColumns.length}개
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
console.log("🔄 수동 재로딩 시도");
|
||||
setFromColumns([]);
|
||||
setToColumns([]);
|
||||
// useEffect가 재실행되도록 강제 업데이트
|
||||
setIsLoading(true);
|
||||
setTimeout(() => setIsLoading(false), 100);
|
||||
}}
|
||||
>
|
||||
다시 시도
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 하단 네비게이션 - 고정 */}
|
||||
<div className="flex-shrink-0 border-t bg-white p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="outline" onClick={onBack} className="flex items-center gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
이전
|
||||
</Button>
|
||||
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{fieldMappings.length > 0 ? `${fieldMappings.length}개 매핑 완료` : "컬럼을 선택해서 매핑하세요"}
|
||||
</div>
|
||||
|
||||
<Button onClick={onNext} disabled={fieldMappings.length === 0} className="flex items-center gap-2">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldMappingStep;
|
||||
|
|
@ -0,0 +1,700 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
Trash2,
|
||||
Copy,
|
||||
Settings2,
|
||||
ArrowLeft,
|
||||
Save,
|
||||
Play,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// API import (컬럼 로드는 중앙에서 관리)
|
||||
|
||||
// 타입 import
|
||||
import { ColumnInfo, Connection, TableInfo } from "@/lib/types/multiConnection";
|
||||
import { ActionGroup, SingleAction, FieldMapping } from "../types/redesigned";
|
||||
|
||||
// 컴포넌트 import
|
||||
import ActionConditionBuilder from "./ActionConfig/ActionConditionBuilder";
|
||||
import FieldMappingCanvas from "./VisualMapping/FieldMappingCanvas";
|
||||
|
||||
interface MultiActionConfigStepProps {
|
||||
fromTable?: TableInfo;
|
||||
toTable?: TableInfo;
|
||||
fromConnection?: Connection;
|
||||
toConnection?: Connection;
|
||||
// 컬럼 정보 (중앙에서 관리) 🔧 추가
|
||||
fromColumns?: ColumnInfo[];
|
||||
toColumns?: ColumnInfo[];
|
||||
// 제어 조건 관련
|
||||
controlConditions: any[];
|
||||
onUpdateControlCondition: (index: number, condition: any) => void;
|
||||
onDeleteControlCondition: (index: number) => void;
|
||||
onAddControlCondition: () => void;
|
||||
// 액션 그룹 관련
|
||||
actionGroups: ActionGroup[];
|
||||
groupsLogicalOperator?: "AND" | "OR";
|
||||
onUpdateActionGroup: (groupId: string, updates: Partial<ActionGroup>) => void;
|
||||
onDeleteActionGroup: (groupId: string) => void;
|
||||
onAddActionGroup: () => void;
|
||||
onAddActionToGroup: (groupId: string) => void;
|
||||
onUpdateActionInGroup: (groupId: string, actionId: string, updates: Partial<SingleAction>) => void;
|
||||
onDeleteActionFromGroup: (groupId: string, actionId: string) => void;
|
||||
onSetGroupsLogicalOperator?: (operator: "AND" | "OR") => void;
|
||||
// 필드 매핑 관련
|
||||
fieldMappings: FieldMapping[];
|
||||
onCreateMapping: (fromField: ColumnInfo, toField: ColumnInfo) => void;
|
||||
onDeleteMapping: (mappingId: string) => void;
|
||||
// 네비게이션
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
// 컬럼 로드 액션
|
||||
onLoadColumns?: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎯 4단계: 통합된 멀티 액션 설정
|
||||
* - 제어 조건 설정
|
||||
* - 여러 액션 그룹 관리
|
||||
* - AND/OR 논리 연산자
|
||||
* - 액션별 조건 설정
|
||||
* - INSERT 액션 시 컬럼 매핑
|
||||
*/
|
||||
const MultiActionConfigStep: React.FC<MultiActionConfigStepProps> = ({
|
||||
fromTable,
|
||||
toTable,
|
||||
fromConnection,
|
||||
toConnection,
|
||||
fromColumns = [], // 🔧 중앙에서 관리되는 컬럼 정보
|
||||
toColumns = [], // 🔧 중앙에서 관리되는 컬럼 정보
|
||||
controlConditions,
|
||||
onUpdateControlCondition,
|
||||
onDeleteControlCondition,
|
||||
onAddControlCondition,
|
||||
actionGroups,
|
||||
groupsLogicalOperator = "AND",
|
||||
onUpdateActionGroup,
|
||||
onDeleteActionGroup,
|
||||
onAddActionGroup,
|
||||
onAddActionToGroup,
|
||||
onUpdateActionInGroup,
|
||||
onDeleteActionFromGroup,
|
||||
onSetGroupsLogicalOperator,
|
||||
fieldMappings,
|
||||
onCreateMapping,
|
||||
onDeleteMapping,
|
||||
onNext,
|
||||
onBack,
|
||||
onLoadColumns,
|
||||
}) => {
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(["group_1"])); // 첫 번째 그룹은 기본 열림
|
||||
const [activeTab, setActiveTab] = useState<"control" | "actions" | "mapping">("control"); // 현재 활성 탭
|
||||
|
||||
// 컬럼 로딩 상태 확인
|
||||
const isColumnsLoaded = fromColumns.length > 0 && toColumns.length > 0;
|
||||
|
||||
// 컴포넌트 마운트 시 컬럼 로드
|
||||
useEffect(() => {
|
||||
if (!isColumnsLoaded && fromConnection && toConnection && fromTable && toTable && onLoadColumns) {
|
||||
console.log("🔄 MultiActionConfigStep: 컬럼 로드 시작");
|
||||
onLoadColumns();
|
||||
}
|
||||
}, [isColumnsLoaded, fromConnection?.id, toConnection?.id, fromTable?.tableName, toTable?.tableName]);
|
||||
|
||||
// 그룹 확장/축소 토글
|
||||
const toggleGroupExpansion = (groupId: string) => {
|
||||
setExpandedGroups((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(groupId)) {
|
||||
newSet.delete(groupId);
|
||||
} else {
|
||||
newSet.add(groupId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
// 액션 타입별 아이콘
|
||||
const getActionTypeIcon = (actionType: string) => {
|
||||
switch (actionType) {
|
||||
case "insert":
|
||||
return "➕";
|
||||
case "update":
|
||||
return "✏️";
|
||||
case "delete":
|
||||
return "🗑️";
|
||||
case "upsert":
|
||||
return "🔄";
|
||||
default:
|
||||
return "⚙️";
|
||||
}
|
||||
};
|
||||
|
||||
// 논리 연산자별 색상
|
||||
const getLogicalOperatorColor = (operator: string) => {
|
||||
switch (operator) {
|
||||
case "AND":
|
||||
return "bg-blue-100 text-blue-800";
|
||||
case "OR":
|
||||
return "bg-orange-100 text-orange-800";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
// INSERT 액션이 있는지 확인
|
||||
const hasInsertActions = actionGroups.some((group) =>
|
||||
group.actions.some((action) => action.actionType === "insert" && action.isEnabled),
|
||||
);
|
||||
|
||||
// 탭 정보 (컬럼 매핑 탭 제거)
|
||||
const tabs = [
|
||||
{ id: "control" as const, label: "제어 조건", icon: "🎯", description: "전체 제어 실행 조건" },
|
||||
{ id: "actions" as const, label: "액션 설정", icon: "⚙️", description: "액션 그룹 및 실행 조건" },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Settings2 className="h-5 w-5" />
|
||||
4단계: 액션 및 매핑 설정
|
||||
</CardTitle>
|
||||
<p className="text-muted-foreground text-sm">제어 조건, 액션 그룹, 필드 매핑을 설정하세요</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex h-full flex-col p-4">
|
||||
{/* 탭 헤더 */}
|
||||
<div className="mb-4 flex border-b">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === tab.id
|
||||
? "border-primary text-primary border-b-2"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<span>{tab.icon}</span>
|
||||
<span>{tab.label}</span>
|
||||
{tab.id === "actions" && (
|
||||
<Badge variant="outline" className="ml-1 text-xs">
|
||||
{actionGroups.filter((g) => g.isEnabled).length}
|
||||
</Badge>
|
||||
)}
|
||||
{tab.id === "mapping" && hasInsertActions && (
|
||||
<Badge variant="outline" className="ml-1 text-xs">
|
||||
{fieldMappings.length}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 탭 설명 */}
|
||||
<div className="bg-muted/30 mb-4 rounded-md p-3">
|
||||
<p className="text-muted-foreground text-sm">{tabs.find((tab) => tab.id === activeTab)?.description}</p>
|
||||
</div>
|
||||
|
||||
{/* 탭별 컨텐츠 */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{activeTab === "control" && (
|
||||
<div className="space-y-4">
|
||||
{/* 제어 조건 섹션 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium">제어 조건</h3>
|
||||
<Button onClick={onAddControlCondition} size="sm" className="flex items-center gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
조건 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{controlConditions.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-gray-300 p-8 text-center">
|
||||
<div className="text-muted-foreground">
|
||||
<AlertTriangle className="mx-auto mb-2 h-8 w-8" />
|
||||
<p className="mb-2">제어 조건이 없습니다</p>
|
||||
<p className="text-sm">조건을 추가하면 해당 조건이 충족될 때만 액션이 실행됩니다</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{controlConditions.map((condition, index) => (
|
||||
<div key={index} className="flex items-center gap-3 rounded-md border p-3">
|
||||
<span className="text-muted-foreground text-sm">조건 {index + 1}</span>
|
||||
<div className="flex-1">
|
||||
{/* 여기에 조건 편집 컴포넌트 추가 */}
|
||||
<div className="text-muted-foreground text-sm">조건 설정: {JSON.stringify(condition)}</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => onDeleteControlCondition(index)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "actions" && (
|
||||
<div className="space-y-4">
|
||||
{/* 액션 그룹 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-medium">액션 그룹</h3>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{actionGroups.filter((g) => g.isEnabled).length}개 활성화
|
||||
</Badge>
|
||||
</div>
|
||||
<Button onClick={onAddActionGroup} size="sm" className="flex items-center gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
그룹 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 그룹 간 논리 연산자 선택 */}
|
||||
{actionGroups.length > 1 && (
|
||||
<div className="rounded-md border bg-blue-50 p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<h4 className="text-sm font-medium text-blue-900">그룹 간 실행 조건</h4>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
id="groups-and"
|
||||
name="groups-operator"
|
||||
checked={groupsLogicalOperator === "AND"}
|
||||
onChange={() => onSetGroupsLogicalOperator?.("AND")}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<label htmlFor="groups-and" className="text-sm text-blue-800">
|
||||
<span className="font-medium">AND</span> - 모든 그룹의 조건이 참일 때 실행
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
id="groups-or"
|
||||
name="groups-operator"
|
||||
checked={groupsLogicalOperator === "OR"}
|
||||
onChange={() => onSetGroupsLogicalOperator?.("OR")}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<label htmlFor="groups-or" className="text-sm text-blue-800">
|
||||
<span className="font-medium">OR</span> - 하나 이상의 그룹 조건이 참일 때 실행
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 액션 그룹 목록 */}
|
||||
<div className="space-y-4">
|
||||
{actionGroups.map((group, groupIndex) => (
|
||||
<div key={group.id}>
|
||||
{/* 그룹 간 논리 연산자 표시 (첫 번째 그룹 제외) */}
|
||||
{groupIndex > 0 && (
|
||||
<div className="my-2 flex items-center justify-center">
|
||||
<div
|
||||
className={`rounded px-2 py-1 text-xs font-medium ${
|
||||
groupsLogicalOperator === "AND"
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-orange-100 text-orange-800"
|
||||
}`}
|
||||
>
|
||||
{groupsLogicalOperator}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-card rounded-lg border">
|
||||
{/* 그룹 헤더 */}
|
||||
<Collapsible
|
||||
open={expandedGroups.has(group.id)}
|
||||
onOpenChange={() => toggleGroupExpansion(group.id)}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="hover:bg-muted/50 flex cursor-pointer items-center justify-between p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{expandedGroups.has(group.id) ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={group.name}
|
||||
onChange={(e) => onUpdateActionGroup(group.id, { name: e.target.value })}
|
||||
className="h-8 w-40"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<Badge className={getLogicalOperatorColor(group.logicalOperator)}>
|
||||
{group.logicalOperator}
|
||||
</Badge>
|
||||
<Badge variant={group.isEnabled ? "default" : "secondary"}>
|
||||
{group.actions.length}개 액션
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 그룹 논리 연산자 선택 */}
|
||||
<Select
|
||||
value={group.logicalOperator}
|
||||
onValueChange={(value: "AND" | "OR") =>
|
||||
onUpdateActionGroup(group.id, { logicalOperator: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-20" onClick={(e) => e.stopPropagation()}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="AND">AND</SelectItem>
|
||||
<SelectItem value="OR">OR</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 그룹 활성화/비활성화 */}
|
||||
<Switch
|
||||
checked={group.isEnabled}
|
||||
onCheckedChange={(checked) => onUpdateActionGroup(group.id, { isEnabled: checked })}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
{/* 그룹 삭제 */}
|
||||
{actionGroups.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteActionGroup(group.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
{/* 그룹 내용 */}
|
||||
<CollapsibleContent>
|
||||
<div className="bg-muted/20 border-t p-4">
|
||||
{/* 액션 추가 버튼 */}
|
||||
<div className="mb-4 flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onAddActionToGroup(group.id)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
액션 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 액션 목록 */}
|
||||
<div className="space-y-3">
|
||||
{group.actions.map((action, actionIndex) => (
|
||||
<div key={action.id} className="rounded-md border bg-white p-3">
|
||||
{/* 액션 헤더 */}
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg">{getActionTypeIcon(action.actionType)}</span>
|
||||
<Input
|
||||
value={action.name}
|
||||
onChange={(e) =>
|
||||
onUpdateActionInGroup(group.id, action.id, { name: e.target.value })
|
||||
}
|
||||
className="h-8 w-32"
|
||||
/>
|
||||
<Select
|
||||
value={action.actionType}
|
||||
onValueChange={(value: "insert" | "update" | "delete" | "upsert") =>
|
||||
onUpdateActionInGroup(group.id, action.id, { actionType: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="insert">INSERT</SelectItem>
|
||||
<SelectItem value="update">UPDATE</SelectItem>
|
||||
<SelectItem value="delete">DELETE</SelectItem>
|
||||
<SelectItem value="upsert">UPSERT</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={action.isEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onUpdateActionInGroup(group.id, action.id, { isEnabled: checked })
|
||||
}
|
||||
/>
|
||||
{group.actions.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDeleteActionFromGroup(group.id, action.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 액션 조건 설정 */}
|
||||
{isColumnsLoaded ? (
|
||||
<ActionConditionBuilder
|
||||
actionType={action.actionType}
|
||||
fromColumns={fromColumns}
|
||||
toColumns={toColumns}
|
||||
conditions={action.conditions}
|
||||
fieldMappings={(() => {
|
||||
// 필드값 설정용: FieldValueMapping 타입만 필터링
|
||||
const fieldValueMappings = (action.fieldMappings || []).filter(
|
||||
(mapping) =>
|
||||
mapping.valueType && // valueType이 있고
|
||||
!mapping.fromField && // fromField가 없고
|
||||
!mapping.toField, // toField가 없으면 FieldValueMapping
|
||||
);
|
||||
|
||||
console.log("📋 ActionConditionBuilder에 전달되는 필드값 설정:", {
|
||||
allMappings: action.fieldMappings,
|
||||
filteredFieldValueMappings: fieldValueMappings,
|
||||
});
|
||||
|
||||
return fieldValueMappings;
|
||||
})()}
|
||||
columnMappings={
|
||||
// 컬럼 매핑용: FieldMapping 타입만 필터링
|
||||
(action.fieldMappings || []).filter(
|
||||
(mapping) =>
|
||||
mapping.fromField &&
|
||||
mapping.toField &&
|
||||
mapping.fromField.columnName &&
|
||||
mapping.toField.columnName,
|
||||
)
|
||||
}
|
||||
onConditionsChange={(conditions) =>
|
||||
onUpdateActionInGroup(group.id, action.id, { conditions })
|
||||
}
|
||||
onFieldMappingsChange={(newFieldMappings) => {
|
||||
// 필드값 설정만 업데이트, 컬럼 매핑은 유지
|
||||
const existingColumnMappings = (action.fieldMappings || []).filter(
|
||||
(mapping) =>
|
||||
mapping.fromField &&
|
||||
mapping.toField &&
|
||||
mapping.fromField.columnName &&
|
||||
mapping.toField.columnName,
|
||||
);
|
||||
|
||||
console.log("🔄 필드값 설정 업데이트:", {
|
||||
existingColumnMappings,
|
||||
newFieldMappings,
|
||||
combined: [...existingColumnMappings, ...newFieldMappings],
|
||||
});
|
||||
|
||||
onUpdateActionInGroup(group.id, action.id, {
|
||||
fieldMappings: [...existingColumnMappings, ...newFieldMappings],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex items-center justify-center py-4">
|
||||
컬럼 정보를 불러오는 중...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* INSERT 액션일 때만 필드 매핑 UI 표시 */}
|
||||
{action.actionType === "insert" && isColumnsLoaded && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<h5 className="text-sm font-medium">필드 매핑</h5>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{action.fieldMappings?.length || 0}개 매핑
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 매핑 캔버스 */}
|
||||
<div className="rounded-lg border bg-white p-3">
|
||||
<FieldMappingCanvas
|
||||
fromFields={fromColumns}
|
||||
toFields={toColumns}
|
||||
mappings={
|
||||
// 컬럼 매핑만 FieldMappingCanvas에 전달
|
||||
(action.fieldMappings || []).filter(
|
||||
(mapping) =>
|
||||
mapping.fromField &&
|
||||
mapping.toField &&
|
||||
mapping.fromField.columnName &&
|
||||
mapping.toField.columnName,
|
||||
)
|
||||
}
|
||||
onCreateMapping={(fromField, toField) => {
|
||||
const newMapping = {
|
||||
id: `${fromField.columnName}_to_${toField.columnName}_${Date.now()}`,
|
||||
fromField,
|
||||
toField,
|
||||
isValid: true,
|
||||
validationMessage: undefined,
|
||||
};
|
||||
|
||||
// 기존 필드값 설정은 유지하고 새 컬럼 매핑만 추가
|
||||
const existingFieldValueMappings = (action.fieldMappings || []).filter(
|
||||
(mapping) =>
|
||||
mapping.valueType && // valueType이 있고
|
||||
!mapping.fromField && // fromField가 없고
|
||||
!mapping.toField, // toField가 없으면 FieldValueMapping
|
||||
);
|
||||
|
||||
const existingColumnMappings = (action.fieldMappings || []).filter(
|
||||
(mapping) =>
|
||||
mapping.fromField &&
|
||||
mapping.toField &&
|
||||
mapping.fromField.columnName &&
|
||||
mapping.toField.columnName,
|
||||
);
|
||||
|
||||
onUpdateActionInGroup(group.id, action.id, {
|
||||
fieldMappings: [
|
||||
...existingFieldValueMappings,
|
||||
...existingColumnMappings,
|
||||
newMapping,
|
||||
],
|
||||
});
|
||||
}}
|
||||
onDeleteMapping={(mappingId) => {
|
||||
// 컬럼 매핑만 삭제하고 필드값 설정은 유지
|
||||
const remainingMappings = (action.fieldMappings || []).filter(
|
||||
(mapping) => mapping.id !== mappingId,
|
||||
);
|
||||
|
||||
onUpdateActionInGroup(group.id, action.id, {
|
||||
fieldMappings: remainingMappings,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 매핑되지 않은 필드 처리 옵션 */}
|
||||
<div className="rounded-md border bg-yellow-50 p-3">
|
||||
<h6 className="mb-2 flex items-center gap-1 text-xs font-medium text-yellow-800">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
매핑되지 않은 필드 처리
|
||||
</h6>
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
id={`empty-${action.id}`}
|
||||
name={`unmapped-${action.id}`}
|
||||
defaultChecked
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
<label htmlFor={`empty-${action.id}`} className="text-yellow-700">
|
||||
비워두기 (NULL 값)
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
id={`default-${action.id}`}
|
||||
name={`unmapped-${action.id}`}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
<label htmlFor={`default-${action.id}`} className="text-yellow-700">
|
||||
기본값 사용
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
id={`skip-${action.id}`}
|
||||
name={`unmapped-${action.id}`}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
<label htmlFor={`skip-${action.id}`} className="text-yellow-700">
|
||||
필드 제외
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 그룹 로직 설명 */}
|
||||
<div className="mt-4 rounded-md bg-blue-50 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 text-blue-600" />
|
||||
<div className="text-sm">
|
||||
<div className="font-medium text-blue-900">{group.logicalOperator} 조건 그룹</div>
|
||||
<div className="text-blue-700">
|
||||
{group.logicalOperator === "AND"
|
||||
? "이 그룹의 모든 액션이 실행 가능한 조건일 때만 실행됩니다."
|
||||
: "이 그룹의 액션 중 하나라도 실행 가능한 조건이면 해당 액션만 실행됩니다."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 하단 네비게이션 */}
|
||||
<div className="flex-shrink-0 border-t pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="outline" onClick={onBack} className="flex items-center gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
이전
|
||||
</Button>
|
||||
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{actionGroups.filter((g) => g.isEnabled).length}개 그룹, 총{" "}
|
||||
{actionGroups.reduce((sum, g) => sum + g.actions.filter((a) => a.isEnabled).length, 0)}개 액션
|
||||
</div>
|
||||
|
||||
<Button onClick={onNext} className="flex items-center gap-2">
|
||||
<Save className="h-4 w-4" />
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiActionConfigStep;
|
||||
|
|
@ -0,0 +1,285 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Globe } from "lucide-react";
|
||||
|
||||
// 타입 import
|
||||
import { RightPanelProps } from "../types/redesigned";
|
||||
|
||||
// 컴포넌트 import
|
||||
import StepProgress from "./StepProgress";
|
||||
import ConnectionStep from "./ConnectionStep";
|
||||
import TableStep from "./TableStep";
|
||||
import FieldMappingStep from "./FieldMappingStep";
|
||||
import ControlConditionStep from "./ControlConditionStep";
|
||||
import ActionConfigStep from "./ActionConfigStep";
|
||||
import MultiActionConfigStep from "./MultiActionConfigStep";
|
||||
import ExternalCallPanel from "../../../external-call/ExternalCallPanel";
|
||||
|
||||
/**
|
||||
* 🎯 우측 패널 (70% 너비)
|
||||
* - 단계별 진행 UI
|
||||
* - 연결 → 테이블 → 필드 매핑
|
||||
* - 시각적 매핑 영역
|
||||
*/
|
||||
const RightPanel: React.FC<RightPanelProps> = ({ state, actions }) => {
|
||||
console.log("🔄 [RightPanel] 컴포넌트 렌더링 - connectionType:", state.connectionType);
|
||||
|
||||
// connectionType 변경 감지
|
||||
useEffect(() => {
|
||||
console.log("🔄 [RightPanel] connectionType 변경됨:", state.connectionType);
|
||||
}, [state.connectionType]);
|
||||
// 완료된 단계 계산
|
||||
const completedSteps: number[] = [];
|
||||
|
||||
if (state.fromConnection && state.toConnection) {
|
||||
completedSteps.push(1);
|
||||
}
|
||||
|
||||
if (state.fromTable && state.toTable) {
|
||||
completedSteps.push(2);
|
||||
}
|
||||
|
||||
// 새로운 단계 순서에 따른 완료 조건
|
||||
const needsFieldMapping = state.actionType === "insert" || state.actionType === "upsert";
|
||||
|
||||
// 3단계: 제어 조건 (테이블 선택 후 바로 접근 가능)
|
||||
if (state.fromTable && state.toTable) {
|
||||
completedSteps.push(3);
|
||||
}
|
||||
|
||||
// 4단계: 액션 설정
|
||||
if (state.actionType) {
|
||||
completedSteps.push(4);
|
||||
}
|
||||
|
||||
// 5단계: 컬럼 매핑 (INSERT/UPSERT인 경우에만)
|
||||
if (needsFieldMapping && state.fieldMappings.length > 0) {
|
||||
completedSteps.push(5);
|
||||
}
|
||||
|
||||
const renderCurrentStep = () => {
|
||||
try {
|
||||
// 외부호출인 경우 단계 무시하고 바로 외부호출 설정 화면 표시
|
||||
console.log("🔍 [RightPanel] renderCurrentStep - connectionType:", state.connectionType);
|
||||
if (state.connectionType === "external_call") {
|
||||
console.log("✅ [RightPanel] 외부호출 화면 렌더링");
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="flex-shrink-0 px-4 py-2">
|
||||
<div className="flex items-center gap-3 border-b pb-2">
|
||||
<Globe className="h-5 w-5 text-blue-600" />
|
||||
<h2 className="text-lg font-semibold">외부 호출 설정</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
REST API 호출을 통해 외부 시스템에 데이터를 전송하거나 알림을 보낼 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 관계명 및 설명 입력 */}
|
||||
<div className="flex-shrink-0 px-4 pb-2">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium">관계명 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={state.relationshipName || ""}
|
||||
onChange={(e) => actions.setRelationshipName(e.target.value)}
|
||||
placeholder="외부호출 관계의 이름을 입력하세요"
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">설명</label>
|
||||
<textarea
|
||||
value={state.description || ""}
|
||||
onChange={(e) => actions.setDescription(e.target.value)}
|
||||
placeholder="외부호출의 용도나 설명을 입력하세요"
|
||||
rows={2}
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 외부호출 패널 - 공간 최적화 */}
|
||||
<div className="flex-1 overflow-hidden px-4">
|
||||
<div className="h-full max-h-[calc(100vh-400px)] overflow-y-auto">
|
||||
<ExternalCallPanel
|
||||
relationshipId={`external-call-${Date.now()}`}
|
||||
readonly={false}
|
||||
initialSettings={
|
||||
state.externalCallConfig || {
|
||||
callType: "rest-api",
|
||||
restApiSettings: {
|
||||
apiUrl: "",
|
||||
httpMethod: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
bodyTemplate: "",
|
||||
authentication: { type: "none" },
|
||||
timeout: 30000,
|
||||
retryCount: 3,
|
||||
},
|
||||
}
|
||||
}
|
||||
onSettingsChange={actions.updateExternalCallConfig}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 하단 버튼 - 바로 붙여서 고정 */}
|
||||
<div className="flex-shrink-0 border-t bg-white px-4 py-2">
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={actions.saveMappings}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700"
|
||||
disabled={state.isLoading}
|
||||
>
|
||||
{state.isLoading ? "저장 중..." : "저장"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 데이터 저장인 경우에만 단계별 진행
|
||||
switch (state.currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
<ConnectionStep
|
||||
connectionType={state.connectionType}
|
||||
fromConnection={state.fromConnection}
|
||||
toConnection={state.toConnection}
|
||||
relationshipName={state.relationshipName}
|
||||
description={state.description}
|
||||
diagramId={state.diagramId} // 🔧 수정 모드 감지용
|
||||
onSelectConnection={actions.selectConnection}
|
||||
onSetRelationshipName={actions.setRelationshipName}
|
||||
onSetDescription={actions.setDescription}
|
||||
onNext={() => actions.goToStep(2)}
|
||||
/>
|
||||
);
|
||||
|
||||
case 2:
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<TableStep
|
||||
fromConnection={state.fromConnection}
|
||||
toConnection={state.toConnection}
|
||||
fromTable={state.fromTable}
|
||||
toTable={state.toTable}
|
||||
onSelectTable={actions.selectTable}
|
||||
onNext={() => actions.goToStep(3)} // 3단계(제어 조건)로
|
||||
onBack={() => actions.goToStep(1)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 3:
|
||||
// 데이터 저장인 경우 제어 조건 단계
|
||||
return (
|
||||
<ControlConditionStep
|
||||
state={state}
|
||||
actions={actions}
|
||||
onBack={() => actions.goToStep(2)}
|
||||
onNext={() => {
|
||||
// 4단계로 넘어가기 전에 컬럼 로드
|
||||
actions.loadColumns();
|
||||
actions.goToStep(4);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case 4:
|
||||
// 외부호출인 경우 4단계 없음
|
||||
if (state.connectionType === "external_call") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 4단계: 통합된 멀티 액션 설정 (제어 조건 + 액션 설정 + 컬럼 매핑)
|
||||
return (
|
||||
<MultiActionConfigStep
|
||||
fromTable={state.fromTable}
|
||||
toTable={state.toTable}
|
||||
fromConnection={state.fromConnection}
|
||||
toConnection={state.toConnection}
|
||||
fromColumns={state.fromColumns} // 🔧 중앙에서 관리되는 컬럼 정보
|
||||
toColumns={state.toColumns} // 🔧 중앙에서 관리되는 컬럼 정보
|
||||
controlConditions={state.controlConditions}
|
||||
onUpdateControlCondition={actions.updateControlCondition}
|
||||
onDeleteControlCondition={actions.deleteControlCondition}
|
||||
onAddControlCondition={actions.addControlCondition}
|
||||
actionGroups={state.actionGroups}
|
||||
groupsLogicalOperator={state.groupsLogicalOperator}
|
||||
onUpdateActionGroup={actions.updateActionGroup}
|
||||
onDeleteActionGroup={actions.deleteActionGroup}
|
||||
onAddActionGroup={actions.addActionGroup}
|
||||
onAddActionToGroup={actions.addActionToGroup}
|
||||
onUpdateActionInGroup={actions.updateActionInGroup}
|
||||
onDeleteActionFromGroup={actions.deleteActionFromGroup}
|
||||
onSetGroupsLogicalOperator={actions.setGroupsLogicalOperator}
|
||||
fieldMappings={state.fieldMappings}
|
||||
onCreateMapping={actions.createMapping}
|
||||
onDeleteMapping={actions.deleteMapping}
|
||||
onLoadColumns={actions.loadColumns}
|
||||
onNext={() => {
|
||||
// 완료 처리 - 저장 및 상위 컴포넌트 알림
|
||||
actions.saveMappings();
|
||||
}}
|
||||
onBack={() => actions.goToStep(3)}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ [RightPanel] renderCurrentStep 에러:", error);
|
||||
return <div>renderCurrentStep 에러: {String(error)}</div>;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
console.log("🎯 [RightPanel] return 시작 - connectionType:", state.connectionType);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 단계 진행 표시 - 외부호출이 아닐 때만 */}
|
||||
{state.connectionType !== "external_call" && (
|
||||
<div className="bg-card/50 border-b p-3">
|
||||
<StepProgress
|
||||
currentStep={state.currentStep}
|
||||
completedSteps={completedSteps}
|
||||
onStepClick={actions.goToStep}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 현재 단계 컨텐츠 */}
|
||||
<div className="min-h-0 flex-1 p-3">
|
||||
{(() => {
|
||||
console.log("🎯 [RightPanel] 조건부 렌더링 체크 - connectionType:", state.connectionType);
|
||||
console.log("🎯 [RightPanel] external_call인가?", state.connectionType === "external_call");
|
||||
|
||||
if (state.connectionType === "external_call") {
|
||||
console.log("🎯 [RightPanel] 외부호출 렌더링 시작");
|
||||
return renderCurrentStep();
|
||||
} else {
|
||||
console.log("🎯 [RightPanel] 데이터 저장 렌더링 시작");
|
||||
return <Card className="flex h-full flex-col overflow-hidden">{renderCurrentStep()}</Card>;
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("❌ [RightPanel] 렌더링 에러:", error);
|
||||
return <div>렌더링 에러 발생: {String(error)}</div>;
|
||||
}
|
||||
};
|
||||
|
||||
export default RightPanel;
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CheckCircle, Circle, ArrowRight } from "lucide-react";
|
||||
|
||||
// 타입 import
|
||||
import { StepProgressProps } from "../types/redesigned";
|
||||
|
||||
/**
|
||||
* 📊 단계 진행 표시
|
||||
* - 현재 단계 하이라이트
|
||||
* - 완료된 단계 체크 표시
|
||||
* - 클릭으로 단계 이동
|
||||
*/
|
||||
const StepProgress: React.FC<StepProgressProps> = ({ currentStep, completedSteps, onStepClick }) => {
|
||||
const steps = [
|
||||
{ number: 1, title: "연결 선택", description: "FROM/TO 데이터베이스 연결" },
|
||||
{ number: 2, title: "테이블 선택", description: "소스/대상 테이블 선택" },
|
||||
{ number: 3, title: "제어 조건", description: "전체 제어 실행 조건 설정" },
|
||||
{ number: 4, title: "액션 및 매핑", description: "액션 설정 및 컬럼 매핑" },
|
||||
];
|
||||
|
||||
const getStepStatus = (stepNumber: number) => {
|
||||
if (completedSteps.includes(stepNumber)) return "completed";
|
||||
if (stepNumber === currentStep) return "current";
|
||||
return "pending";
|
||||
};
|
||||
|
||||
const getStepIcon = (stepNumber: number) => {
|
||||
const status = getStepStatus(stepNumber);
|
||||
|
||||
if (status === "completed") {
|
||||
return <CheckCircle className="h-5 w-5 text-green-600" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Circle className={`h-5 w-5 ${status === "current" ? "text-primary fill-primary" : "text-muted-foreground"}`} />
|
||||
);
|
||||
};
|
||||
|
||||
const canClickStep = (stepNumber: number) => {
|
||||
// 현재 단계이거나 완료된 단계만 클릭 가능
|
||||
return stepNumber === currentStep || completedSteps.includes(stepNumber);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-4xl items-center justify-between">
|
||||
{steps.map((step, index) => (
|
||||
<React.Fragment key={step.number}>
|
||||
{/* 단계 */}
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`flex h-auto items-center gap-3 p-3 ${
|
||||
canClickStep(step.number) ? "hover:bg-muted/50 cursor-pointer" : "cursor-default"
|
||||
}`}
|
||||
onClick={() => canClickStep(step.number) && onStepClick(step.number as 1 | 2 | 3 | 4 | 5)}
|
||||
disabled={!canClickStep(step.number)}
|
||||
>
|
||||
{/* 아이콘 */}
|
||||
<div className="flex-shrink-0">{getStepIcon(step.number)}</div>
|
||||
|
||||
{/* 텍스트 */}
|
||||
<div className="text-left">
|
||||
<div
|
||||
className={`text-sm font-medium ${
|
||||
getStepStatus(step.number) === "current"
|
||||
? "text-primary"
|
||||
: getStepStatus(step.number) === "completed"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{step.title}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">{step.description}</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 화살표 (마지막 단계 제외) */}
|
||||
{index < steps.length - 1 && <ArrowRight className="text-muted-foreground mx-2 h-4 w-4" />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StepProgress;
|
||||
|
|
@ -0,0 +1,343 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ArrowLeft, ArrowRight, Table, Search, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// API import
|
||||
import { getTablesFromConnection, getBatchTablesWithColumns } from "@/lib/api/multiConnection";
|
||||
|
||||
// 타입 import
|
||||
import { Connection, TableInfo } from "@/lib/types/multiConnection";
|
||||
|
||||
interface TableStepProps {
|
||||
fromConnection?: Connection;
|
||||
toConnection?: Connection;
|
||||
fromTable?: TableInfo;
|
||||
toTable?: TableInfo;
|
||||
onSelectTable: (type: "from" | "to", table: TableInfo) => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 📋 2단계: 테이블 선택
|
||||
* - FROM/TO 테이블 선택
|
||||
* - 테이블 검색 기능
|
||||
* - 컬럼 수 정보 표시
|
||||
*/
|
||||
const TableStep: React.FC<TableStepProps> = ({
|
||||
fromConnection,
|
||||
toConnection,
|
||||
fromTable,
|
||||
toTable,
|
||||
onSelectTable,
|
||||
onNext,
|
||||
onBack,
|
||||
}) => {
|
||||
const [fromTables, setFromTables] = useState<TableInfo[]>([]);
|
||||
const [toTables, setToTables] = useState<TableInfo[]>([]);
|
||||
const [fromSearch, setFromSearch] = useState("");
|
||||
const [toSearch, setToSearch] = useState("");
|
||||
const [isLoadingFrom, setIsLoadingFrom] = useState(false);
|
||||
const [isLoadingTo, setIsLoadingTo] = useState(false);
|
||||
const [tableColumnCounts, setTableColumnCounts] = useState<Record<string, number>>({});
|
||||
|
||||
// FROM 테이블 목록 로드 (배치 조회)
|
||||
useEffect(() => {
|
||||
if (fromConnection) {
|
||||
const loadFromTables = async () => {
|
||||
try {
|
||||
setIsLoadingFrom(true);
|
||||
console.log("🚀 FROM 테이블 배치 조회 시작");
|
||||
|
||||
// 배치 조회로 테이블 정보와 컬럼 수를 한번에 가져오기
|
||||
const batchResult = await getBatchTablesWithColumns(fromConnection.id);
|
||||
|
||||
console.log("✅ FROM 테이블 배치 조회 완료:", batchResult);
|
||||
|
||||
// TableInfo 형식으로 변환
|
||||
const tables: TableInfo[] = batchResult.map((item) => ({
|
||||
tableName: item.tableName,
|
||||
displayName: item.displayName || item.tableName,
|
||||
}));
|
||||
|
||||
setFromTables(tables);
|
||||
|
||||
// 컬럼 수 정보를 state에 저장
|
||||
const columnCounts: Record<string, number> = {};
|
||||
batchResult.forEach((item) => {
|
||||
columnCounts[`from_${item.tableName}`] = item.columnCount;
|
||||
});
|
||||
|
||||
setTableColumnCounts((prev) => ({
|
||||
...prev,
|
||||
...columnCounts,
|
||||
}));
|
||||
|
||||
console.log(`📊 FROM 테이블 ${tables.length}개 로드 완료, 컬럼 수:`, columnCounts);
|
||||
} catch (error) {
|
||||
console.error("FROM 테이블 목록 로드 실패:", error);
|
||||
toast.error("소스 테이블 목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setIsLoadingFrom(false);
|
||||
}
|
||||
};
|
||||
loadFromTables();
|
||||
}
|
||||
}, [fromConnection]);
|
||||
|
||||
// TO 테이블 목록 로드 (배치 조회)
|
||||
useEffect(() => {
|
||||
if (toConnection) {
|
||||
const loadToTables = async () => {
|
||||
try {
|
||||
setIsLoadingTo(true);
|
||||
console.log("🚀 TO 테이블 배치 조회 시작");
|
||||
|
||||
// 배치 조회로 테이블 정보와 컬럼 수를 한번에 가져오기
|
||||
const batchResult = await getBatchTablesWithColumns(toConnection.id);
|
||||
|
||||
console.log("✅ TO 테이블 배치 조회 완료:", batchResult);
|
||||
|
||||
// TableInfo 형식으로 변환
|
||||
const tables: TableInfo[] = batchResult.map((item) => ({
|
||||
tableName: item.tableName,
|
||||
displayName: item.displayName || item.tableName,
|
||||
}));
|
||||
|
||||
setToTables(tables);
|
||||
|
||||
// 컬럼 수 정보를 state에 저장
|
||||
const columnCounts: Record<string, number> = {};
|
||||
batchResult.forEach((item) => {
|
||||
columnCounts[`to_${item.tableName}`] = item.columnCount;
|
||||
});
|
||||
|
||||
setTableColumnCounts((prev) => ({
|
||||
...prev,
|
||||
...columnCounts,
|
||||
}));
|
||||
|
||||
console.log(`📊 TO 테이블 ${tables.length}개 로드 완료, 컬럼 수:`, columnCounts);
|
||||
} catch (error) {
|
||||
console.error("TO 테이블 목록 로드 실패:", error);
|
||||
toast.error("대상 테이블 목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setIsLoadingTo(false);
|
||||
}
|
||||
};
|
||||
loadToTables();
|
||||
}
|
||||
}, [toConnection]);
|
||||
|
||||
// 테이블 필터링
|
||||
const filteredFromTables = fromTables.filter((table) =>
|
||||
(table.displayName || table.tableName).toLowerCase().includes(fromSearch.toLowerCase()),
|
||||
);
|
||||
|
||||
const filteredToTables = toTables.filter((table) =>
|
||||
(table.displayName || table.tableName).toLowerCase().includes(toSearch.toLowerCase()),
|
||||
);
|
||||
|
||||
const handleTableSelect = (type: "from" | "to", tableName: string) => {
|
||||
const tables = type === "from" ? fromTables : toTables;
|
||||
const table = tables.find((t) => t.tableName === tableName);
|
||||
if (table) {
|
||||
onSelectTable(type, table);
|
||||
}
|
||||
};
|
||||
|
||||
const canProceed = fromTable && toTable;
|
||||
|
||||
const renderTableItem = (table: TableInfo, type: "from" | "to") => {
|
||||
const displayName =
|
||||
table.displayName && table.displayName !== table.tableName ? table.displayName : table.tableName;
|
||||
|
||||
const columnCount = tableColumnCounts[`${type}_${table.tableName}`];
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Table className="h-4 w-4" />
|
||||
<span>{displayName}</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{columnCount !== undefined ? columnCount : table.columnCount || 0}개 컬럼
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Table className="h-5 w-5" />
|
||||
2단계: 테이블 선택
|
||||
</CardTitle>
|
||||
<p className="text-muted-foreground text-sm">연결된 데이터베이스에서 소스와 대상 테이블을 선택하세요.</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="max-h-[calc(100vh-400px)] min-h-[400px] space-y-6 overflow-y-auto">
|
||||
{/* FROM 테이블 선택 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium">FROM 테이블 (소스)</h3>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{fromConnection?.name}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 검색 */}
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
|
||||
<Input
|
||||
placeholder="테이블 검색..."
|
||||
value={fromSearch}
|
||||
onChange={(e) => setFromSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 테이블 선택 */}
|
||||
{isLoadingFrom ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">테이블 목록 로드 중...</span>
|
||||
</div>
|
||||
) : (
|
||||
<Select value={fromTable?.tableName || ""} onValueChange={(value) => handleTableSelect("from", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="소스 테이블을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filteredFromTables.map((table) => (
|
||||
<SelectItem key={table.tableName} value={table.tableName}>
|
||||
{renderTableItem(table, "from")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{fromTable && (
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="font-medium">{fromTable.displayName || fromTable.tableName}</span>
|
||||
<Badge variant="secondary">
|
||||
📊 {tableColumnCounts[`from_${fromTable.tableName}`] || fromTable.columnCount || 0}개 컬럼
|
||||
</Badge>
|
||||
</div>
|
||||
{fromTable.description && <p className="text-muted-foreground text-xs">{fromTable.description}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* TO 테이블 선택 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium">TO 테이블 (대상)</h3>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{toConnection?.name}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 검색 */}
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
|
||||
<Input
|
||||
placeholder="테이블 검색..."
|
||||
value={toSearch}
|
||||
onChange={(e) => setToSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 테이블 선택 */}
|
||||
{isLoadingTo ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">테이블 목록 로드 중...</span>
|
||||
</div>
|
||||
) : (
|
||||
<Select value={toTable?.tableName || ""} onValueChange={(value) => handleTableSelect("to", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="대상 테이블을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filteredToTables.map((table) => (
|
||||
<SelectItem key={table.tableName} value={table.tableName}>
|
||||
{renderTableItem(table, "to")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{toTable && (
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="font-medium">{toTable.displayName || toTable.tableName}</span>
|
||||
<Badge variant="secondary">
|
||||
📊 {tableColumnCounts[`to_${toTable.tableName}`] || toTable.columnCount || 0}개 컬럼
|
||||
</Badge>
|
||||
</div>
|
||||
{toTable.description && <p className="text-muted-foreground text-xs">{toTable.description}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 테이블 매핑 표시 */}
|
||||
{fromTable && toTable && (
|
||||
<div className="bg-primary/5 border-primary/20 rounded-lg border p-4">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<div className="text-center">
|
||||
<div className="font-medium">{fromTable.displayName || fromTable.tableName}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{tableColumnCounts[`from_${fromTable.tableName}`] || fromTable.columnCount || 0}개 컬럼
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ArrowRight className="text-primary h-5 w-5" />
|
||||
|
||||
<div className="text-center">
|
||||
<div className="font-medium">{toTable.displayName || toTable.tableName}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{tableColumnCounts[`to_${toTable.tableName}`] || toTable.columnCount || 0}개 컬럼
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-center">
|
||||
<Badge variant="outline" className="text-primary">
|
||||
💡 테이블 매핑: {fromTable.displayName || fromTable.tableName} →{" "}
|
||||
{toTable.displayName || toTable.tableName}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 네비게이션 버튼 */}
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button variant="outline" onClick={onBack} className="flex items-center gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
이전: 연결 선택
|
||||
</Button>
|
||||
|
||||
<Button onClick={onNext} disabled={!canProceed} className="flex items-center gap-2">
|
||||
다음: 컬럼 매핑
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableStep;
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
interface ConnectionLineProps {
|
||||
id: string;
|
||||
fromX: number;
|
||||
fromY: number;
|
||||
toX: number;
|
||||
toY: number;
|
||||
isValid: boolean;
|
||||
mapping: any;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔗 SVG 연결선 컴포넌트
|
||||
* - 베지어 곡선으로 부드러운 연결선 표시
|
||||
* - 유효성에 따른 색상 변경
|
||||
* - 호버 시 삭제 버튼 표시
|
||||
*/
|
||||
const ConnectionLine: React.FC<ConnectionLineProps> = ({ id, fromX, fromY, toX, toY, isValid, mapping, onDelete }) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
// 베지어 곡선 제어점 계산
|
||||
const controlPointOffset = Math.abs(toX - fromX) * 0.5;
|
||||
const controlPoint1X = fromX + controlPointOffset;
|
||||
const controlPoint1Y = fromY;
|
||||
const controlPoint2X = toX - controlPointOffset;
|
||||
const controlPoint2Y = toY;
|
||||
|
||||
// 패스 생성
|
||||
const pathData = `M ${fromX} ${fromY} C ${controlPoint1X} ${controlPoint1Y}, ${controlPoint2X} ${controlPoint2Y}, ${toX} ${toY}`;
|
||||
|
||||
// 색상 결정
|
||||
const strokeColor = isValid
|
||||
? isHovered
|
||||
? "#10b981" // green-500 hover
|
||||
: "#22c55e" // green-500
|
||||
: isHovered
|
||||
? "#f97316" // orange-500 hover
|
||||
: "#fb923c"; // orange-400
|
||||
|
||||
// 중간점 계산 (삭제 버튼 위치)
|
||||
const midX = (fromX + toX) / 2;
|
||||
const midY = (fromY + toY) / 2;
|
||||
|
||||
return (
|
||||
<g>
|
||||
{/* 연결선 - 더 부드럽고 덜 방해되는 스타일 */}
|
||||
<path
|
||||
d={pathData}
|
||||
stroke={strokeColor}
|
||||
strokeWidth={isHovered ? "2.5" : "1.5"}
|
||||
fill="none"
|
||||
opacity={isHovered ? "0.9" : "0.6"}
|
||||
strokeDasharray="0"
|
||||
className="cursor-pointer transition-all duration-300"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
style={{ pointerEvents: "stroke" }}
|
||||
/>
|
||||
|
||||
{/* 연결선 위의 투명한 넓은 영역 (호버 감지용) */}
|
||||
<path
|
||||
d={pathData}
|
||||
stroke="transparent"
|
||||
strokeWidth="12"
|
||||
fill="none"
|
||||
className="cursor-pointer"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
style={{ pointerEvents: "stroke" }}
|
||||
/>
|
||||
|
||||
{/* 시작점 원 */}
|
||||
<circle
|
||||
cx={fromX}
|
||||
cy={fromY}
|
||||
r={isHovered ? "3.5" : "2.5"}
|
||||
fill={strokeColor}
|
||||
opacity={isHovered ? "0.9" : "0.7"}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
|
||||
{/* 끝점 원 */}
|
||||
<circle
|
||||
cx={toX}
|
||||
cy={toY}
|
||||
r={isHovered ? "3.5" : "2.5"}
|
||||
fill={strokeColor}
|
||||
opacity={isHovered ? "0.9" : "0.7"}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
|
||||
{/* 호버 시 삭제 버튼 */}
|
||||
{isHovered && (
|
||||
<g>
|
||||
{/* 삭제 버튼 배경 */}
|
||||
<circle
|
||||
cx={midX}
|
||||
cy={midY}
|
||||
r="12"
|
||||
fill="white"
|
||||
stroke={strokeColor}
|
||||
strokeWidth="2"
|
||||
className="cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
style={{ pointerEvents: "all" }}
|
||||
/>
|
||||
|
||||
{/* X 아이콘 */}
|
||||
<g
|
||||
transform={`translate(${midX - 4}, ${midY - 4})`}
|
||||
className="cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
style={{ pointerEvents: "all" }}
|
||||
>
|
||||
<path d="M1 1L7 7M7 1L1 7" stroke={strokeColor} strokeWidth="1.5" strokeLinecap="round" />
|
||||
</g>
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* 매핑 정보 툴팁 (호버 시) */}
|
||||
{isHovered && (
|
||||
<g>
|
||||
<rect
|
||||
x={midX - 60}
|
||||
y={midY - 35}
|
||||
width="120"
|
||||
height="20"
|
||||
rx="4"
|
||||
fill="rgba(0, 0, 0, 0.8)"
|
||||
style={{ pointerEvents: "none" }}
|
||||
/>
|
||||
<text x={midX} y={midY - 22} textAnchor="middle" fill="white" fontSize="10" style={{ pointerEvents: "none" }}>
|
||||
{mapping.fromField.webType} → {mapping.toField.webType}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectionLine;
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Link, GripVertical } from "lucide-react";
|
||||
|
||||
// 타입 import
|
||||
import { ColumnInfo } from "@/lib/types/multiConnection";
|
||||
|
||||
interface FieldColumnProps {
|
||||
fields: ColumnInfo[];
|
||||
type: "from" | "to";
|
||||
selectedField: ColumnInfo | null;
|
||||
onFieldSelect: (field: ColumnInfo | null) => void;
|
||||
onFieldPositionUpdate: (fieldId: string, element: HTMLElement) => void;
|
||||
isFieldMapped: (field: ColumnInfo, type: "from" | "to") => boolean;
|
||||
onDragStart?: (field: ColumnInfo) => void;
|
||||
onDragEnd?: () => void;
|
||||
onDrop?: (targetField: ColumnInfo, sourceField: ColumnInfo) => void;
|
||||
isDragOver?: boolean;
|
||||
draggedField?: ColumnInfo | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 📋 필드 컬럼 컴포넌트
|
||||
* - 필드 목록 표시
|
||||
* - 선택 상태 관리
|
||||
* - 위치 정보 업데이트
|
||||
*/
|
||||
const FieldColumn: React.FC<FieldColumnProps> = ({
|
||||
fields,
|
||||
type,
|
||||
selectedField,
|
||||
onFieldSelect,
|
||||
onFieldPositionUpdate,
|
||||
isFieldMapped,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDrop,
|
||||
isDragOver,
|
||||
draggedField,
|
||||
}) => {
|
||||
const fieldRefs = useRef<Record<string, HTMLDivElement>>({});
|
||||
|
||||
// 필드 위치 업데이트
|
||||
useEffect(() => {
|
||||
const updatePositions = () => {
|
||||
Object.entries(fieldRefs.current).forEach(([fieldId, element]) => {
|
||||
if (element) {
|
||||
onFieldPositionUpdate(fieldId, element);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 약간의 지연을 두어 DOM이 완전히 렌더링된 후 위치 업데이트
|
||||
const timeoutId = setTimeout(updatePositions, 100);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [fields.length]); // fields 배열 대신 length만 의존성으로 사용
|
||||
|
||||
// 드래그 앤 드롭 핸들러
|
||||
const handleDragStart = (e: React.DragEvent, field: ColumnInfo) => {
|
||||
if (type === "from" && onDragStart) {
|
||||
e.dataTransfer.setData("text/plain", JSON.stringify(field));
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
onDragStart(field);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = (e: React.DragEvent) => {
|
||||
if (onDragEnd) {
|
||||
onDragEnd();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
if (type === "to") {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent, targetField: ColumnInfo) => {
|
||||
if (type === "to" && onDrop) {
|
||||
e.preventDefault();
|
||||
|
||||
// 이미 매핑된 TO 필드인지 확인
|
||||
const isMapped = isFieldMapped(targetField, "to");
|
||||
if (isMapped) {
|
||||
// 이미 매핑된 필드에는 드롭할 수 없음을 시각적으로 표시
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sourceFieldData = e.dataTransfer.getData("text/plain");
|
||||
const sourceField = JSON.parse(sourceFieldData) as ColumnInfo;
|
||||
onDrop(targetField, sourceField);
|
||||
} catch (error) {
|
||||
console.error("드롭 처리 중 오류:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 필드 렌더링
|
||||
const renderField = (field: ColumnInfo, index: number) => {
|
||||
const fieldId = `${type}_${field.columnName}`;
|
||||
const isSelected = selectedField?.columnName === field.columnName;
|
||||
const isMapped = isFieldMapped(field, type);
|
||||
const displayName = field.displayName || field.columnName;
|
||||
const isDragging = draggedField?.columnName === field.columnName;
|
||||
const isDropTarget = type === "to" && isDragOver && draggedField && !isMapped;
|
||||
const isBlockedDropTarget = type === "to" && isDragOver && draggedField && isMapped;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${type}_${field.columnName}_${index}`}
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
fieldRefs.current[fieldId] = el;
|
||||
}
|
||||
}}
|
||||
className={`relative cursor-pointer rounded-lg border p-3 transition-all duration-200 ${
|
||||
isDragging
|
||||
? "border-primary bg-primary/20 scale-105 transform opacity-50 shadow-lg"
|
||||
: isSelected
|
||||
? "border-primary bg-primary/10 shadow-md"
|
||||
: isMapped
|
||||
? "border-green-500 bg-green-50 shadow-sm"
|
||||
: isBlockedDropTarget
|
||||
? "border-red-400 bg-red-50 shadow-md"
|
||||
: isDropTarget
|
||||
? "border-blue-400 bg-blue-50 shadow-md"
|
||||
: "border-border hover:bg-muted/50 hover:shadow-sm"
|
||||
} `}
|
||||
draggable={type === "from" && !isMapped}
|
||||
onDragStart={(e) => handleDragStart(e, field)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e, field)}
|
||||
onClick={() => onFieldSelect(isSelected ? null : field)}
|
||||
>
|
||||
{/* 연결점 표시 */}
|
||||
<div
|
||||
className={`absolute ${type === "from" ? "right-0" : "left-0"} top-1/2 h-3 w-3 -translate-y-1/2 transform rounded-full border-2 transition-colors ${
|
||||
isSelected
|
||||
? "bg-primary border-primary"
|
||||
: isMapped
|
||||
? "border-green-500 bg-green-500"
|
||||
: "border-gray-300 bg-white"
|
||||
} `}
|
||||
style={{
|
||||
[type === "from" ? "right" : "left"]: "-6px",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{type === "from" && !isMapped && <GripVertical className="h-3 w-3 flex-shrink-0 text-gray-400" />}
|
||||
<span className="truncate text-sm font-medium">{displayName}</span>
|
||||
{isMapped && <Link className="h-3 w-3 flex-shrink-0 text-green-600" />}
|
||||
</div>
|
||||
<Badge variant="outline" className="flex-shrink-0 text-xs">
|
||||
{field.webType || field.dataType || "unknown"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{field.description && <p className="text-muted-foreground mt-1 truncate text-xs">{field.description}</p>}
|
||||
|
||||
{/* 선택 상태 표시 */}
|
||||
{isSelected && <div className="border-primary pointer-events-none absolute inset-0 rounded-lg border-2" />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<ScrollArea className="h-full rounded-lg border">
|
||||
<div className="space-y-2 p-2">
|
||||
{fields.map((field, index) => renderField(field, index))}
|
||||
|
||||
{fields.length === 0 && (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||
<p>필드가 없습니다.</p>
|
||||
<p className="mt-1 text-xs">테이블을 선택해주세요.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldColumn;
|
||||
|
|
@ -0,0 +1,327 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Search, Link, Unlink } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// 타입 import
|
||||
import { ColumnInfo } from "@/lib/types/multiConnection";
|
||||
import { FieldMapping, FieldMappingCanvasProps } from "../../types/redesigned";
|
||||
|
||||
// 컴포넌트 import
|
||||
import FieldColumn from "./FieldColumn";
|
||||
import MappingControls from "./MappingControls";
|
||||
|
||||
/**
|
||||
* 🎨 시각적 필드 매핑 캔버스
|
||||
* - SVG 기반 연결선 표시
|
||||
* - 드래그 앤 드롭 지원 (향후)
|
||||
* - 실시간 연결선 업데이트
|
||||
*/
|
||||
const FieldMappingCanvas: React.FC<FieldMappingCanvasProps> = ({
|
||||
fromFields,
|
||||
toFields,
|
||||
mappings,
|
||||
onCreateMapping,
|
||||
onDeleteMapping,
|
||||
}) => {
|
||||
const [fromSearch, setFromSearch] = useState("");
|
||||
const [toSearch, setToSearch] = useState("");
|
||||
const [selectedFromField, setSelectedFromField] = useState<ColumnInfo | null>(null);
|
||||
const [selectedToField, setSelectedToField] = useState<ColumnInfo | null>(null);
|
||||
const [fieldPositions, setFieldPositions] = useState<Record<string, { x: number; y: number }>>({});
|
||||
|
||||
// 드래그 앤 드롭 상태
|
||||
const [draggedField, setDraggedField] = useState<ColumnInfo | null>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const fromColumnRef = useRef<HTMLDivElement>(null);
|
||||
const toColumnRef = useRef<HTMLDivElement>(null);
|
||||
const fieldRefs = useRef<Record<string, HTMLElement>>({});
|
||||
|
||||
// 필드 필터링 - 안전한 배열 처리
|
||||
const safeFromFields = Array.isArray(fromFields) ? fromFields : [];
|
||||
const safeToFields = Array.isArray(toFields) ? toFields : [];
|
||||
|
||||
const filteredFromFields = safeFromFields.filter((field) => {
|
||||
const fieldName = field.displayName || field.columnName || "";
|
||||
return fieldName.toLowerCase().includes(fromSearch.toLowerCase());
|
||||
});
|
||||
|
||||
const filteredToFields = safeToFields.filter((field) => {
|
||||
const fieldName = field.displayName || field.columnName || "";
|
||||
return fieldName.toLowerCase().includes(toSearch.toLowerCase());
|
||||
});
|
||||
|
||||
// 매핑 생성
|
||||
const handleCreateMapping = useCallback(() => {
|
||||
if (selectedFromField && selectedToField) {
|
||||
// 안전한 매핑 배열 처리
|
||||
const safeMappings = Array.isArray(mappings) ? mappings : [];
|
||||
|
||||
// N:1 매핑 방지 - TO 필드가 이미 매핑되어 있는지 확인
|
||||
const existingToMapping = safeMappings.find((m) => m.toField.columnName === selectedToField.columnName);
|
||||
|
||||
if (existingToMapping) {
|
||||
toast.error(
|
||||
`대상 필드 '${selectedToField.displayName || selectedToField.columnName}'는 이미 매핑되어 있습니다.\nN:1 매핑은 허용되지 않습니다.`,
|
||||
);
|
||||
setSelectedFromField(null);
|
||||
setSelectedToField(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// 동일한 매핑 중복 체크
|
||||
const existingMapping = safeMappings.find(
|
||||
(m) =>
|
||||
m.fromField.columnName === selectedFromField.columnName &&
|
||||
m.toField.columnName === selectedToField.columnName,
|
||||
);
|
||||
|
||||
if (existingMapping) {
|
||||
setSelectedFromField(null);
|
||||
setSelectedToField(null);
|
||||
return;
|
||||
}
|
||||
|
||||
onCreateMapping(selectedFromField, selectedToField);
|
||||
setSelectedFromField(null);
|
||||
setSelectedToField(null);
|
||||
}
|
||||
}, [selectedFromField, selectedToField, mappings, onCreateMapping]);
|
||||
|
||||
// 드래그 앤 드롭 핸들러들
|
||||
const handleDragStart = useCallback((field: ColumnInfo) => {
|
||||
setDraggedField(field);
|
||||
setSelectedFromField(field); // 드래그 시작 시 선택 상태로 표시
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDraggedField(null);
|
||||
setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
// 드래그 오버 상태 관리
|
||||
useEffect(() => {
|
||||
if (draggedField) {
|
||||
setIsDragOver(true);
|
||||
} else {
|
||||
setIsDragOver(false);
|
||||
}
|
||||
}, [draggedField]);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(targetField: ColumnInfo, sourceField: ColumnInfo) => {
|
||||
// 안전한 매핑 배열 처리
|
||||
const safeMappings = Array.isArray(mappings) ? mappings : [];
|
||||
|
||||
// N:1 매핑 방지 - TO 필드가 이미 매핑되어 있는지 확인
|
||||
const existingToMapping = safeMappings.find((m) => m.toField.columnName === targetField.columnName);
|
||||
|
||||
if (existingToMapping) {
|
||||
toast.error(
|
||||
`대상 필드 '${targetField.displayName || targetField.columnName}'는 이미 매핑되어 있습니다.\nN:1 매핑은 허용되지 않습니다.`,
|
||||
);
|
||||
setDraggedField(null);
|
||||
setIsDragOver(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 동일한 매핑 중복 체크
|
||||
const existingMapping = mappings.find(
|
||||
(m) => m.fromField.columnName === sourceField.columnName && m.toField.columnName === targetField.columnName,
|
||||
);
|
||||
|
||||
if (existingMapping) {
|
||||
setDraggedField(null);
|
||||
setIsDragOver(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 매핑 생성
|
||||
onCreateMapping(sourceField, targetField);
|
||||
|
||||
// 상태 초기화
|
||||
setDraggedField(null);
|
||||
setIsDragOver(false);
|
||||
setSelectedFromField(null);
|
||||
setSelectedToField(null);
|
||||
},
|
||||
[mappings, onCreateMapping],
|
||||
);
|
||||
|
||||
// 필드 위치 업데이트 (메모이제이션)
|
||||
const updateFieldPosition = useCallback((fieldId: string, element: HTMLElement) => {
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
// fieldRefs에 저장
|
||||
fieldRefs.current[fieldId] = element;
|
||||
|
||||
const canvasRect = canvasRef.current.getBoundingClientRect();
|
||||
const fieldRect = element.getBoundingClientRect();
|
||||
|
||||
const x = fieldRect.left - canvasRect.left + fieldRect.width / 2;
|
||||
const y = fieldRect.top - canvasRect.top + fieldRect.height / 2;
|
||||
|
||||
setFieldPositions((prev) => {
|
||||
// 위치가 실제로 변경된 경우에만 업데이트
|
||||
const currentPos = prev[fieldId];
|
||||
if (currentPos && Math.abs(currentPos.x - x) < 1 && Math.abs(currentPos.y - y) < 1) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[fieldId]: { x, y },
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 스크롤 이벤트 리스너로 연결선 위치 업데이트
|
||||
useEffect(() => {
|
||||
const updatePositionsOnScroll = () => {
|
||||
// 모든 필드의 위치를 다시 계산
|
||||
Object.entries(fieldRefs.current || {}).forEach(([fieldId, element]) => {
|
||||
if (element) {
|
||||
updateFieldPosition(fieldId, element);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 스크롤 가능한 영역들에 이벤트 리스너 추가
|
||||
const scrollAreas = document.querySelectorAll("[data-radix-scroll-area-viewport]");
|
||||
|
||||
scrollAreas.forEach((area) => {
|
||||
area.addEventListener("scroll", updatePositionsOnScroll, { passive: true });
|
||||
});
|
||||
|
||||
// 윈도우 리사이즈 시에도 위치 업데이트
|
||||
window.addEventListener("resize", updatePositionsOnScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
scrollAreas.forEach((area) => {
|
||||
area.removeEventListener("scroll", updatePositionsOnScroll);
|
||||
});
|
||||
window.removeEventListener("resize", updatePositionsOnScroll);
|
||||
};
|
||||
}, [updateFieldPosition]);
|
||||
|
||||
// 매핑 여부 확인
|
||||
const isFieldMapped = useCallback(
|
||||
(field: ColumnInfo, type: "from" | "to") => {
|
||||
return mappings
|
||||
.filter((mapping) => mapping.fromField && mapping.toField) // 유효한 매핑만 확인
|
||||
.some((mapping) =>
|
||||
type === "from"
|
||||
? mapping.fromField?.columnName === field.columnName
|
||||
: mapping.toField?.columnName === field.columnName,
|
||||
);
|
||||
},
|
||||
[mappings],
|
||||
);
|
||||
|
||||
// 연결선 데이터 생성
|
||||
|
||||
return (
|
||||
<div ref={canvasRef} className="relative flex h-full flex-col">
|
||||
{/* 매핑 생성 컨트롤 */}
|
||||
<div className="mb-4 flex-shrink-0">
|
||||
<MappingControls
|
||||
selectedFromField={selectedFromField}
|
||||
selectedToField={selectedToField}
|
||||
onCreateMapping={handleCreateMapping}
|
||||
canCreate={!!(selectedFromField && selectedToField)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 필드 매핑 영역 */}
|
||||
<div className="grid max-h-[500px] min-h-[300px] flex-1 grid-cols-2 gap-6 overflow-hidden">
|
||||
{/* FROM 필드 컬럼 */}
|
||||
<div ref={fromColumnRef} className="flex h-full flex-col">
|
||||
<div className="mb-3 flex flex-shrink-0 items-center justify-between">
|
||||
<h3 className="font-medium">FROM 필드</h3>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{filteredFromFields.length}개
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-3 flex-shrink-0">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
|
||||
<Input
|
||||
placeholder="필드 검색..."
|
||||
value={fromSearch}
|
||||
onChange={(e) => setFromSearch(e.target.value)}
|
||||
className="h-8 pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[400px] min-h-0 flex-1">
|
||||
<FieldColumn
|
||||
fields={filteredFromFields}
|
||||
type="from"
|
||||
selectedField={selectedFromField}
|
||||
onFieldSelect={setSelectedFromField}
|
||||
onFieldPositionUpdate={updateFieldPosition}
|
||||
isFieldMapped={isFieldMapped}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
draggedField={draggedField}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TO 필드 컬럼 */}
|
||||
<div ref={toColumnRef} className="flex h-full flex-col">
|
||||
<div className="mb-3 flex flex-shrink-0 items-center justify-between">
|
||||
<h3 className="font-medium">TO 필드</h3>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{filteredToFields.length}개
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-3 flex-shrink-0">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
|
||||
<Input
|
||||
placeholder="필드 검색..."
|
||||
value={toSearch}
|
||||
onChange={(e) => setToSearch(e.target.value)}
|
||||
className="h-8 pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[400px] min-h-0 flex-1">
|
||||
<FieldColumn
|
||||
fields={filteredToFields}
|
||||
type="to"
|
||||
selectedField={selectedToField}
|
||||
onFieldSelect={setSelectedToField}
|
||||
onFieldPositionUpdate={updateFieldPosition}
|
||||
isFieldMapped={isFieldMapped}
|
||||
onDrop={handleDrop}
|
||||
isDragOver={isDragOver}
|
||||
draggedField={draggedField}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 매핑 규칙 안내 */}
|
||||
<div className="mt-4 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||
<h4 className="mb-2 text-sm font-medium">📋 매핑 규칙</h4>
|
||||
<div className="text-muted-foreground space-y-1 text-xs">
|
||||
<p>✅ 1:N 매핑 허용 (하나의 소스 필드를 여러 대상 필드에 매핑)</p>
|
||||
<p>❌ N:1 매핑 금지 (여러 소스 필드를 하나의 대상 필드에 매핑 불가)</p>
|
||||
<p>🔒 이미 매핑된 대상 필드는 추가 매핑이 차단됩니다</p>
|
||||
<p>🔗 {mappings.length}개 연결됨</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldMappingCanvas;
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Link, ArrowRight, MousePointer, Move } from "lucide-react";
|
||||
|
||||
// 타입 import
|
||||
import { ColumnInfo } from "@/lib/types/multiConnection";
|
||||
|
||||
interface MappingControlsProps {
|
||||
selectedFromField: ColumnInfo | null;
|
||||
selectedToField: ColumnInfo | null;
|
||||
onCreateMapping: () => void;
|
||||
canCreate: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎯 매핑 생성 컨트롤
|
||||
* - 선택된 필드 표시
|
||||
* - 매핑 생성 버튼
|
||||
* - 시각적 피드백
|
||||
*/
|
||||
const MappingControls: React.FC<MappingControlsProps> = ({
|
||||
selectedFromField,
|
||||
selectedToField,
|
||||
onCreateMapping,
|
||||
canCreate,
|
||||
}) => {
|
||||
// 안내 메시지 표시 여부
|
||||
const showGuidance = !selectedFromField && !selectedToField;
|
||||
|
||||
if (showGuidance) {
|
||||
return (
|
||||
<div className="bg-muted/50 rounded-lg border p-4">
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-6 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<MousePointer className="h-4 w-4" />
|
||||
<span>클릭으로 선택</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground">또는</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Move className="h-4 w-4" />
|
||||
<span>드래그 앤 드롭으로 매핑</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-muted/50 flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">선택된 필드:</span>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
{/* FROM 필드 */}
|
||||
<Badge
|
||||
variant={selectedFromField ? "default" : "outline"}
|
||||
className={`transition-all ${selectedFromField ? "shadow-sm" : ""}`}
|
||||
>
|
||||
FROM: {selectedFromField?.displayName || selectedFromField?.columnName || "없음"}
|
||||
</Badge>
|
||||
|
||||
{/* 화살표 */}
|
||||
<ArrowRight
|
||||
className={`h-4 w-4 transition-colors ${canCreate ? "text-primary" : "text-muted-foreground"}`}
|
||||
/>
|
||||
|
||||
{/* TO 필드 */}
|
||||
<Badge
|
||||
variant={selectedToField ? "default" : "outline"}
|
||||
className={`transition-all ${selectedToField ? "shadow-sm" : ""}`}
|
||||
>
|
||||
TO: {selectedToField?.displayName || selectedToField?.columnName || "없음"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 타입 호환성 표시 */}
|
||||
{selectedFromField && selectedToField && (
|
||||
<div className="text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">타입:</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{selectedFromField.webType || "unknown"}
|
||||
</Badge>
|
||||
<ArrowRight className="text-muted-foreground h-3 w-3" />
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{selectedToField.webType || "unknown"}
|
||||
</Badge>
|
||||
{/* 타입 호환성 아이콘 */}
|
||||
{selectedFromField.webType === selectedToField.webType ? (
|
||||
<span className="text-xs text-green-600">✅</span>
|
||||
) : (
|
||||
<span className="text-xs text-orange-600">⚠️</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 매핑 생성 버튼 */}
|
||||
<Button
|
||||
onClick={onCreateMapping}
|
||||
disabled={!canCreate}
|
||||
size="sm"
|
||||
className={`transition-all ${canCreate ? "shadow-sm hover:shadow-md" : ""}`}
|
||||
>
|
||||
<Link className="mr-1 h-4 w-4" />
|
||||
매핑 생성
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MappingControls;
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
// 🎨 제어관리 UI 재설계 - 타입 정의
|
||||
|
||||
import { ColumnInfo, Connection, TableInfo } from "@/lib/types/multiConnection";
|
||||
|
||||
// 연결 타입
|
||||
export interface ConnectionType {
|
||||
id: "data_save" | "external_call";
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
// 필드 매핑
|
||||
export interface FieldMapping {
|
||||
id: string;
|
||||
fromField: ColumnInfo;
|
||||
toField: ColumnInfo;
|
||||
transformRule?: string;
|
||||
isValid: boolean;
|
||||
validationMessage?: string;
|
||||
}
|
||||
|
||||
// 시각적 연결선
|
||||
export interface MappingLine {
|
||||
id: string;
|
||||
fromX: number;
|
||||
fromY: number;
|
||||
toX: number;
|
||||
toY: number;
|
||||
isValid: boolean;
|
||||
isHovered: boolean;
|
||||
}
|
||||
|
||||
// 매핑 통계
|
||||
export interface MappingStats {
|
||||
totalMappings: number;
|
||||
validMappings: number;
|
||||
invalidMappings: number;
|
||||
missingRequiredFields: number;
|
||||
estimatedRows: number;
|
||||
actionType: "INSERT" | "UPDATE" | "DELETE";
|
||||
}
|
||||
|
||||
// 검증 결과
|
||||
export interface ValidationError {
|
||||
id: string;
|
||||
type: "error" | "warning" | "info";
|
||||
message: string;
|
||||
fieldId?: string;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
errors: ValidationError[];
|
||||
warnings: ValidationError[];
|
||||
}
|
||||
|
||||
// 테스트 결과
|
||||
export interface TestResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
affectedRows?: number;
|
||||
executionTime?: number;
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
// 단일 액션 정의
|
||||
export interface SingleAction {
|
||||
id: string;
|
||||
name: string;
|
||||
actionType: "insert" | "update" | "delete" | "upsert";
|
||||
conditions: any[];
|
||||
fieldMappings: any[];
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
// 액션 그룹 (AND/OR 조건으로 연결)
|
||||
export interface ActionGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
logicalOperator: "AND" | "OR";
|
||||
actions: SingleAction[];
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
// 메인 상태
|
||||
export interface DataConnectionState {
|
||||
// 기본 설정
|
||||
connectionType: "data_save" | "external_call";
|
||||
currentStep: 1 | 2 | 3 | 4;
|
||||
|
||||
// 관계 정보
|
||||
diagramId?: number; // 🔧 수정 모드 감지용
|
||||
relationshipName?: string;
|
||||
description?: string;
|
||||
|
||||
// 연결 정보
|
||||
fromConnection?: Connection;
|
||||
toConnection?: Connection;
|
||||
fromTable?: TableInfo;
|
||||
toTable?: TableInfo;
|
||||
|
||||
// 매핑 정보
|
||||
fieldMappings: FieldMapping[];
|
||||
mappingStats: MappingStats;
|
||||
|
||||
// 제어 실행 조건 (전체 제어가 언제 실행될지)
|
||||
controlConditions: any[]; // 전체 제어 트리거 조건
|
||||
|
||||
// 액션 설정 (멀티 액션 지원)
|
||||
actionGroups: ActionGroup[];
|
||||
groupsLogicalOperator?: "AND" | "OR"; // 그룹 간의 논리 연산자
|
||||
|
||||
// 외부호출 설정
|
||||
externalCallConfig?: {
|
||||
restApiSettings: {
|
||||
apiUrl: string;
|
||||
httpMethod: string;
|
||||
headers: Record<string, string>;
|
||||
bodyTemplate: string;
|
||||
authentication: {
|
||||
type: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
timeout: number;
|
||||
retryCount: number;
|
||||
};
|
||||
};
|
||||
|
||||
// 기존 호환성을 위한 필드들 (deprecated)
|
||||
actionType?: "insert" | "update" | "delete" | "upsert";
|
||||
actionConditions?: any[]; // 각 액션의 대상 레코드 조건
|
||||
actionFieldMappings?: any[]; // 액션별 필드 매핑
|
||||
|
||||
// UI 상태
|
||||
selectedMapping?: string;
|
||||
fromColumns?: ColumnInfo[]; // 🔧 FROM 테이블 컬럼 정보 (중앙 관리)
|
||||
toColumns?: ColumnInfo[]; // 🔧 TO 테이블 컬럼 정보 (중앙 관리)
|
||||
isLoading: boolean;
|
||||
validationErrors: ValidationError[];
|
||||
}
|
||||
|
||||
// 액션 인터페이스
|
||||
export interface DataConnectionActions {
|
||||
// 연결 타입
|
||||
setConnectionType: (type: "data_save" | "external_call") => void;
|
||||
|
||||
// 관계 정보
|
||||
setRelationshipName: (name: string) => void;
|
||||
setDescription: (description: string) => void;
|
||||
setGroupsLogicalOperator: (operator: "AND" | "OR") => void;
|
||||
|
||||
// 단계 진행
|
||||
goToStep: (step: 1 | 2 | 3 | 4) => void;
|
||||
|
||||
// 연결/테이블 선택
|
||||
selectConnection: (type: "from" | "to", connection: Connection) => void;
|
||||
selectTable: (type: "from" | "to", table: TableInfo) => void;
|
||||
|
||||
// 컬럼 정보 로드 (중앙 관리)
|
||||
loadColumns: () => Promise<void>;
|
||||
|
||||
// 필드 매핑
|
||||
createMapping: (fromField: ColumnInfo, toField: ColumnInfo) => void;
|
||||
updateMapping: (mappingId: string, updates: Partial<FieldMapping>) => void;
|
||||
deleteMapping: (mappingId: string) => void;
|
||||
|
||||
// 제어 조건 관리 (전체 실행 조건)
|
||||
addControlCondition: () => void;
|
||||
updateControlCondition: (index: number, condition: any) => void;
|
||||
deleteControlCondition: (index: number) => void;
|
||||
|
||||
// 외부호출 설정 관리
|
||||
updateExternalCallConfig: (config: any) => void;
|
||||
|
||||
// 액션 그룹 관리 (멀티 액션)
|
||||
addActionGroup: () => void;
|
||||
updateActionGroup: (groupId: string, updates: Partial<ActionGroup>) => void;
|
||||
deleteActionGroup: (groupId: string) => void;
|
||||
addActionToGroup: (groupId: string) => void;
|
||||
updateActionInGroup: (groupId: string, actionId: string, updates: Partial<SingleAction>) => void;
|
||||
deleteActionFromGroup: (groupId: string, actionId: string) => void;
|
||||
|
||||
// 기존 액션 설정 (호환성)
|
||||
setActionType: (type: "insert" | "update" | "delete" | "upsert") => void;
|
||||
addActionCondition: () => void;
|
||||
updateActionCondition: (index: number, condition: any) => void;
|
||||
setActionConditions: (conditions: any[]) => void; // 액션 조건 배열 전체 업데이트
|
||||
deleteActionCondition: (index: number) => void;
|
||||
|
||||
// 검증 및 저장
|
||||
validateMappings: () => Promise<ValidationResult>;
|
||||
saveMappings: () => Promise<void>;
|
||||
testExecution: () => Promise<TestResult>;
|
||||
}
|
||||
|
||||
// 컴포넌트 Props 타입들
|
||||
export interface DataConnectionDesignerProps {
|
||||
onClose?: () => void;
|
||||
initialData?: Partial<DataConnectionState>;
|
||||
showBackButton?: boolean;
|
||||
}
|
||||
|
||||
export interface LeftPanelProps {
|
||||
state: DataConnectionState;
|
||||
actions: DataConnectionActions;
|
||||
}
|
||||
|
||||
export interface RightPanelProps {
|
||||
state: DataConnectionState;
|
||||
actions: DataConnectionActions;
|
||||
}
|
||||
|
||||
export interface ConnectionTypeSelectorProps {
|
||||
selectedType: "data_save" | "external_call";
|
||||
onTypeChange: (type: "data_save" | "external_call") => void;
|
||||
}
|
||||
|
||||
export interface MappingInfoPanelProps {
|
||||
stats: MappingStats;
|
||||
validationErrors: ValidationError[];
|
||||
}
|
||||
|
||||
export interface MappingDetailListProps {
|
||||
mappings: FieldMapping[];
|
||||
selectedMapping?: string;
|
||||
onSelectMapping: (mappingId: string) => void;
|
||||
onUpdateMapping: (mappingId: string, updates: Partial<FieldMapping>) => void;
|
||||
onDeleteMapping: (mappingId: string) => void;
|
||||
}
|
||||
|
||||
export interface StepProgressProps {
|
||||
currentStep: 1 | 2 | 3 | 4;
|
||||
completedSteps: number[];
|
||||
onStepClick: (step: 1 | 2 | 3 | 4) => void;
|
||||
}
|
||||
|
||||
export interface FieldMappingCanvasProps {
|
||||
fromFields: ColumnInfo[];
|
||||
toFields: ColumnInfo[];
|
||||
mappings: FieldMapping[];
|
||||
onCreateMapping: (fromField: ColumnInfo, toField: ColumnInfo) => void;
|
||||
onDeleteMapping: (mappingId: string) => void;
|
||||
}
|
||||
|
|
@ -0,0 +1,322 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Globe, Settings, TestTube, History, Info } from "lucide-react";
|
||||
|
||||
// 타입 import
|
||||
import {
|
||||
ExternalCallConfig,
|
||||
ExternalCallPanelProps,
|
||||
RestApiSettings as RestApiSettingsType,
|
||||
ApiTestResult,
|
||||
} from "@/types/external-call/ExternalCallTypes";
|
||||
|
||||
// 하위 컴포넌트 import
|
||||
import RestApiSettings from "./RestApiSettings";
|
||||
import ExternalCallTestPanel from "./ExternalCallTestPanel";
|
||||
|
||||
/**
|
||||
* 🌐 외부호출 메인 패널 컴포넌트
|
||||
*
|
||||
* 데이터 저장 기능과 완전히 분리된 독립적인 외부호출 전용 패널
|
||||
* REST API 설정, 테스트, 실행 이력 등을 통합 관리
|
||||
*/
|
||||
const ExternalCallPanel: React.FC<ExternalCallPanelProps> = ({
|
||||
relationshipId,
|
||||
onSettingsChange,
|
||||
initialSettings,
|
||||
readonly = false,
|
||||
}) => {
|
||||
console.log("🌐 [ExternalCallPanel] Component mounted with props:", {
|
||||
relationshipId,
|
||||
initialSettings,
|
||||
readonly,
|
||||
});
|
||||
// 상태 관리
|
||||
const [config, setConfig] = useState<ExternalCallConfig>(
|
||||
() =>
|
||||
initialSettings || {
|
||||
callType: "rest-api",
|
||||
restApiSettings: {
|
||||
apiUrl: "",
|
||||
httpMethod: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
bodyTemplate: `{
|
||||
"message": "데이터가 업데이트되었습니다",
|
||||
"data": {{sourceData}},
|
||||
"timestamp": "{{timestamp}}",
|
||||
"relationshipId": "{{relationshipId}}"
|
||||
}`,
|
||||
authentication: {
|
||||
type: "none",
|
||||
},
|
||||
timeout: 30000, // 30초
|
||||
retryCount: 3,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>("settings");
|
||||
const [lastTestResult, setLastTestResult] = useState<ApiTestResult | null>(null);
|
||||
const [isConfigValid, setIsConfigValid] = useState<boolean>(false);
|
||||
|
||||
// 설정 변경 핸들러
|
||||
const handleRestApiSettingsChange = useCallback(
|
||||
(newSettings: RestApiSettingsType) => {
|
||||
const updatedConfig: ExternalCallConfig = {
|
||||
...config,
|
||||
restApiSettings: newSettings,
|
||||
metadata: {
|
||||
...config.metadata,
|
||||
updatedAt: new Date().toISOString(),
|
||||
version: "1.0",
|
||||
},
|
||||
};
|
||||
|
||||
setConfig(updatedConfig);
|
||||
onSettingsChange(updatedConfig);
|
||||
},
|
||||
[config, onSettingsChange],
|
||||
);
|
||||
|
||||
// 테스트 결과 핸들러
|
||||
const handleTestResult = useCallback((result: ApiTestResult) => {
|
||||
setLastTestResult(result);
|
||||
|
||||
// 테스트 탭에 머물러서 응답 정보를 바로 확인할 수 있도록 함
|
||||
// (이전에는 성공 시 자동으로 history 탭으로 이동했음)
|
||||
}, []);
|
||||
|
||||
// 설정 유효성 검사
|
||||
const validateConfig = useCallback(() => {
|
||||
const { restApiSettings } = config;
|
||||
|
||||
// HTTP 메서드에 따라 바디 필요 여부 결정
|
||||
const methodNeedsBody = !["GET", "HEAD", "DELETE"].includes(restApiSettings.httpMethod?.toUpperCase());
|
||||
|
||||
const isValid = !!(
|
||||
restApiSettings.apiUrl &&
|
||||
restApiSettings.apiUrl.startsWith("http") &&
|
||||
restApiSettings.httpMethod &&
|
||||
(methodNeedsBody ? restApiSettings.bodyTemplate : true) // GET/HEAD/DELETE는 바디 불필요
|
||||
);
|
||||
|
||||
setIsConfigValid(isValid);
|
||||
return isValid;
|
||||
}, [config]);
|
||||
|
||||
// 설정 변경 시 유효성 검사 실행
|
||||
useEffect(() => {
|
||||
validateConfig();
|
||||
}, [validateConfig]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full max-h-full flex-col space-y-2">
|
||||
{/* 헤더 */}
|
||||
<Card>
|
||||
<CardHeader className="pt-3 pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-5 w-5 text-blue-500" />
|
||||
<CardTitle className="text-lg">외부 호출 설정</CardTitle>
|
||||
<Badge variant={isConfigValid ? "default" : "secondary"}>
|
||||
{isConfigValid ? "설정 완료" : "설정 필요"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground text-sm">
|
||||
관계 실행 시 외부 API를 호출하여 데이터를 전송하거나 알림을 보낼 수 있습니다.
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{/* 메인 탭 컨텐츠 */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex w-full flex-1 flex-col overflow-hidden">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="settings" className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
설정
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="test" className="flex items-center gap-2">
|
||||
<TestTube className="h-4 w-4" />
|
||||
테스트
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="history" className="flex items-center gap-2">
|
||||
<History className="h-4 w-4" />
|
||||
이력
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="info" className="flex items-center gap-2">
|
||||
<Info className="h-4 w-4" />
|
||||
정보
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 설정 탭 */}
|
||||
<TabsContent value="settings" className="flex-1 space-y-2 overflow-y-auto">
|
||||
<RestApiSettings
|
||||
settings={config.restApiSettings}
|
||||
onSettingsChange={handleRestApiSettingsChange}
|
||||
readonly={readonly}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* 테스트 탭 */}
|
||||
<TabsContent value="test" className="flex-1 space-y-4 overflow-y-auto">
|
||||
{isConfigValid ? (
|
||||
<ExternalCallTestPanel
|
||||
settings={config.restApiSettings}
|
||||
context={{
|
||||
relationshipId,
|
||||
diagramId: "test-diagram",
|
||||
userId: "current-user",
|
||||
executionId: "test-execution",
|
||||
sourceData: { test: "data" },
|
||||
timestamp: new Date().toISOString(),
|
||||
}}
|
||||
onTestResult={handleTestResult}
|
||||
disabled={readonly}
|
||||
/>
|
||||
) : (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>API 테스트를 실행하려면 먼저 설정 탭에서 필수 정보를 입력해주세요.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* 이력 탭 */}
|
||||
<TabsContent value="history" className="flex-1 space-y-4 overflow-y-auto">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">실행 이력</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{lastTestResult ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">최근 테스트 결과</span>
|
||||
<Badge variant={lastTestResult.success ? "default" : "destructive"}>
|
||||
{lastTestResult.success ? "성공" : "실패"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">상태 코드:</span>
|
||||
<span className="ml-2 font-mono">{lastTestResult.statusCode || "N/A"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">응답 시간:</span>
|
||||
<span className="ml-2 font-mono">{lastTestResult.responseTime}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lastTestResult.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="text-sm">{lastTestResult.error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{lastTestResult.responseData && (
|
||||
<div>
|
||||
<span className="text-muted-foreground text-sm">응답 데이터:</span>
|
||||
<pre className="bg-muted mt-1 max-h-32 overflow-auto rounded p-2 text-xs">
|
||||
{JSON.stringify(lastTestResult.responseData, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground py-8 text-center">
|
||||
<TestTube className="mx-auto mb-2 h-8 w-8 opacity-50" />
|
||||
<p>아직 실행 이력이 없습니다.</p>
|
||||
<p className="text-sm">테스트 탭에서 API 호출을 테스트해보세요.</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 정보 탭 */}
|
||||
<TabsContent value="info" className="flex-1 space-y-4 overflow-y-auto">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">템플릿 변수 가이드</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="text-muted-foreground text-sm">요청 바디에서 사용할 수 있는 템플릿 변수들입니다:</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<code className="bg-muted rounded px-2 py-1">{"{{sourceData}}"}</code>
|
||||
<span className="text-muted-foreground">소스 노드의 전체 데이터</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<code className="bg-muted rounded px-2 py-1">{"{{timestamp}}"}</code>
|
||||
<span className="text-muted-foreground">현재 타임스탬프</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<code className="bg-muted rounded px-2 py-1">{"{{relationshipId}}"}</code>
|
||||
<span className="text-muted-foreground">관계 ID</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<code className="bg-muted rounded px-2 py-1">{"{{userId}}"}</code>
|
||||
<span className="text-muted-foreground">현재 사용자 ID</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<code className="bg-muted rounded px-2 py-1">{"{{executionId}}"}</code>
|
||||
<span className="text-muted-foreground">실행 ID</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
템플릿 변수는 실제 실행 시 해당 값으로 자동 치환됩니다. JSON 형식의 데이터는 따옴표 없이 사용하세요.
|
||||
(예: {"{{sourceData}}"})
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">설정 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">관계 ID:</span>
|
||||
<code className="bg-muted rounded px-2 py-1 text-xs">{relationshipId}</code>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">호출 타입:</span>
|
||||
<Badge variant="outline">{config.callType.toUpperCase()}</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">설정 상태:</span>
|
||||
<Badge variant={isConfigValid ? "default" : "secondary"}>{isConfigValid ? "완료" : "미완료"}</Badge>
|
||||
</div>
|
||||
{config.metadata?.updatedAt && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">마지막 수정:</span>
|
||||
<span className="text-xs">{new Date(config.metadata.updatedAt).toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExternalCallPanel;
|
||||
|
|
@ -0,0 +1,497 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
TestTube,
|
||||
Play,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
Copy,
|
||||
RefreshCw,
|
||||
Zap,
|
||||
Code,
|
||||
Network,
|
||||
Timer,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// 타입 import
|
||||
import {
|
||||
ExternalCallTestPanelProps,
|
||||
ApiTestResult,
|
||||
ExternalCallContext,
|
||||
} from "@/types/external-call/ExternalCallTypes";
|
||||
import { ExternalCallAPI } from "@/lib/api/externalCall";
|
||||
|
||||
/**
|
||||
* 🧪 API 테스트 전용 컴포넌트
|
||||
*
|
||||
* REST API 설정을 실제로 테스트하고 결과를 표시
|
||||
* 템플릿 변수 치환, 응답 분석, 오류 진단 등 제공
|
||||
*/
|
||||
const ExternalCallTestPanel: React.FC<ExternalCallTestPanelProps> = ({
|
||||
settings,
|
||||
context,
|
||||
onTestResult,
|
||||
disabled = false,
|
||||
}) => {
|
||||
// 상태 관리
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [testResult, setTestResult] = useState<ApiTestResult | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<string>("request");
|
||||
const [processedTemplate, setProcessedTemplate] = useState<string>("");
|
||||
const [testContext, setTestContext] = useState<ExternalCallContext>(() => ({
|
||||
relationshipId: context?.relationshipId || "test-relationship",
|
||||
diagramId: context?.diagramId || "test-diagram",
|
||||
userId: context?.userId || "test-user",
|
||||
executionId: context?.executionId || `test-${Date.now()}`,
|
||||
sourceData: context?.sourceData || {
|
||||
id: 1,
|
||||
name: "테스트 데이터",
|
||||
value: 100,
|
||||
status: "active",
|
||||
},
|
||||
targetData: context?.targetData,
|
||||
timestamp: context?.timestamp || new Date().toISOString(),
|
||||
metadata: context?.metadata,
|
||||
}));
|
||||
|
||||
// 템플릿 변수 치환 함수
|
||||
const processTemplate = useCallback((template: string, context: ExternalCallContext): string => {
|
||||
let processed = template;
|
||||
|
||||
// 각 템플릿 변수를 실제 값으로 치환
|
||||
const replacements = {
|
||||
"{{sourceData}}": JSON.stringify(context.sourceData, null, 2),
|
||||
"{{targetData}}": context.targetData ? JSON.stringify(context.targetData, null, 2) : "null",
|
||||
"{{timestamp}}": context.timestamp,
|
||||
"{{relationshipId}}": context.relationshipId,
|
||||
"{{diagramId}}": context.diagramId,
|
||||
"{{userId}}": context.userId,
|
||||
"{{executionId}}": context.executionId,
|
||||
};
|
||||
|
||||
Object.entries(replacements).forEach(([variable, value]) => {
|
||||
processed = processed.replace(new RegExp(variable.replace(/[{}]/g, "\\$&"), "g"), value);
|
||||
});
|
||||
|
||||
return processed;
|
||||
}, []);
|
||||
|
||||
// 템플릿 처리 (설정이나 컨텍스트 변경 시)
|
||||
useEffect(() => {
|
||||
if (settings.bodyTemplate) {
|
||||
const processed = processTemplate(settings.bodyTemplate, testContext);
|
||||
setProcessedTemplate(processed);
|
||||
}
|
||||
}, [settings.bodyTemplate, testContext, processTemplate]);
|
||||
|
||||
// API 테스트 실행
|
||||
const handleRunTest = useCallback(async () => {
|
||||
if (!settings.apiUrl) {
|
||||
toast.error("API URL을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
// 테스트 요청 데이터 구성 (백엔드 형식에 맞춤)
|
||||
const testRequest = {
|
||||
settings: {
|
||||
callType: "rest-api" as const,
|
||||
apiType: "generic" as const,
|
||||
url: settings.apiUrl,
|
||||
method: settings.httpMethod,
|
||||
headers: settings.headers,
|
||||
body: processedTemplate,
|
||||
authentication: settings.authentication, // 인증 정보 추가
|
||||
timeout: settings.timeout,
|
||||
retryCount: settings.retryCount,
|
||||
},
|
||||
templateData: testContext,
|
||||
};
|
||||
|
||||
// API 호출
|
||||
const response = await ExternalCallAPI.testExternalCall(testRequest);
|
||||
|
||||
if (response.success && response.result) {
|
||||
// 백엔드 응답을 ApiTestResult 형태로 변환
|
||||
const apiTestResult: ApiTestResult = {
|
||||
success: response.result.success,
|
||||
statusCode: response.result.statusCode,
|
||||
responseTime: response.result.executionTime || 0,
|
||||
response: response.result.response,
|
||||
error: response.result.error,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setTestResult(apiTestResult);
|
||||
onTestResult(apiTestResult);
|
||||
|
||||
if (apiTestResult.success) {
|
||||
toast.success("API 테스트가 성공했습니다!");
|
||||
setActiveTab("response");
|
||||
} else {
|
||||
toast.error("API 호출이 실패했습니다.");
|
||||
setActiveTab("response");
|
||||
}
|
||||
} else {
|
||||
const errorResult: ApiTestResult = {
|
||||
success: false,
|
||||
responseTime: 0,
|
||||
error: response.error || "알 수 없는 오류가 발생했습니다.",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
setTestResult(errorResult);
|
||||
onTestResult(errorResult);
|
||||
toast.error(response.error || "테스트 실행 중 오류가 발생했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
const errorResult: ApiTestResult = {
|
||||
success: false,
|
||||
responseTime: 0,
|
||||
error: error instanceof Error ? error.message : "네트워크 오류가 발생했습니다.",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
setTestResult(errorResult);
|
||||
onTestResult(errorResult);
|
||||
toast.error("테스트 실행 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [settings, processedTemplate, testContext, onTestResult]);
|
||||
|
||||
// 테스트 데이터 복사
|
||||
const handleCopyToClipboard = useCallback(async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success("클립보드에 복사되었습니다.");
|
||||
} catch (error) {
|
||||
toast.error("복사에 실패했습니다.");
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 테스트 컨텍스트 리셋
|
||||
const handleResetContext = useCallback(() => {
|
||||
setTestContext({
|
||||
relationshipId: "test-relationship",
|
||||
diagramId: "test-diagram",
|
||||
userId: "test-user",
|
||||
executionId: `test-${Date.now()}`,
|
||||
sourceData: {
|
||||
id: 1,
|
||||
name: "테스트 데이터",
|
||||
value: 100,
|
||||
status: "active",
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 테스트 실행 헤더 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<TestTube className="h-5 w-5 text-blue-500" />
|
||||
<CardTitle className="text-lg">API 테스트</CardTitle>
|
||||
{testResult && (
|
||||
<Badge variant={testResult.success ? "default" : "destructive"}>
|
||||
{testResult.success ? "성공" : "실패"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleResetContext} disabled={disabled || isLoading}>
|
||||
<RefreshCw className="mr-1 h-4 w-4" />
|
||||
리셋
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleRunTest}
|
||||
disabled={disabled || isLoading || !settings.apiUrl}
|
||||
className="min-w-[100px]"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||||
테스트 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
테스트 실행
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{/* 테스트 결과 요약 */}
|
||||
{testResult && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid grid-cols-4 gap-4 text-center">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{testResult.success ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
)}
|
||||
<span className="text-sm font-medium">상태</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">{testResult.success ? "성공" : "실패"}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Network className="h-4 w-4 text-blue-500" />
|
||||
<span className="text-sm font-medium">상태 코드</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">{testResult.statusCode || "N/A"}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Timer className="h-4 w-4 text-orange-500" />
|
||||
<span className="text-sm font-medium">응답 시간</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">{testResult.responseTime}ms</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Clock className="h-4 w-4 text-purple-500" />
|
||||
<span className="text-sm font-medium">실행 시간</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{new Date(testResult.timestamp).toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 상세 정보 탭 */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="request" className="flex items-center gap-2">
|
||||
<Zap className="h-4 w-4" />
|
||||
요청 정보
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="response" className="flex items-center gap-2">
|
||||
<Code className="h-4 w-4" />
|
||||
응답 정보
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="context" className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
테스트 데이터
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 요청 정보 탭 */}
|
||||
<TabsContent value="request" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">요청 정보</CardTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleCopyToClipboard(
|
||||
JSON.stringify(
|
||||
{
|
||||
url: settings.apiUrl,
|
||||
method: settings.httpMethod,
|
||||
headers: settings.headers,
|
||||
body: processedTemplate,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Copy className="mr-1 h-4 w-4" />
|
||||
복사
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* URL과 메서드 */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="col-span-1">
|
||||
<Label className="text-muted-foreground text-xs">HTTP 메서드</Label>
|
||||
<Badge variant="outline" className="mt-1">
|
||||
{settings.httpMethod}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<Label className="text-muted-foreground text-xs">URL</Label>
|
||||
<div className="bg-muted mt-1 rounded p-2 font-mono text-sm break-all">{settings.apiUrl}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 헤더 */}
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">헤더</Label>
|
||||
<ScrollArea className="mt-1 h-32 w-full rounded border">
|
||||
<div className="p-3">
|
||||
<pre className="text-xs">{JSON.stringify(settings.headers, null, 2)}</pre>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* 요청 바디 (POST/PUT/PATCH인 경우) */}
|
||||
{["POST", "PUT", "PATCH"].includes(settings.httpMethod) && (
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">요청 바디 (템플릿 변수 치환됨)</Label>
|
||||
<ScrollArea className="mt-1 h-40 w-full rounded border">
|
||||
<div className="p-3">
|
||||
<pre className="text-xs whitespace-pre-wrap">{processedTemplate}</pre>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 응답 정보 탭 */}
|
||||
<TabsContent value="response" className="space-y-4">
|
||||
{testResult ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">응답 정보</CardTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleCopyToClipboard(JSON.stringify(testResult, null, 2))}
|
||||
>
|
||||
<Copy className="mr-1 h-4 w-4" />
|
||||
복사
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{testResult.success ? (
|
||||
<>
|
||||
{/* 상태 코드 */}
|
||||
{testResult.statusCode && (
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">상태 코드</Label>
|
||||
<div className="mt-1 rounded border bg-green-50 p-2">
|
||||
<span className="font-mono text-sm text-green-700">{testResult.statusCode}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 응답 시간 */}
|
||||
{testResult.responseTime !== undefined && (
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">응답 시간</Label>
|
||||
<div className="mt-1 rounded border bg-blue-50 p-2">
|
||||
<span className="font-mono text-sm text-blue-700">{testResult.responseTime}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 응답 데이터 */}
|
||||
{testResult.response && (
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">응답 데이터</Label>
|
||||
<ScrollArea className="mt-1 h-40 w-full rounded border">
|
||||
<div className="p-3">
|
||||
<pre className="text-xs whitespace-pre-wrap">{testResult.response}</pre>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Alert variant="destructive">
|
||||
<XCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium">오류 발생</div>
|
||||
<div className="text-sm">{testResult.error}</div>
|
||||
{testResult.statusCode && <div className="text-sm">상태 코드: {testResult.statusCode}</div>}
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-muted-foreground py-8 text-center">
|
||||
<TestTube className="mx-auto mb-2 h-8 w-8 opacity-50" />
|
||||
<p>테스트를 실행하면 응답 정보가 여기에 표시됩니다.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* 테스트 데이터 탭 */}
|
||||
<TabsContent value="context" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">테스트 컨텍스트</CardTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleCopyToClipboard(JSON.stringify(testContext, null, 2))}
|
||||
>
|
||||
<Copy className="mr-1 h-4 w-4" />
|
||||
복사
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="text-muted-foreground text-sm">템플릿 변수 치환에 사용되는 테스트 데이터입니다.</div>
|
||||
|
||||
<ScrollArea className="h-60 w-full rounded border">
|
||||
<div className="p-3">
|
||||
<pre className="text-xs whitespace-pre-wrap">{JSON.stringify(testContext, null, 2)}</pre>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
실제 실행 시에는 관계의 실제 데이터가 사용됩니다. 이 데이터는 테스트 목적으로만 사용됩니다.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExternalCallTestPanel;
|
||||
|
|
@ -0,0 +1,659 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import {
|
||||
Globe,
|
||||
Key,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
Trash2,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
// 타입 import
|
||||
import { RestApiSettings as RestApiSettingsType, RestApiSettingsProps } from "@/types/external-call/ExternalCallTypes";
|
||||
import {
|
||||
HttpMethod,
|
||||
AuthenticationType,
|
||||
COMMON_HEADER_PRESETS,
|
||||
JSON_BODY_TEMPLATES,
|
||||
DEFAULT_RETRY_POLICY,
|
||||
DEFAULT_TIMEOUT_CONFIG,
|
||||
} from "@/types/external-call/RestApiTypes";
|
||||
|
||||
/**
|
||||
* 🔧 REST API 전용 설정 컴포넌트
|
||||
*
|
||||
* URL, HTTP 메서드, 헤더, 인증, 바디 템플릿 등
|
||||
* REST API 호출에 필요한 모든 설정을 관리
|
||||
*/
|
||||
const RestApiSettings: React.FC<RestApiSettingsProps> = ({ settings, onSettingsChange, readonly = false }) => {
|
||||
// 상태 관리
|
||||
const [activeTab, setActiveTab] = useState<string>("basic");
|
||||
const [showPassword, setShowPassword] = useState<boolean>(false);
|
||||
const [isAdvancedOpen, setIsAdvancedOpen] = useState<boolean>(false);
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
const [newHeaderKey, setNewHeaderKey] = useState<string>("");
|
||||
const [newHeaderValue, setNewHeaderValue] = useState<string>("");
|
||||
|
||||
// URL 변경 핸들러
|
||||
const handleUrlChange = useCallback(
|
||||
(url: string) => {
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
apiUrl: url,
|
||||
});
|
||||
},
|
||||
[settings, onSettingsChange],
|
||||
);
|
||||
|
||||
// HTTP 메서드 변경 핸들러
|
||||
const handleMethodChange = useCallback(
|
||||
(method: HttpMethod) => {
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
httpMethod: method,
|
||||
});
|
||||
},
|
||||
[settings, onSettingsChange],
|
||||
);
|
||||
|
||||
// 헤더 추가 핸들러
|
||||
const handleAddHeader = useCallback(() => {
|
||||
if (newHeaderKey && newHeaderValue) {
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
headers: {
|
||||
...settings.headers,
|
||||
[newHeaderKey]: newHeaderValue,
|
||||
},
|
||||
});
|
||||
setNewHeaderKey("");
|
||||
setNewHeaderValue("");
|
||||
}
|
||||
}, [settings, onSettingsChange, newHeaderKey, newHeaderValue]);
|
||||
|
||||
// 헤더 삭제 핸들러
|
||||
const handleRemoveHeader = useCallback(
|
||||
(key: string) => {
|
||||
const newHeaders = { ...settings.headers };
|
||||
delete newHeaders[key];
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
headers: newHeaders,
|
||||
});
|
||||
},
|
||||
[settings, onSettingsChange],
|
||||
);
|
||||
|
||||
// 헤더 프리셋 적용 핸들러
|
||||
const handleApplyHeaderPreset = useCallback(
|
||||
(presetName: string) => {
|
||||
const preset = COMMON_HEADER_PRESETS.find((p) => p.name === presetName);
|
||||
if (preset) {
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
headers: {
|
||||
...settings.headers,
|
||||
...preset.headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[settings, onSettingsChange],
|
||||
);
|
||||
|
||||
// 바디 템플릿 변경 핸들러
|
||||
const handleBodyTemplateChange = useCallback(
|
||||
(template: string) => {
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
bodyTemplate: template,
|
||||
});
|
||||
},
|
||||
[settings, onSettingsChange],
|
||||
);
|
||||
|
||||
// 바디 템플릿 프리셋 적용 핸들러
|
||||
const handleApplyBodyPreset = useCallback(
|
||||
(presetKey: string) => {
|
||||
const preset = JSON_BODY_TEMPLATES[presetKey as keyof typeof JSON_BODY_TEMPLATES];
|
||||
if (preset) {
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
bodyTemplate: preset.template,
|
||||
});
|
||||
}
|
||||
},
|
||||
[settings, onSettingsChange],
|
||||
);
|
||||
|
||||
// 인증 설정 변경 핸들러
|
||||
const handleAuthChange = useCallback(
|
||||
(auth: Partial<AuthenticationType>) => {
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
authentication: {
|
||||
...settings.authentication,
|
||||
...auth,
|
||||
} as AuthenticationType,
|
||||
});
|
||||
},
|
||||
[settings, onSettingsChange],
|
||||
);
|
||||
|
||||
// 타임아웃 변경 핸들러 (초 단위를 밀리초로 변환)
|
||||
const handleTimeoutChange = useCallback(
|
||||
(timeoutInSeconds: number) => {
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
timeout: timeoutInSeconds * 1000, // 초를 밀리초로 변환
|
||||
});
|
||||
},
|
||||
[settings, onSettingsChange],
|
||||
);
|
||||
|
||||
// 재시도 횟수 변경 핸들러
|
||||
const handleRetryCountChange = useCallback(
|
||||
(retryCount: number) => {
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
retryCount,
|
||||
});
|
||||
},
|
||||
[settings, onSettingsChange],
|
||||
);
|
||||
|
||||
// 설정 유효성 검사
|
||||
const validateSettings = useCallback(() => {
|
||||
const errors: string[] = [];
|
||||
|
||||
// URL 검증
|
||||
if (!settings.apiUrl) {
|
||||
errors.push("API URL은 필수입니다.");
|
||||
} else if (!settings.apiUrl.startsWith("http")) {
|
||||
errors.push("API URL은 http:// 또는 https://로 시작해야 합니다.");
|
||||
}
|
||||
|
||||
// 바디 템플릿 JSON 검증 (POST/PUT/PATCH 메서드인 경우)
|
||||
if (["POST", "PUT", "PATCH"].includes(settings.httpMethod) && settings.bodyTemplate) {
|
||||
try {
|
||||
// 템플릿 변수를 임시 값으로 치환하여 JSON 유효성 검사
|
||||
const testTemplate = settings.bodyTemplate.replace(/\{\{[^}]+\}\}/g, '"test_value"');
|
||||
JSON.parse(testTemplate);
|
||||
} catch {
|
||||
errors.push("요청 바디 템플릿이 유효한 JSON 형식이 아닙니다.");
|
||||
}
|
||||
}
|
||||
|
||||
// 인증 설정 검증
|
||||
if (settings.authentication?.type === "bearer" && !settings.authentication.token) {
|
||||
errors.push("Bearer 토큰이 필요합니다.");
|
||||
}
|
||||
if (
|
||||
settings.authentication?.type === "basic" &&
|
||||
(!settings.authentication.username || !settings.authentication.password)
|
||||
) {
|
||||
errors.push("Basic 인증에는 사용자명과 비밀번호가 필요합니다.");
|
||||
}
|
||||
if (settings.authentication?.type === "api-key" && !settings.authentication.apiKey) {
|
||||
errors.push("API 키가 필요합니다.");
|
||||
}
|
||||
|
||||
setValidationErrors(errors);
|
||||
return errors.length === 0;
|
||||
}, [settings]);
|
||||
|
||||
// 설정 변경 시 유효성 검사 실행
|
||||
useEffect(() => {
|
||||
validateSettings();
|
||||
}, [validateSettings]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 유효성 검사 오류 표시 */}
|
||||
{validationErrors.length > 0 && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<div className="space-y-1">
|
||||
{validationErrors.map((error, index) => (
|
||||
<div key={index} className="text-sm">
|
||||
• {error}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="basic">기본 설정</TabsTrigger>
|
||||
<TabsTrigger value="headers">헤더</TabsTrigger>
|
||||
<TabsTrigger value="body">요청 바디</TabsTrigger>
|
||||
<TabsTrigger value="auth">인증</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 기본 설정 탭 */}
|
||||
<TabsContent value="basic" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Globe className="h-4 w-4" />
|
||||
기본 설정
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* API URL */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apiUrl">API URL *</Label>
|
||||
<Input
|
||||
id="apiUrl"
|
||||
type="url"
|
||||
placeholder="https://api.example.com/webhook"
|
||||
value={settings.apiUrl}
|
||||
onChange={(e) => handleUrlChange(e.target.value)}
|
||||
disabled={readonly}
|
||||
className={validationErrors.some((e) => e.includes("URL")) ? "border-red-500" : ""}
|
||||
/>
|
||||
<div className="text-muted-foreground text-xs">호출할 API의 전체 URL을 입력하세요.</div>
|
||||
</div>
|
||||
|
||||
{/* HTTP 메서드 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="httpMethod">HTTP 메서드</Label>
|
||||
<Select value={settings.httpMethod} onValueChange={handleMethodChange} disabled={readonly}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</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>
|
||||
|
||||
{/* 고급 설정 (접을 수 있는 섹션) */}
|
||||
<Collapsible open={isAdvancedOpen} onOpenChange={setIsAdvancedOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" className="h-auto w-full justify-between p-0">
|
||||
<span className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
고급 설정
|
||||
</span>
|
||||
{isAdvancedOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-4 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* 타임아웃 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="timeout">타임아웃 (초)</Label>
|
||||
<Input
|
||||
id="timeout"
|
||||
type="number"
|
||||
min="1"
|
||||
max="300"
|
||||
value={Math.round((settings.timeout || DEFAULT_TIMEOUT_CONFIG.request) / 1000)}
|
||||
onChange={(e) => handleTimeoutChange(parseInt(e.target.value))}
|
||||
disabled={readonly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 재시도 횟수 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="retryCount">재시도 횟수</Label>
|
||||
<Input
|
||||
id="retryCount"
|
||||
type="number"
|
||||
min="0"
|
||||
max="10"
|
||||
value={settings.retryCount || DEFAULT_RETRY_POLICY.maxRetries}
|
||||
onChange={(e) => handleRetryCountChange(parseInt(e.target.value))}
|
||||
disabled={readonly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 헤더 탭 */}
|
||||
<TabsContent value="headers" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">HTTP 헤더</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
{COMMON_HEADER_PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset.name}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleApplyHeaderPreset(preset.name)}
|
||||
disabled={readonly}
|
||||
className="text-xs"
|
||||
>
|
||||
{preset.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 기존 헤더 목록 */}
|
||||
<div className="space-y-2">
|
||||
{Object.entries(settings.headers).map(([key, value]) => (
|
||||
<div key={key} className="bg-muted flex items-center gap-2 rounded p-2">
|
||||
<div className="grid flex-1 grid-cols-2 gap-2">
|
||||
<Input value={key} disabled className="bg-background" />
|
||||
<Input value={value} disabled className="bg-background" />
|
||||
</div>
|
||||
{!readonly && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveHeader(key)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 새 헤더 추가 */}
|
||||
{!readonly && (
|
||||
<div className="space-y-2">
|
||||
<Label>새 헤더 추가</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="헤더명 (예: X-API-Key)"
|
||||
value={newHeaderKey}
|
||||
onChange={(e) => setNewHeaderKey(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="헤더값"
|
||||
value={newHeaderValue}
|
||||
onChange={(e) => setNewHeaderValue(e.target.value)}
|
||||
/>
|
||||
<Button onClick={handleAddHeader} disabled={!newHeaderKey || !newHeaderValue}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 요청 바디 탭 */}
|
||||
<TabsContent value="body" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">요청 바디 템플릿</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
{Object.entries(JSON_BODY_TEMPLATES).map(([key, template]) => (
|
||||
<Button
|
||||
key={key}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleApplyBodyPreset(key)}
|
||||
disabled={readonly}
|
||||
className="text-xs"
|
||||
>
|
||||
{template.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{["POST", "PUT", "PATCH"].includes(settings.httpMethod) ? (
|
||||
<>
|
||||
<Textarea
|
||||
placeholder="JSON 템플릿을 입력하세요..."
|
||||
value={settings.bodyTemplate}
|
||||
onChange={(e) => handleBodyTemplateChange(e.target.value)}
|
||||
disabled={readonly}
|
||||
className="min-h-[200px] font-mono text-sm"
|
||||
/>
|
||||
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
템플릿 변수를 사용할 수 있습니다: {"{{sourceData}}"}, {"{{timestamp}}"}, {"{{relationshipId}}"} 등
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{settings.httpMethod} 메서드는 요청 바디를 사용하지 않습니다.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 인증 탭 */}
|
||||
<TabsContent value="auth" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Key className="h-4 w-4" />
|
||||
인증 설정
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 인증 타입 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label>인증 방식</Label>
|
||||
<Select
|
||||
value={settings.authentication?.type || "none"}
|
||||
onValueChange={(type) => handleAuthChange({ type: type as AuthenticationType["type"] })}
|
||||
disabled={readonly}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">인증 없음</SelectItem>
|
||||
<SelectItem value="bearer">Bearer Token</SelectItem>
|
||||
<SelectItem value="basic">Basic Authentication</SelectItem>
|
||||
<SelectItem value="api-key">API Key</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 인증 타입별 설정 */}
|
||||
{settings.authentication?.type === "bearer" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bearerToken">Bearer Token *</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="bearerToken"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="토큰을 입력하세요"
|
||||
value={settings.authentication.token || ""}
|
||||
onChange={(e) => handleAuthChange({ token: e.target.value })}
|
||||
disabled={readonly}
|
||||
className="pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-0 right-0 h-full px-3"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{settings.authentication?.type === "basic" && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">사용자명 *</Label>
|
||||
<Input
|
||||
id="username"
|
||||
placeholder="사용자명"
|
||||
value={settings.authentication.username || ""}
|
||||
onChange={(e) => handleAuthChange({ username: e.target.value })}
|
||||
disabled={readonly}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">비밀번호 *</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="비밀번호"
|
||||
value={settings.authentication.password || ""}
|
||||
onChange={(e) => handleAuthChange({ password: e.target.value })}
|
||||
disabled={readonly}
|
||||
className="pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-0 right-0 h-full px-3"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{settings.authentication?.type === "api-key" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apiKey">API Key *</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="apiKey"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="API 키를 입력하세요"
|
||||
value={settings.authentication.apiKey || ""}
|
||||
onChange={(e) => handleAuthChange({ apiKey: e.target.value })}
|
||||
disabled={readonly}
|
||||
className="pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-0 right-0 h-full px-3"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>위치</Label>
|
||||
<Select
|
||||
value={settings.authentication.apiKeyLocation || "header"}
|
||||
onValueChange={(location) =>
|
||||
handleAuthChange({
|
||||
apiKeyLocation: location as "header" | "query",
|
||||
})
|
||||
}
|
||||
disabled={readonly}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="header">HTTP 헤더</SelectItem>
|
||||
<SelectItem value="query">쿼리 파라미터</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="keyName">키 이름</Label>
|
||||
<Input
|
||||
id="keyName"
|
||||
placeholder={settings.authentication.apiKeyLocation === "query" ? "api_key" : "X-API-Key"}
|
||||
value={settings.authentication.apiKeyHeader || settings.authentication.apiKeyQueryParam || ""}
|
||||
onChange={(e) => {
|
||||
if (settings.authentication?.apiKeyLocation === "query") {
|
||||
handleAuthChange({ apiKeyQueryParam: e.target.value });
|
||||
} else {
|
||||
handleAuthChange({ apiKeyHeader: e.target.value });
|
||||
}
|
||||
}}
|
||||
disabled={readonly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* 설정 상태 표시 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{validationErrors.length === 0 ? (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
<span className="text-sm text-green-600">설정이 완료되었습니다</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="h-4 w-4 text-orange-500" />
|
||||
<span className="text-sm text-orange-600">{validationErrors.length}개의 설정이 필요합니다</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Badge variant={validationErrors.length === 0 ? "default" : "secondary"}>
|
||||
{validationErrors.length === 0 ? "완료" : "미완료"}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RestApiSettings;
|
||||
|
|
@ -227,7 +227,7 @@ export const FloatingPanel: React.FC<FloatingPanelProps> = ({
|
|||
<div
|
||||
ref={panelRef}
|
||||
className={cn(
|
||||
"fixed z-[9998] rounded-lg border border-gray-200 bg-white shadow-lg",
|
||||
"fixed z-[9998] rounded-xl border border-gray-200/60 bg-white/95 backdrop-blur-sm shadow-xl shadow-gray-900/10",
|
||||
isDragging ? "cursor-move shadow-2xl" : "transition-all duration-200 ease-in-out",
|
||||
isResizing && "cursor-se-resize",
|
||||
className,
|
||||
|
|
@ -246,7 +246,7 @@ export const FloatingPanel: React.FC<FloatingPanelProps> = ({
|
|||
<div
|
||||
ref={dragHandleRef}
|
||||
data-header="true"
|
||||
className="flex cursor-move items-center justify-between rounded-t-lg border-b border-gray-200 bg-gray-50 p-3"
|
||||
className="flex cursor-move items-center justify-between rounded-t-xl border-b border-gray-200/60 bg-gradient-to-r from-gray-50 to-slate-50 p-4"
|
||||
onMouseDown={handleDragStart}
|
||||
style={{
|
||||
userSelect: "none", // 텍스트 선택 방지
|
||||
|
|
@ -259,8 +259,8 @@ export const FloatingPanel: React.FC<FloatingPanelProps> = ({
|
|||
<GripVertical className="h-4 w-4 text-gray-400" />
|
||||
<h3 className="text-sm font-medium text-gray-900">{title}</h3>
|
||||
</div>
|
||||
<button onClick={onClose} className="rounded p-1 transition-colors hover:bg-gray-200">
|
||||
<X className="h-4 w-4 text-gray-500" />
|
||||
<button onClick={onClose} className="rounded-lg p-2 transition-all duration-200 hover:bg-white/80 hover:shadow-sm">
|
||||
<X className="h-4 w-4 text-gray-500 hover:text-gray-700" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -282,7 +282,7 @@ export const FloatingPanel: React.FC<FloatingPanelProps> = ({
|
|||
{/* 리사이즈 핸들 */}
|
||||
{resizable && !autoHeight && (
|
||||
<div className="absolute right-0 bottom-0 h-4 w-4 cursor-se-resize" onMouseDown={handleResizeStart}>
|
||||
<div className="absolute right-1 bottom-1 h-2 w-2 rounded-sm bg-gray-400" />
|
||||
<div className="absolute right-1 bottom-1 h-2 w-2 rounded-sm bg-gradient-to-br from-gray-400 to-gray-500 shadow-sm" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import {
|
|||
RotateCw,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Grid,
|
||||
} from "lucide-react";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { commonCodeApi } from "@/lib/api/commonCode";
|
||||
|
|
@ -1721,7 +1722,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex h-full flex-col", className)} style={{ ...style, minHeight: "680px" }}>
|
||||
<div className={cn("flex h-full flex-col rounded-xl border border-gray-200/60 bg-gradient-to-br from-white to-gray-50/30 shadow-sm", className)} style={{ ...style, minHeight: "680px" }}>
|
||||
{/* 헤더 */}
|
||||
<div className="p-6 pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -1811,7 +1812,8 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
<div className="flex h-full flex-col">
|
||||
{visibleColumns.length > 0 ? (
|
||||
<>
|
||||
<Table>
|
||||
<div className="rounded-lg border border-gray-200/60 bg-white shadow-sm overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{/* 체크박스 컬럼 (삭제 기능이 활성화된 경우) */}
|
||||
|
|
@ -1826,7 +1828,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
{visibleColumns.map((column: DataTableColumn) => (
|
||||
<TableHead
|
||||
key={column.id}
|
||||
className="px-4 font-semibold"
|
||||
className="px-4 font-semibold text-gray-700 bg-gradient-to-r from-gray-50 to-slate-50"
|
||||
style={{ width: `${((column.gridColumns || 2) / totalGridColumns) * 100}%` }}
|
||||
>
|
||||
{column.label}
|
||||
|
|
@ -1850,7 +1852,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
</TableRow>
|
||||
) : data.length > 0 ? (
|
||||
data.map((row, rowIndex) => (
|
||||
<TableRow key={rowIndex} className="hover:bg-muted/50">
|
||||
<TableRow key={rowIndex} className="hover:bg-gradient-to-r hover:from-blue-50/50 hover:to-indigo-50/30 transition-all duration-200">
|
||||
{/* 체크박스 셀 (삭제 기능이 활성화된 경우) */}
|
||||
{component.enableDelete && (
|
||||
<TableCell className="w-12 px-4">
|
||||
|
|
@ -1861,7 +1863,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
</TableCell>
|
||||
)}
|
||||
{visibleColumns.map((column: DataTableColumn) => (
|
||||
<TableCell key={column.id} className="px-4 font-mono text-sm">
|
||||
<TableCell key={column.id} className="px-4 text-sm font-medium text-gray-900">
|
||||
{formatCellValue(row[column.columnName], column, row)}
|
||||
</TableCell>
|
||||
))}
|
||||
|
|
@ -1884,10 +1886,11 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{component.pagination?.enabled && totalPages > 1 && (
|
||||
<div className="bg-muted/20 mt-auto border-t">
|
||||
<div className="bg-gradient-to-r from-gray-50 to-slate-50 mt-auto border-t border-gray-200/60">
|
||||
<div className="flex items-center justify-between px-6 py-3">
|
||||
{component.pagination.showPageInfo && (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import { enhancedFormService } from "@/lib/services/enhancedFormService";
|
|||
import { FormValidationIndicator } from "@/components/common/FormValidationIndicator";
|
||||
import { useFormValidation } from "@/hooks/useFormValidation";
|
||||
import { UnifiedColumnInfo as ColumnInfo } from "@/types";
|
||||
import { isFileComponent } from "@/lib/utils/componentTypeUtils";
|
||||
|
||||
interface InteractiveScreenViewerProps {
|
||||
component: ComponentData;
|
||||
|
|
@ -771,11 +772,17 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
|
||||
const currentValue = getCurrentValue();
|
||||
|
||||
// 화면 ID 추출 (URL에서)
|
||||
const screenId = typeof window !== 'undefined' && window.location.pathname.includes('/screens/')
|
||||
? parseInt(window.location.pathname.split('/screens/')[1])
|
||||
: null;
|
||||
|
||||
console.log("📁 InteractiveScreenViewer - File 위젯:", {
|
||||
componentId: widget.id,
|
||||
widgetType: widget.widgetType,
|
||||
config,
|
||||
currentValue,
|
||||
screenId,
|
||||
appliedSettings: {
|
||||
accept: config?.accept,
|
||||
multiple: config?.multiple,
|
||||
|
|
@ -1572,7 +1579,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
};
|
||||
|
||||
// 파일 첨부 컴포넌트 처리
|
||||
if (component.type === "file") {
|
||||
if (isFileComponent(component)) {
|
||||
const fileComponent = component as FileComponent;
|
||||
|
||||
console.log("🎯 File 컴포넌트 렌더링:", {
|
||||
|
|
@ -1719,17 +1726,19 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full w-full">
|
||||
<div className="h-full w-full rounded-xl border border-gray-200/60 bg-gradient-to-br from-white to-gray-50/30 p-4 shadow-sm transition-all duration-200 hover:shadow-md">
|
||||
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
|
||||
{shouldShowLabel && (
|
||||
<div className="block" style={labelStyle}>
|
||||
{labelText}
|
||||
{component.required && <span style={{ color: "#f97316", marginLeft: "2px" }}>*</span>}
|
||||
<div className="block mb-3" style={labelStyle}>
|
||||
<div className="inline-flex items-center bg-gray-100 px-3 py-1 rounded-lg text-sm font-semibold">
|
||||
{labelText}
|
||||
{component.required && <span style={{ color: "#f97316", marginLeft: "4px" }}>*</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 실제 위젯 */}
|
||||
<div className="h-full w-full">{renderInteractiveWidget(component)}</div>
|
||||
<div className="flex-1 rounded-lg overflow-hidden">{renderInteractiveWidget(component)}</div>
|
||||
</div>
|
||||
|
||||
{/* 개선된 검증 패널 (선택적 표시) */}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { InteractiveDataTable } from "./InteractiveDataTable";
|
|||
import { DynamicWebTypeRenderer } from "@/lib/registry";
|
||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
import { isFileComponent, isDataTableComponent, isButtonComponent } from "@/lib/utils/componentTypeUtils";
|
||||
|
||||
// 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록
|
||||
import "@/lib/registry/components/ButtonRenderer";
|
||||
|
|
@ -143,7 +144,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
// 동적 대화형 위젯 렌더링
|
||||
const renderInteractiveWidget = (comp: ComponentData) => {
|
||||
// 데이터 테이블 컴포넌트 처리
|
||||
if (comp.type === "datatable") {
|
||||
if (isDataTableComponent(comp)) {
|
||||
return (
|
||||
<InteractiveDataTable
|
||||
component={comp as DataTableComponent}
|
||||
|
|
@ -157,12 +158,12 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
}
|
||||
|
||||
// 버튼 컴포넌트 처리
|
||||
if (comp.type === "button") {
|
||||
if (isButtonComponent(comp)) {
|
||||
return renderButton(comp);
|
||||
}
|
||||
|
||||
// 파일 컴포넌트 처리
|
||||
if (comp.type === "file") {
|
||||
if (isFileComponent(comp)) {
|
||||
return renderFileComponent(comp as FileComponent);
|
||||
}
|
||||
|
||||
|
|
@ -413,6 +414,12 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
const { label, readonly } = comp;
|
||||
const fieldName = comp.columnName || comp.id;
|
||||
|
||||
// 화면 ID 추출 (URL에서)
|
||||
const screenId = screenInfo?.screenId ||
|
||||
(typeof window !== 'undefined' && window.location.pathname.includes('/screens/')
|
||||
? parseInt(window.location.pathname.split('/screens/')[1])
|
||||
: null);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
{/* 실제 FileUploadComponent 사용 */}
|
||||
|
|
@ -433,12 +440,18 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
isInteractive={true}
|
||||
isDesignMode={false}
|
||||
formData={{
|
||||
tableName: screenInfo?.tableName,
|
||||
screenId, // 🎯 화면 ID 전달
|
||||
// 🎯 백엔드 API가 기대하는 정확한 형식으로 설정
|
||||
autoLink: true, // 자동 연결 활성화
|
||||
linkedTable: 'screen_files', // 연결 테이블
|
||||
recordId: screenId, // 레코드 ID
|
||||
columnName: fieldName, // 컬럼명 (중요!)
|
||||
isVirtualFileColumn: true, // 가상 파일 컬럼
|
||||
id: formData.id,
|
||||
...formData
|
||||
}}
|
||||
onFormDataChange={(data) => {
|
||||
console.log("📝 파일 업로드 완료:", data);
|
||||
console.log("📝 실제 화면 파일 업로드 완료:", data);
|
||||
if (onFormDataChange) {
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
onFormDataChange(key, value);
|
||||
|
|
@ -446,11 +459,57 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
}
|
||||
}}
|
||||
onUpdate={(updates) => {
|
||||
console.log("🔄 파일 컴포넌트 업데이트:", updates);
|
||||
// 파일 업로드 완료 시 formData 업데이트
|
||||
console.log("🔄🔄🔄 실제 화면 파일 컴포넌트 업데이트:", {
|
||||
componentId: comp.id,
|
||||
hasUploadedFiles: !!updates.uploadedFiles,
|
||||
filesCount: updates.uploadedFiles?.length || 0,
|
||||
hasLastFileUpdate: !!updates.lastFileUpdate,
|
||||
updates
|
||||
});
|
||||
|
||||
// 파일 업로드/삭제 완료 시 formData 업데이터
|
||||
if (updates.uploadedFiles && onFormDataChange) {
|
||||
onFormDataChange(fieldName, updates.uploadedFiles);
|
||||
}
|
||||
|
||||
// 🎯 화면설계 모드와 동기화를 위한 전역 이벤트 발생 (업로드/삭제 모두)
|
||||
if (updates.uploadedFiles !== undefined && typeof window !== 'undefined') {
|
||||
// 업로드인지 삭제인지 판단 (lastFileUpdate가 있으면 변경사항 있음)
|
||||
const action = updates.lastFileUpdate ? 'update' : 'sync';
|
||||
|
||||
const eventDetail = {
|
||||
componentId: comp.id,
|
||||
files: updates.uploadedFiles,
|
||||
fileCount: updates.uploadedFiles.length,
|
||||
action: action,
|
||||
timestamp: updates.lastFileUpdate || Date.now(),
|
||||
source: 'realScreen' // 실제 화면에서 온 이벤트임을 표시
|
||||
};
|
||||
|
||||
console.log("🚀🚀🚀 실제 화면 파일 변경 이벤트 발생:", eventDetail);
|
||||
|
||||
const event = new CustomEvent('globalFileStateChanged', {
|
||||
detail: eventDetail
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
|
||||
console.log("✅✅✅ 실제 화면 → 화면설계 모드 동기화 이벤트 발생 완료");
|
||||
|
||||
// 추가 지연 이벤트들 (화면설계 모드가 열려있을 때를 대비)
|
||||
setTimeout(() => {
|
||||
console.log("🔄 실제 화면 추가 이벤트 발생 (지연 100ms)");
|
||||
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||
detail: { ...eventDetail, delayed: true }
|
||||
}));
|
||||
}, 100);
|
||||
|
||||
setTimeout(() => {
|
||||
console.log("🔄 실제 화면 추가 이벤트 발생 (지연 500ms)");
|
||||
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||
detail: { ...eventDetail, delayed: true, attempt: 2 }
|
||||
}));
|
||||
}, 500);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -473,19 +532,19 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
return (
|
||||
<>
|
||||
<div className="absolute" style={componentStyle}>
|
||||
<div className="h-full w-full">
|
||||
<div className="h-full w-full rounded-xl border border-gray-200/60 bg-gradient-to-br from-white to-gray-50/30 p-4 shadow-sm transition-all duration-200 hover:shadow-md">
|
||||
{/* 라벨 표시 - 컴포넌트 내부에서 라벨을 처리하므로 외부에서는 표시하지 않음 */}
|
||||
{!hideLabel && component.label && component.style?.labelDisplay === false && (
|
||||
<div className="mb-1">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
<div className="mb-3">
|
||||
<div className="inline-flex items-center bg-gray-100 px-3 py-1 rounded-lg text-sm font-semibold text-gray-700">
|
||||
{component.label}
|
||||
{(component as WidgetComponent).required && <span className="ml-1 text-red-500">*</span>}
|
||||
</label>
|
||||
{(component as WidgetComponent).required && <span className="ml-1 text-orange-500">*</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 위젯 렌더링 */}
|
||||
<div className="flex-1">{renderInteractiveWidget(component)}</div>
|
||||
<div className="flex-1 rounded-lg overflow-hidden">{renderInteractiveWidget(component)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { ComponentData, WebType, isWidgetComponent, isContainerComponent } from "@/types";
|
||||
import { isFileComponent } from "@/lib/utils/componentTypeUtils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
|
@ -126,6 +127,17 @@ const WidgetRenderer: React.FC<{ component: ComponentData }> = ({ component }) =
|
|||
className: `w-full h-full ${borderClass}`,
|
||||
};
|
||||
|
||||
// 파일 컴포넌트는 별도 로직에서 처리하므로 여기서는 제외
|
||||
if (isFileComponent(widget)) {
|
||||
console.log("🎯 RealtimePreview - 파일 컴포넌트 감지 (별도 처리):", {
|
||||
componentId: widget.id,
|
||||
widgetType: widgetType,
|
||||
isFileComponent: true
|
||||
});
|
||||
|
||||
return <div className="text-xs text-gray-500 p-2">파일 컴포넌트 (별도 렌더링)</div>;
|
||||
}
|
||||
|
||||
// 동적 웹타입 렌더링 사용
|
||||
if (widgetType) {
|
||||
try {
|
||||
|
|
@ -209,6 +221,89 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
}) => {
|
||||
const { user } = useAuth();
|
||||
const { type, id, position, size, style = {} } = component;
|
||||
const [fileUpdateTrigger, setFileUpdateTrigger] = useState(0);
|
||||
|
||||
// 전역 파일 상태 변경 감지 (해당 컴포넌트만)
|
||||
useEffect(() => {
|
||||
const handleGlobalFileStateChange = (event: CustomEvent) => {
|
||||
console.log("🎯🎯🎯 RealtimePreview 이벤트 수신:", {
|
||||
eventComponentId: event.detail.componentId,
|
||||
currentComponentId: component.id,
|
||||
isMatch: event.detail.componentId === component.id,
|
||||
filesCount: event.detail.files?.length || 0,
|
||||
action: event.detail.action,
|
||||
delayed: event.detail.delayed || false,
|
||||
attempt: event.detail.attempt || 1,
|
||||
eventDetail: event.detail
|
||||
});
|
||||
|
||||
if (event.detail.componentId === component.id) {
|
||||
console.log("✅✅✅ RealtimePreview 파일 상태 변경 감지 - 리렌더링 시작:", {
|
||||
componentId: component.id,
|
||||
filesCount: event.detail.files?.length || 0,
|
||||
action: event.detail.action,
|
||||
oldTrigger: fileUpdateTrigger,
|
||||
delayed: event.detail.delayed || false,
|
||||
attempt: event.detail.attempt || 1
|
||||
});
|
||||
setFileUpdateTrigger(prev => {
|
||||
const newTrigger = prev + 1;
|
||||
console.log("🔄🔄🔄 fileUpdateTrigger 업데이트:", {
|
||||
old: prev,
|
||||
new: newTrigger,
|
||||
componentId: component.id,
|
||||
attempt: event.detail.attempt || 1
|
||||
});
|
||||
return newTrigger;
|
||||
});
|
||||
} else {
|
||||
console.log("❌ 컴포넌트 ID 불일치:", {
|
||||
eventComponentId: event.detail.componentId,
|
||||
currentComponentId: component.id
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 강제 업데이트 함수 등록
|
||||
const forceUpdate = (componentId: string, files: any[]) => {
|
||||
console.log("🔥🔥🔥 RealtimePreview 강제 업데이트 호출:", {
|
||||
targetComponentId: componentId,
|
||||
currentComponentId: component.id,
|
||||
isMatch: componentId === component.id,
|
||||
filesCount: files.length
|
||||
});
|
||||
|
||||
if (componentId === component.id) {
|
||||
console.log("✅✅✅ RealtimePreview 강제 업데이트 적용:", {
|
||||
componentId: component.id,
|
||||
filesCount: files.length,
|
||||
oldTrigger: fileUpdateTrigger
|
||||
});
|
||||
setFileUpdateTrigger(prev => {
|
||||
const newTrigger = prev + 1;
|
||||
console.log("🔄🔄🔄 강제 fileUpdateTrigger 업데이트:", {
|
||||
old: prev,
|
||||
new: newTrigger,
|
||||
componentId: component.id
|
||||
});
|
||||
return newTrigger;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
|
||||
|
||||
// 전역 강제 업데이트 함수 등록
|
||||
if (!(window as any).forceRealtimePreviewUpdate) {
|
||||
(window as any).forceRealtimePreviewUpdate = forceUpdate;
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
|
||||
};
|
||||
}
|
||||
}, [component.id, fileUpdateTrigger]);
|
||||
|
||||
// 컴포넌트 스타일 계산
|
||||
const componentStyle = {
|
||||
|
|
@ -299,8 +394,8 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 위젯 타입 - 동적 렌더링 */}
|
||||
{type === "widget" && (
|
||||
{/* 위젯 타입 - 동적 렌더링 (파일 컴포넌트 제외) */}
|
||||
{type === "widget" && !isFileComponent(component) && (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="pointer-events-none flex-1">
|
||||
<WidgetRenderer component={component} />
|
||||
|
|
@ -308,8 +403,8 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 파일 타입 */}
|
||||
{type === "file" && (() => {
|
||||
{/* 파일 타입 - 레거시 및 신규 타입 지원 */}
|
||||
{isFileComponent(component) && (() => {
|
||||
const fileComponent = component as any;
|
||||
const uploadedFiles = fileComponent.uploadedFiles || [];
|
||||
|
||||
|
|
@ -327,11 +422,12 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
currentFilesCount: currentFiles.length,
|
||||
currentFiles: currentFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName || f.name })),
|
||||
componentType: component.type,
|
||||
fileUpdateTrigger: fileUpdateTrigger,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div key={`file-component-${component.id}-${fileUpdateTrigger}`} className="flex h-full flex-col">
|
||||
<div className="pointer-events-none flex-1 rounded border-2 border-dashed border-gray-300 bg-gray-50 p-2">
|
||||
{currentFiles.length > 0 ? (
|
||||
<div className="h-full overflow-y-auto">
|
||||
|
|
|
|||
|
|
@ -734,6 +734,100 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
initComponents();
|
||||
}, []);
|
||||
|
||||
// 화면 선택 시 파일 복원
|
||||
useEffect(() => {
|
||||
if (selectedScreen?.screenId) {
|
||||
restoreScreenFiles();
|
||||
}
|
||||
}, [selectedScreen?.screenId]);
|
||||
|
||||
// 화면의 모든 파일 컴포넌트 파일 복원
|
||||
const restoreScreenFiles = useCallback(async () => {
|
||||
if (!selectedScreen?.screenId) return;
|
||||
|
||||
try {
|
||||
console.log("🔄 화면 파일 복원 시작:", selectedScreen.screenId);
|
||||
|
||||
// 해당 화면의 모든 파일 조회
|
||||
const response = await ScreenFileAPI.getScreenComponentFiles(selectedScreen.screenId);
|
||||
|
||||
if (response.success && response.componentFiles) {
|
||||
console.log("📁 복원할 파일 데이터:", response.componentFiles);
|
||||
|
||||
// 각 컴포넌트별로 파일 복원 (전역 상태와 localStorage 우선 적용)
|
||||
Object.entries(response.componentFiles).forEach(([componentId, serverFiles]) => {
|
||||
if (Array.isArray(serverFiles) && serverFiles.length > 0) {
|
||||
// 🎯 전역 상태와 localStorage에서 현재 파일 상태 확인
|
||||
const globalFileState = typeof window !== 'undefined' ? (window as any).globalFileState || {} : {};
|
||||
const currentGlobalFiles = globalFileState[componentId] || [];
|
||||
|
||||
let currentLocalStorageFiles: any[] = [];
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const storedFiles = localStorage.getItem(`fileComponent_${componentId}_files`);
|
||||
if (storedFiles) {
|
||||
currentLocalStorageFiles = JSON.parse(storedFiles);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("localStorage 파일 파싱 실패:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 🎯 우선순위: 전역 상태 > localStorage > 서버 데이터
|
||||
let finalFiles = serverFiles;
|
||||
if (currentGlobalFiles.length > 0) {
|
||||
finalFiles = currentGlobalFiles;
|
||||
console.log(`📂 컴포넌트 ${componentId} 전역 상태 우선 적용:`, finalFiles.length, "개");
|
||||
} else if (currentLocalStorageFiles.length > 0) {
|
||||
finalFiles = currentLocalStorageFiles;
|
||||
console.log(`📂 컴포넌트 ${componentId} localStorage 우선 적용:`, finalFiles.length, "개");
|
||||
} else {
|
||||
console.log(`📂 컴포넌트 ${componentId} 서버 데이터 적용:`, finalFiles.length, "개");
|
||||
}
|
||||
|
||||
// 전역 상태에 파일 저장
|
||||
globalFileState[componentId] = finalFiles;
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).globalFileState = globalFileState;
|
||||
}
|
||||
|
||||
// localStorage에도 백업
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(`fileComponent_${componentId}_files`, JSON.stringify(finalFiles));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 레이아웃의 컴포넌트들에 파일 정보 적용 (전역 상태 우선)
|
||||
setLayout(prevLayout => {
|
||||
const updatedComponents = prevLayout.components.map(comp => {
|
||||
// 🎯 전역 상태에서 최신 파일 정보 가져오기
|
||||
const globalFileState = typeof window !== 'undefined' ? (window as any).globalFileState || {} : {};
|
||||
const finalFiles = globalFileState[comp.id] || [];
|
||||
|
||||
if (finalFiles.length > 0) {
|
||||
return {
|
||||
...comp,
|
||||
uploadedFiles: finalFiles,
|
||||
lastFileUpdate: Date.now()
|
||||
};
|
||||
}
|
||||
return comp;
|
||||
});
|
||||
|
||||
return {
|
||||
...prevLayout,
|
||||
components: updatedComponents
|
||||
};
|
||||
});
|
||||
|
||||
console.log("✅ 화면 파일 복원 완료");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 화면 파일 복원 오류:", error);
|
||||
}
|
||||
}, [selectedScreen?.screenId]);
|
||||
|
||||
// 전역 파일 상태 변경 이벤트 리스너
|
||||
useEffect(() => {
|
||||
const handleGlobalFileStateChange = (event: CustomEvent) => {
|
||||
|
|
@ -3302,7 +3396,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col bg-gray-100">
|
||||
<div className="flex h-screen w-full flex-col bg-gradient-to-br from-gray-50 to-slate-100">
|
||||
{/* 상단 툴바 */}
|
||||
<DesignerToolbar
|
||||
screenName={selectedScreen?.screenName}
|
||||
|
|
@ -3322,7 +3416,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
/>
|
||||
|
||||
{/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) - 좌우 최소화, 위아래 넉넉한 여유 */}
|
||||
<div className="relative flex-1 overflow-auto bg-gray-100 px-2 py-6">
|
||||
<div className="relative flex-1 overflow-auto bg-gradient-to-br from-gray-50 to-slate-100 px-2 py-6">
|
||||
{/* 해상도 정보 표시 - 적당한 여백 */}
|
||||
<div className="mb-4 flex items-center justify-center">
|
||||
<div className="rounded-lg border bg-white px-4 py-2 shadow-sm">
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ export default function ColumnComponent({
|
|||
className={cn(
|
||||
"flex-1 rounded border border-gray-200 p-2",
|
||||
isSelected && "border-blue-500 bg-blue-50",
|
||||
isMoving && "cursor-move shadow-lg",
|
||||
isMoving && "cursor-move",
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ export default function ContainerComponent({
|
|||
className={cn(
|
||||
"rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-4",
|
||||
isSelected && "border-blue-500 bg-blue-50",
|
||||
isMoving && "cursor-move shadow-lg",
|
||||
isMoving && "cursor-move",
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ export default function RowComponent({
|
|||
className={cn(
|
||||
"flex gap-4 rounded border border-gray-200 p-2",
|
||||
isSelected && "border-blue-500 bg-blue-50",
|
||||
isMoving && "cursor-move shadow-lg",
|
||||
isMoving && "cursor-move",
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
|
|
|
|||
|
|
@ -125,126 +125,145 @@ export function ComponentsPanel({ className }: ComponentsPanelProps) {
|
|||
};
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Package className="mr-2 h-5 w-5" />
|
||||
컴포넌트 ({componentsByCategory.all.length})
|
||||
<div className={`h-full bg-gradient-to-br from-slate-50 to-purple-50/30 border-r border-gray-200/60 shadow-sm ${className}`}>
|
||||
<div className="p-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-1">컴포넌트</h2>
|
||||
<p className="text-sm text-gray-500">{componentsByCategory.all.length}개의 사용 가능한 컴포넌트</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} title="컴포넌트 새로고침">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
title="컴포넌트 새로고침"
|
||||
className="bg-white/60 border-gray-200/60 hover:bg-white hover:border-gray-300"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</div>
|
||||
|
||||
{/* 검색창 */}
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute top-2.5 left-2 h-4 w-4" />
|
||||
<div className="relative mb-6">
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Input
|
||||
placeholder="컴포넌트 검색..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8"
|
||||
className="pl-10 border-0 bg-white/80 backdrop-blur-sm shadow-sm focus:bg-white transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</div>
|
||||
|
||||
<CardContent>
|
||||
<div className="px-6">
|
||||
<Tabs
|
||||
value={selectedCategory}
|
||||
onValueChange={(value) => setSelectedCategory(value as ComponentCategory | "all")}
|
||||
>
|
||||
{/* 카테고리 탭 (input 카테고리 제외) */}
|
||||
<TabsList className="grid w-full grid-cols-3 lg:grid-cols-5">
|
||||
<TabsTrigger value="all" className="flex items-center">
|
||||
<TabsList className="grid w-full grid-cols-3 lg:grid-cols-5 bg-white/60 backdrop-blur-sm border-0 p-1">
|
||||
<TabsTrigger value="all" className="flex items-center text-xs data-[state=active]:bg-purple-600 data-[state=active]:text-white">
|
||||
<Package className="mr-1 h-3 w-3" />
|
||||
전체
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="display" className="flex items-center">
|
||||
<TabsTrigger value="display" className="flex items-center text-xs data-[state=active]:bg-purple-600 data-[state=active]:text-white">
|
||||
<Palette className="mr-1 h-3 w-3" />
|
||||
표시
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="action" className="flex items-center">
|
||||
<TabsTrigger value="action" className="flex items-center text-xs data-[state=active]:bg-purple-600 data-[state=active]:text-white">
|
||||
<Zap className="mr-1 h-3 w-3" />
|
||||
액션
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="layout" className="flex items-center">
|
||||
<TabsTrigger value="layout" className="flex items-center text-xs data-[state=active]:bg-purple-600 data-[state=active]:text-white">
|
||||
<Layers className="mr-1 h-3 w-3" />
|
||||
레이아웃
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="utility" className="flex items-center">
|
||||
<TabsTrigger value="utility" className="flex items-center text-xs data-[state=active]:bg-purple-600 data-[state=active]:text-white">
|
||||
<Package className="mr-1 h-3 w-3" />
|
||||
유틸
|
||||
유틸리티
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 컴포넌트 목록 */}
|
||||
<div className="mt-4">
|
||||
<TabsContent value={selectedCategory} className="space-y-2">
|
||||
<div className="mt-6">
|
||||
<TabsContent value={selectedCategory} className="space-y-3">
|
||||
{filteredComponents.length > 0 ? (
|
||||
<div className="grid max-h-96 grid-cols-1 gap-2 overflow-y-auto">
|
||||
<div className="grid max-h-96 grid-cols-1 gap-3 overflow-y-auto pr-2">
|
||||
{filteredComponents.map((component) => (
|
||||
<div
|
||||
key={component.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, component)}
|
||||
className="hover:bg-accent flex cursor-grab items-center rounded-lg border p-3 transition-colors active:cursor-grabbing"
|
||||
onDragStart={(e) => {
|
||||
handleDragStart(e, component);
|
||||
// 드래그 시작 시 시각적 피드백
|
||||
e.currentTarget.style.opacity = '0.5';
|
||||
e.currentTarget.style.transform = 'rotate(-3deg) scale(0.95)';
|
||||
}}
|
||||
onDragEnd={(e) => {
|
||||
// 드래그 종료 시 원래 상태로 복원
|
||||
e.currentTarget.style.opacity = '1';
|
||||
e.currentTarget.style.transform = 'none';
|
||||
}}
|
||||
className="group cursor-grab rounded-lg border border-gray-200/40 bg-white/90 backdrop-blur-sm p-5 shadow-sm transition-all duration-300 hover:bg-white hover:shadow-lg hover:shadow-purple-500/15 hover:scale-[1.02] hover:border-purple-300/60 hover:-translate-y-1 active:cursor-grabbing active:scale-[0.98] active:translate-y-0"
|
||||
title={component.description}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<h4 className="truncate text-sm font-medium">{component.name}</h4>
|
||||
<div className="flex items-center space-x-1">
|
||||
{/* 카테고리 뱃지 */}
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{getCategoryIcon(component.category)}
|
||||
<span className="ml-1">{component.category}</span>
|
||||
</Badge>
|
||||
|
||||
{/* 새 컴포넌트 뱃지 */}
|
||||
<Badge variant="default" className="bg-green-500 text-xs">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-gradient-to-br from-purple-500 to-purple-600 text-white shadow-md group-hover:shadow-lg group-hover:scale-110 transition-all duration-300">
|
||||
{getCategoryIcon(component.category)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h4 className="font-semibold text-gray-900 text-sm leading-tight">{component.name}</h4>
|
||||
<Badge variant="default" className="bg-gradient-to-r from-emerald-500 to-emerald-600 text-white text-xs border-0 ml-2 px-2 py-1 rounded-full font-medium shadow-sm">
|
||||
신규
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground truncate text-xs">{component.description}</p>
|
||||
<p className="text-xs text-gray-500 line-clamp-2 leading-relaxed mb-3">{component.description}</p>
|
||||
|
||||
{/* 웹타입 및 크기 정보 */}
|
||||
<div className="text-muted-foreground mt-2 flex items-center justify-between text-xs">
|
||||
<span>웹타입: {component.webType}</span>
|
||||
<span>
|
||||
{component.defaultSize.width}×{component.defaultSize.height}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 태그 */}
|
||||
{component.tags && component.tags.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{component.tags.slice(0, 3).map((tag, index) => (
|
||||
<Badge key={index} variant="outline" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{component.tags.length > 3 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{component.tags.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center space-x-2 text-xs text-gray-400">
|
||||
<span className="bg-gradient-to-r from-gray-100 to-gray-200 px-3 py-1 rounded-full font-medium text-gray-600">
|
||||
{component.defaultSize.width}×{component.defaultSize.height}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-purple-600 capitalize bg-gradient-to-r from-purple-50 to-indigo-50 px-3 py-1 rounded-full border border-purple-200/50">
|
||||
{component.category}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 태그 */}
|
||||
{component.tags && component.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{component.tags.slice(0, 2).map((tag, index) => (
|
||||
<Badge key={index} variant="outline" className="text-xs bg-gradient-to-r from-gray-50 to-gray-100 text-gray-600 border-gray-200/50 rounded-full px-2 py-1">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{component.tags.length > 2 && (
|
||||
<Badge variant="outline" className="text-xs bg-gradient-to-r from-gray-50 to-gray-100 text-gray-600 border-gray-200/50 rounded-full px-2 py-1">
|
||||
+{component.tags.length - 2}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground py-8 text-center">
|
||||
<Package className="mx-auto mb-3 h-12 w-12 opacity-50" />
|
||||
<p className="text-sm">
|
||||
{searchQuery
|
||||
? `"${searchQuery}"에 대한 검색 결과가 없습니다.`
|
||||
: "이 카테고리에 컴포넌트가 없습니다."}
|
||||
</p>
|
||||
<div className="py-12 text-center text-gray-500">
|
||||
<div className="p-8">
|
||||
<Package className="mx-auto mb-3 h-12 w-12 text-gray-300" />
|
||||
<p className="text-sm font-medium text-gray-600">
|
||||
{searchQuery
|
||||
? `"${searchQuery}"에 대한 컴포넌트를 찾을 수 없습니다`
|
||||
: "이 카테고리에 컴포넌트가 없습니다"}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">검색어나 필터를 조정해보세요</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
|
@ -252,31 +271,40 @@ export function ComponentsPanel({ className }: ComponentsPanelProps) {
|
|||
</Tabs>
|
||||
|
||||
{/* 통계 정보 */}
|
||||
<div className="mt-4 border-t pt-3">
|
||||
<div className="mt-6 rounded-xl bg-gradient-to-r from-purple-50 to-pink-50 border border-purple-100/60 p-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-lg font-bold text-green-600">{filteredComponents.length}</div>
|
||||
<div className="text-muted-foreground text-xs">표시된 컴포넌트</div>
|
||||
<div className="text-lg font-bold text-emerald-600">{filteredComponents.length}</div>
|
||||
<div className="text-xs text-gray-500">필터됨</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-blue-600">{allComponents.length}</div>
|
||||
<div className="text-muted-foreground text-xs">전체 컴포넌트</div>
|
||||
<div className="text-lg font-bold text-purple-600">{allComponents.length}</div>
|
||||
<div className="text-xs text-gray-500">전체</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 개발 정보 (개발 모드에서만) */}
|
||||
{process.env.NODE_ENV === "development" && (
|
||||
<div className="mt-4 border-t pt-3">
|
||||
<div className="text-muted-foreground space-y-1 text-xs">
|
||||
<div>🔧 레지스트리 기반 시스템</div>
|
||||
<div>⚡ Hot Reload 지원</div>
|
||||
<div>🛡️ 완전한 타입 안전성</div>
|
||||
<div className="mt-4 rounded-xl bg-gradient-to-r from-gray-50 to-slate-50 border border-gray-100/60 p-4">
|
||||
<div className="space-y-1 text-xs text-gray-600">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="w-2 h-2 bg-blue-500 rounded-full"></span>
|
||||
<span>레지스트리 기반 시스템</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full"></span>
|
||||
<span>Hot Reload 지원</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="w-2 h-2 bg-purple-500 rounded-full"></span>
|
||||
<span>완전한 타입 안전성</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
} from "@/types/screen";
|
||||
// 레거시 ButtonConfigPanel 제거됨
|
||||
import { FileComponentConfigPanel } from "./FileComponentConfigPanel";
|
||||
import { isFileComponent } from "@/lib/utils/componentTypeUtils";
|
||||
|
||||
// 새로운 컴포넌트 설정 패널들 import
|
||||
import { ButtonConfigPanel as NewButtonConfigPanel } from "../config-panels/ButtonConfigPanel";
|
||||
|
|
@ -908,7 +909,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
}
|
||||
|
||||
// 파일 컴포넌트인 경우 FileComponentConfigPanel 렌더링
|
||||
if (selectedComponent.type === "file" || (selectedComponent.type === "widget" && selectedComponent.widgetType === "file")) {
|
||||
if (isFileComponent(selectedComponent)) {
|
||||
const fileComponent = selectedComponent as FileComponent;
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
|||
import { FileComponent, TableInfo } from "@/types/screen";
|
||||
import { Plus, X, Upload, File, Image, FileText, Download, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FileInfo } from "@/lib/registry/components/file-upload/types";
|
||||
import { FileInfo, FileUploadResponse } from "@/lib/registry/components/file-upload/types";
|
||||
import { uploadFiles, downloadFile, deleteFile } from "@/lib/api/file";
|
||||
import { formatFileSize } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -28,6 +28,13 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
currentTable,
|
||||
currentTableName,
|
||||
}) => {
|
||||
console.log("🎨🎨🎨 FileComponentConfigPanel 렌더링:", {
|
||||
componentId: component?.id,
|
||||
componentType: component?.type,
|
||||
hasOnUpdateProperty: !!onUpdateProperty,
|
||||
currentTable,
|
||||
currentTableName
|
||||
});
|
||||
// fileConfig가 없는 경우 초기화
|
||||
React.useEffect(() => {
|
||||
if (!component.fileConfig) {
|
||||
|
|
@ -112,13 +119,18 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
const componentFiles = component.uploadedFiles || [];
|
||||
const globalFiles = getGlobalFileState()[component.id] || [];
|
||||
|
||||
// localStorage 백업에서 복원 (영구 저장된 파일 + 임시 파일)
|
||||
// localStorage 백업에서 복원 (영구 저장된 파일 + 임시 파일 + FileUploadComponent 백업)
|
||||
const backupKey = `fileComponent_${component.id}_files`;
|
||||
const tempBackupKey = `fileComponent_${component.id}_files_temp`;
|
||||
const fileUploadBackupKey = `fileUpload_${component.id}`; // 🎯 실제 화면과 동기화
|
||||
|
||||
const backupFiles = localStorage.getItem(backupKey);
|
||||
const tempBackupFiles = localStorage.getItem(tempBackupKey);
|
||||
const fileUploadBackupFiles = localStorage.getItem(fileUploadBackupKey); // 🎯 실제 화면 백업
|
||||
|
||||
let parsedBackupFiles: FileInfo[] = [];
|
||||
let parsedTempFiles: FileInfo[] = [];
|
||||
let parsedFileUploadFiles: FileInfo[] = []; // 🎯 실제 화면 파일
|
||||
|
||||
if (backupFiles) {
|
||||
try {
|
||||
|
|
@ -136,8 +148,18 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
}
|
||||
}
|
||||
|
||||
// 우선순위: 전역 상태 > localStorage > 임시 파일 > 컴포넌트 속성
|
||||
// 🎯 실제 화면 FileUploadComponent 백업 파싱
|
||||
if (fileUploadBackupFiles) {
|
||||
try {
|
||||
parsedFileUploadFiles = JSON.parse(fileUploadBackupFiles);
|
||||
} catch (error) {
|
||||
console.error("FileUploadComponent 백업 파일 파싱 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 🎯 우선순위: 전역 상태 > FileUploadComponent 백업 > localStorage > 임시 파일 > 컴포넌트 속성
|
||||
const finalFiles = globalFiles.length > 0 ? globalFiles :
|
||||
parsedFileUploadFiles.length > 0 ? parsedFileUploadFiles : // 🎯 실제 화면 우선
|
||||
parsedBackupFiles.length > 0 ? parsedBackupFiles :
|
||||
parsedTempFiles.length > 0 ? parsedTempFiles :
|
||||
componentFiles;
|
||||
|
|
@ -148,8 +170,12 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
globalFiles: globalFiles.length,
|
||||
backupFiles: parsedBackupFiles.length,
|
||||
tempFiles: parsedTempFiles.length,
|
||||
fileUploadFiles: parsedFileUploadFiles.length, // 🎯 실제 화면 파일 수
|
||||
finalFiles: finalFiles.length,
|
||||
source: globalFiles.length > 0 ? 'global' : parsedBackupFiles.length > 0 ? 'localStorage' : parsedTempFiles.length > 0 ? 'temp' : 'component'
|
||||
source: globalFiles.length > 0 ? 'global' :
|
||||
parsedFileUploadFiles.length > 0 ? 'fileUploadComponent' : // 🎯 실제 화면 소스
|
||||
parsedBackupFiles.length > 0 ? 'localStorage' :
|
||||
parsedTempFiles.length > 0 ? 'temp' : 'component'
|
||||
});
|
||||
|
||||
return finalFiles;
|
||||
|
|
@ -190,7 +216,17 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
|
||||
// 파일 업로드 처리
|
||||
const handleFileUpload = useCallback(async (files: FileList | File[]) => {
|
||||
if (!files || files.length === 0) return;
|
||||
console.log("🚀🚀🚀 FileComponentConfigPanel 파일 업로드 시작:", {
|
||||
filesCount: files?.length || 0,
|
||||
componentId: component?.id,
|
||||
componentType: component?.type,
|
||||
hasOnUpdateProperty: !!onUpdateProperty
|
||||
});
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
console.log("❌ 파일이 없음");
|
||||
return;
|
||||
}
|
||||
|
||||
const fileArray = Array.from(files);
|
||||
const validFiles: File[] = [];
|
||||
|
|
@ -291,23 +327,49 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
setUploading(true);
|
||||
toast.loading(`${filesToUpload.length}개 파일 업로드 중...`);
|
||||
|
||||
// 그리드와 연동되는 targetObjid 생성 (화면 복원 시스템과 통일)
|
||||
const tableName = 'screen_files';
|
||||
const screenId = (window as any).__CURRENT_SCREEN_ID__ || 'unknown'; // 현재 화면 ID
|
||||
// 🎯 여러 방법으로 screenId 확인
|
||||
let screenId = (window as any).__CURRENT_SCREEN_ID__;
|
||||
|
||||
// 1차: 전역 변수에서 가져오기
|
||||
if (!screenId) {
|
||||
// 2차: URL에서 추출 시도
|
||||
if (typeof window !== 'undefined' && window.location.pathname.includes('/screens/')) {
|
||||
const pathScreenId = window.location.pathname.split('/screens/')[1];
|
||||
if (pathScreenId && !isNaN(parseInt(pathScreenId))) {
|
||||
screenId = parseInt(pathScreenId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3차: 기본값 설정
|
||||
if (!screenId) {
|
||||
screenId = 40; // 기본 화면 ID (디자인 모드용)
|
||||
console.warn("⚠️ screenId를 찾을 수 없어 기본값(40) 사용");
|
||||
}
|
||||
|
||||
const componentId = component.id;
|
||||
const fieldName = component.columnName || component.id || 'file_attachment';
|
||||
const targetObjid = `${tableName}:${screenId}:${componentId}:${fieldName}`;
|
||||
|
||||
console.log("📋 파일 업로드 기본 정보:", {
|
||||
screenId,
|
||||
screenIdSource: (window as any).__CURRENT_SCREEN_ID__ ? 'global' : 'url_or_default',
|
||||
componentId,
|
||||
fieldName,
|
||||
docType: localInputs.docType,
|
||||
docTypeName: localInputs.docTypeName,
|
||||
currentPath: typeof window !== 'undefined' ? window.location.pathname : 'unknown'
|
||||
});
|
||||
|
||||
const response = await uploadFiles({
|
||||
files: filesToUpload,
|
||||
tableName: tableName,
|
||||
fieldName: fieldName,
|
||||
recordId: `${screenId}:${componentId}`, // 화면ID:컴포넌트ID 형태
|
||||
// 🎯 백엔드 API가 기대하는 정확한 형식으로 설정
|
||||
autoLink: true, // 자동 연결 활성화
|
||||
linkedTable: 'screen_files', // 연결 테이블
|
||||
recordId: screenId, // 레코드 ID
|
||||
columnName: fieldName, // 컬럼명
|
||||
isVirtualFileColumn: true, // 가상 파일 컬럼
|
||||
docType: localInputs.docType,
|
||||
docTypeName: localInputs.docTypeName,
|
||||
targetObjid: targetObjid, // 그리드 연동을 위한 targetObjid
|
||||
columnName: fieldName,
|
||||
isVirtualFileColumn: true, // 가상 파일 컬럼으로 처리
|
||||
});
|
||||
|
||||
console.log("📤 파일 업로드 응답:", response);
|
||||
|
|
@ -358,6 +420,65 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
const backupKey = `fileComponent_${component.id}_files`;
|
||||
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
|
||||
|
||||
// 전역 파일 상태 변경 이벤트 발생 (RealtimePreview 업데이트용)
|
||||
if (typeof window !== 'undefined') {
|
||||
const eventDetail = {
|
||||
componentId: component.id,
|
||||
files: updatedFiles,
|
||||
fileCount: updatedFiles.length,
|
||||
action: 'upload',
|
||||
timestamp: Date.now(),
|
||||
source: 'designMode' // 🎯 화면설계 모드에서 온 이벤트임을 표시
|
||||
};
|
||||
|
||||
console.log("🚀🚀🚀 FileComponentConfigPanel 이벤트 발생:", eventDetail);
|
||||
console.log("🔍 현재 컴포넌트 ID:", component.id);
|
||||
console.log("🔍 업로드된 파일 수:", updatedFiles.length);
|
||||
console.log("🔍 파일 목록:", updatedFiles.map(f => f.name));
|
||||
|
||||
const event = new CustomEvent('globalFileStateChanged', {
|
||||
detail: eventDetail
|
||||
});
|
||||
|
||||
// 이벤트 리스너가 있는지 확인
|
||||
const listenerCount = window.getEventListeners ?
|
||||
window.getEventListeners(window)?.globalFileStateChanged?.length || 0 :
|
||||
'unknown';
|
||||
console.log("🔍 globalFileStateChanged 리스너 수:", listenerCount);
|
||||
|
||||
window.dispatchEvent(event);
|
||||
|
||||
console.log("✅✅✅ globalFileStateChanged 이벤트 발생 완료");
|
||||
|
||||
// 강제로 모든 RealtimePreview 컴포넌트에게 알림 (여러 번)
|
||||
setTimeout(() => {
|
||||
console.log("🔄 추가 이벤트 발생 (지연 100ms)");
|
||||
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||
detail: { ...eventDetail, delayed: true }
|
||||
}));
|
||||
}, 100);
|
||||
|
||||
setTimeout(() => {
|
||||
console.log("🔄 추가 이벤트 발생 (지연 300ms)");
|
||||
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||
detail: { ...eventDetail, delayed: true, attempt: 2 }
|
||||
}));
|
||||
}, 300);
|
||||
|
||||
setTimeout(() => {
|
||||
console.log("🔄 추가 이벤트 발생 (지연 500ms)");
|
||||
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||
detail: { ...eventDetail, delayed: true, attempt: 3 }
|
||||
}));
|
||||
}, 500);
|
||||
|
||||
// 직접 전역 상태 강제 업데이트
|
||||
console.log("🔄 전역 상태 강제 업데이트 시도");
|
||||
if ((window as any).forceRealtimePreviewUpdate) {
|
||||
(window as any).forceRealtimePreviewUpdate(component.id, updatedFiles);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("🔄 FileComponentConfigPanel 자동 저장:", {
|
||||
componentId: component.id,
|
||||
uploadedFiles: updatedFiles.length,
|
||||
|
|
@ -369,6 +490,11 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
|
||||
// 그리드 파일 상태 새로고침 이벤트 발생
|
||||
if (typeof window !== 'undefined') {
|
||||
const tableName = component.tableName || currentTableName || 'unknown';
|
||||
const columnName = component.columnName || component.id;
|
||||
const recordId = component.id; // 임시로 컴포넌트 ID 사용
|
||||
const targetObjid = component.id;
|
||||
|
||||
const refreshEvent = new CustomEvent('refreshFileStatus', {
|
||||
detail: {
|
||||
tableName: tableName,
|
||||
|
|
@ -399,10 +525,18 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
} else {
|
||||
throw new Error(response.message || '파일 업로드에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 파일 업로드 오류:', error);
|
||||
} catch (error: any) {
|
||||
console.error('❌ 파일 업로드 오류:', {
|
||||
error,
|
||||
errorMessage: error?.message,
|
||||
errorResponse: error?.response?.data,
|
||||
errorStatus: error?.response?.status,
|
||||
componentId: component?.id,
|
||||
screenId,
|
||||
fieldName
|
||||
});
|
||||
toast.dismiss();
|
||||
toast.error('파일 업로드에 실패했습니다.');
|
||||
toast.error(`파일 업로드에 실패했습니다: ${error?.message || '알 수 없는 오류'}`);
|
||||
} finally {
|
||||
console.log("🏁 파일 업로드 완료, 로딩 상태 해제");
|
||||
setUploading(false);
|
||||
|
|
@ -413,7 +547,7 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
const handleFileDownload = useCallback(async (file: FileInfo) => {
|
||||
try {
|
||||
await downloadFile({
|
||||
fileId: file.objid || file.id,
|
||||
fileId: file.objid || file.id || '',
|
||||
serverFilename: file.savedFileName,
|
||||
originalName: file.realFileName || file.name || 'download',
|
||||
});
|
||||
|
|
@ -426,8 +560,17 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
|
||||
// 파일 삭제 처리
|
||||
const handleFileDelete = useCallback(async (fileId: string) => {
|
||||
console.log("🗑️🗑️🗑️ FileComponentConfigPanel 파일 삭제 시작:", {
|
||||
fileId,
|
||||
componentId: component?.id,
|
||||
currentFilesCount: uploadedFiles.length,
|
||||
hasOnUpdateProperty: !!onUpdateProperty
|
||||
});
|
||||
|
||||
try {
|
||||
await deleteFile(fileId);
|
||||
console.log("📡 deleteFile API 호출 시작...");
|
||||
await deleteFile(fileId, 'temp_record');
|
||||
console.log("✅ deleteFile API 호출 성공");
|
||||
const updatedFiles = uploadedFiles.filter(file => file.objid !== fileId && file.id !== fileId);
|
||||
setUploadedFiles(updatedFiles);
|
||||
|
||||
|
|
@ -455,8 +598,42 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
timestamp: timestamp
|
||||
});
|
||||
|
||||
// 그리드 파일 상태 새로고침 이벤트 발생
|
||||
if (typeof window !== 'undefined') {
|
||||
// 🎯 RealtimePreview 동기화를 위한 전역 이벤트 발생
|
||||
if (typeof window !== 'undefined') {
|
||||
const eventDetail = {
|
||||
componentId: component.id,
|
||||
files: updatedFiles,
|
||||
fileCount: updatedFiles.length,
|
||||
action: 'delete',
|
||||
timestamp: timestamp,
|
||||
source: 'designMode' // 🎯 화면설계 모드에서 온 이벤트임을 표시
|
||||
};
|
||||
|
||||
console.log("🚀🚀🚀 FileComponentConfigPanel 삭제 이벤트 발생:", eventDetail);
|
||||
|
||||
const event = new CustomEvent('globalFileStateChanged', {
|
||||
detail: eventDetail
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
|
||||
console.log("✅✅✅ globalFileStateChanged 삭제 이벤트 발생 완료");
|
||||
|
||||
// 추가 지연 이벤트들
|
||||
setTimeout(() => {
|
||||
console.log("🔄 추가 삭제 이벤트 발생 (지연 100ms)");
|
||||
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||
detail: { ...eventDetail, delayed: true }
|
||||
}));
|
||||
}, 100);
|
||||
|
||||
setTimeout(() => {
|
||||
console.log("🔄 추가 삭제 이벤트 발생 (지연 300ms)");
|
||||
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||
detail: { ...eventDetail, delayed: true, attempt: 2 }
|
||||
}));
|
||||
}, 300);
|
||||
|
||||
// 그리드 파일 상태 새로고침 이벤트도 유지
|
||||
const tableName = currentTableName || 'screen_files';
|
||||
const recordId = component.id;
|
||||
const columnName = component.columnName || component.id || 'file_attachment';
|
||||
|
|
@ -539,12 +716,22 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const files = e.dataTransfer.files;
|
||||
console.log("📂 드래그앤드롭 이벤트:", {
|
||||
filesCount: files.length,
|
||||
files: files.length > 0 ? Array.from(files).map(f => f.name) : [],
|
||||
componentId: component?.id
|
||||
});
|
||||
if (files.length > 0) {
|
||||
handleFileUpload(files);
|
||||
}
|
||||
}, [handleFileUpload]);
|
||||
}, [handleFileUpload, component?.id]);
|
||||
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
console.log("📁 파일 선택 이벤트:", {
|
||||
filesCount: e.target.files?.length || 0,
|
||||
files: e.target.files ? Array.from(e.target.files).map(f => f.name) : []
|
||||
});
|
||||
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
handleFileUpload(files);
|
||||
|
|
@ -649,20 +836,49 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
// 전역 파일 상태 변경 감지 (화면 복원 포함)
|
||||
useEffect(() => {
|
||||
const handleGlobalFileStateChange = (event: CustomEvent) => {
|
||||
const { componentId, files, fileCount, isRestore } = event.detail;
|
||||
const { componentId, files, fileCount, isRestore, source } = event.detail;
|
||||
|
||||
if (componentId === component.id) {
|
||||
console.log("🌐 FileComponentConfigPanel 전역 상태 변경 감지:", {
|
||||
componentId,
|
||||
fileCount,
|
||||
isRestore: !!isRestore,
|
||||
source: source || 'unknown',
|
||||
files: files?.map((f: any) => ({ objid: f.objid, name: f.realFileName }))
|
||||
});
|
||||
|
||||
if (files && Array.isArray(files)) {
|
||||
setUploadedFiles(files);
|
||||
|
||||
if (isRestore) {
|
||||
// 🎯 실제 화면에서 온 이벤트이거나 화면 복원인 경우 컴포넌트 속성도 업데이트
|
||||
if (isRestore || source === 'realScreen') {
|
||||
console.log("✅✅✅ 실제 화면 → 화면설계 모드 동기화 적용:", {
|
||||
componentId,
|
||||
fileCount: files.length,
|
||||
source: source || 'restore'
|
||||
});
|
||||
|
||||
onUpdateProperty(component.id, "uploadedFiles", files);
|
||||
onUpdateProperty(component.id, "lastFileUpdate", Date.now());
|
||||
|
||||
// localStorage 백업도 업데이트
|
||||
try {
|
||||
const backupKey = `fileComponent_${component.id}_files`;
|
||||
localStorage.setItem(backupKey, JSON.stringify(files));
|
||||
console.log("💾 실제 화면 동기화 후 localStorage 백업 업데이트:", {
|
||||
componentId: component.id,
|
||||
fileCount: files.length
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("localStorage 백업 업데이트 실패:", e);
|
||||
}
|
||||
|
||||
// 전역 상태 업데이트
|
||||
setGlobalFileState(prev => ({
|
||||
...prev,
|
||||
[component.id]: files
|
||||
}));
|
||||
} else if (isRestore) {
|
||||
console.log("✅ 파일 컴포넌트 설정 패널 데이터 복원 완료:", {
|
||||
componentId,
|
||||
restoredFileCount: files.length
|
||||
|
|
@ -679,7 +895,7 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
window.removeEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
|
||||
};
|
||||
}
|
||||
}, [component.id]);
|
||||
}, [component.id, onUpdateProperty]);
|
||||
|
||||
// 미리 정의된 문서 타입들
|
||||
const docTypeOptions = [
|
||||
|
|
@ -875,18 +1091,33 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
{/* 파일 업로드 영역 */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium text-gray-900">파일 업로드</h4>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<Card className="border-gray-200/60 shadow-sm">
|
||||
<CardContent className="p-6">
|
||||
<div
|
||||
className={`
|
||||
border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors
|
||||
${dragOver ? 'border-blue-400 bg-blue-50' : 'border-gray-300'}
|
||||
${uploading ? 'opacity-50 cursor-not-allowed' : 'hover:border-gray-400'}
|
||||
border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all duration-300
|
||||
${dragOver ? 'border-blue-400 bg-gradient-to-br from-blue-50 to-indigo-50 shadow-sm' : 'border-gray-300/60'}
|
||||
${uploading ? 'opacity-50 cursor-not-allowed' : 'hover:border-blue-400/60 hover:bg-gray-50/50 hover:shadow-sm'}
|
||||
`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => !uploading && document.getElementById('file-input-config')?.click()}
|
||||
onClick={() => {
|
||||
console.log("🖱️ 파일 업로드 영역 클릭:", {
|
||||
uploading,
|
||||
inputElement: document.getElementById('file-input-config'),
|
||||
componentId: component?.id
|
||||
});
|
||||
if (!uploading) {
|
||||
const input = document.getElementById('file-input-config');
|
||||
if (input) {
|
||||
console.log("✅ 파일 input 클릭 실행");
|
||||
input.click();
|
||||
} else {
|
||||
console.log("❌ 파일 input 요소를 찾을 수 없음");
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
id="file-input-config"
|
||||
|
|
@ -959,7 +1190,7 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleFileDelete(file.objid || file.id)}
|
||||
onClick={() => handleFileDelete(file.objid || file.id || '')}
|
||||
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
|
||||
title="삭제"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ export default function LayoutsPanel({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className={`layouts-panel h-full ${className || ""}`}>
|
||||
<div className={`layouts-panel h-full bg-gradient-to-br from-slate-50 to-indigo-50/30 border-r border-gray-200/60 shadow-sm ${className || ""}`}>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="border-b p-4">
|
||||
|
|
|
|||
|
|
@ -487,16 +487,22 @@ export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({ onDragStart }) =
|
|||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-4 p-4">
|
||||
<div className="flex h-full flex-col bg-gradient-to-br from-slate-50 to-blue-50/30 p-6 border-r border-gray-200/60 shadow-sm">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-1">템플릿</h2>
|
||||
<p className="text-sm text-gray-500">캔버스로 드래그하여 화면을 구성하세요</p>
|
||||
</div>
|
||||
|
||||
{/* 검색 */}
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Input
|
||||
placeholder="템플릿 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
className="pl-10 border-0 bg-white/80 backdrop-blur-sm shadow-sm focus:bg-white transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -508,7 +514,13 @@ export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({ onDragStart }) =
|
|||
variant={selectedCategory === category.id ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedCategory(category.id)}
|
||||
className="flex items-center space-x-1"
|
||||
className={`
|
||||
flex items-center space-x-1.5 h-8 px-3 text-xs font-medium rounded-full transition-all
|
||||
${selectedCategory === category.id
|
||||
? 'bg-blue-600 text-white shadow-sm hover:bg-blue-700'
|
||||
: 'bg-white/60 text-gray-600 border-gray-200/60 hover:bg-white hover:text-gray-900 hover:border-gray-300'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{category.icon}
|
||||
<span>{category.name}</span>
|
||||
|
|
@ -517,23 +529,21 @@ export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({ onDragStart }) =
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 새로고침 버튼 */}
|
||||
{error && (
|
||||
<div className="flex items-center justify-between rounded-lg bg-yellow-50 p-3 text-yellow-800">
|
||||
<div className="flex items-center justify-between rounded-xl bg-amber-50/80 border border-amber-200/60 p-3 text-amber-800 mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Info className="h-4 w-4" />
|
||||
<span className="text-sm">템플릿 로딩 실패, 기본 템플릿 사용 중</span>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => refetch()}>
|
||||
<Button size="sm" variant="outline" onClick={() => refetch()} className="border-amber-300 text-amber-700 hover:bg-amber-100">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 템플릿 목록 */}
|
||||
<div className="flex-1 space-y-2 overflow-y-auto">
|
||||
<div className="flex-1 space-y-3 overflow-y-auto mt-6">
|
||||
{isLoading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
|
|
@ -541,9 +551,10 @@ export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({ onDragStart }) =
|
|||
</div>
|
||||
) : filteredTemplates.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center text-center text-gray-500">
|
||||
<div>
|
||||
<FileText className="mx-auto mb-2 h-8 w-8" />
|
||||
<p className="text-sm">검색 결과가 없습니다</p>
|
||||
<div className="p-8">
|
||||
<FileText className="mx-auto mb-3 h-12 w-12 text-gray-300" />
|
||||
<p className="text-sm font-medium text-gray-600">템플릿을 찾을 수 없습니다</p>
|
||||
<p className="text-xs text-gray-400 mt-1">검색어나 필터를 조정해보세요</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -551,27 +562,40 @@ export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({ onDragStart }) =
|
|||
<div
|
||||
key={template.id}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, template)}
|
||||
className="group cursor-move rounded-lg border border-gray-200 bg-white p-3 shadow-sm transition-all hover:border-blue-300 hover:shadow-md"
|
||||
onDragStart={(e) => {
|
||||
onDragStart(e, template);
|
||||
// 드래그 시작 시 시각적 피드백
|
||||
e.currentTarget.style.opacity = '0.6';
|
||||
e.currentTarget.style.transform = 'rotate(2deg) scale(0.98)';
|
||||
}}
|
||||
onDragEnd={(e) => {
|
||||
// 드래그 종료 시 원래 상태로 복원
|
||||
e.currentTarget.style.opacity = '1';
|
||||
e.currentTarget.style.transform = 'none';
|
||||
}}
|
||||
className="group cursor-grab rounded-lg border border-gray-200/40 bg-white/90 backdrop-blur-sm p-5 shadow-sm transition-all duration-300 hover:bg-white hover:shadow-lg hover:shadow-blue-500/15 hover:scale-[1.02] hover:border-blue-300/60 hover:-translate-y-1 active:cursor-grabbing active:scale-[0.98] active:translate-y-0"
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-50 text-blue-600 group-hover:bg-blue-100">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-blue-600 text-white shadow-md group-hover:shadow-lg group-hover:scale-110 transition-all duration-300">
|
||||
{template.icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h4 className="truncate font-medium text-gray-900">{template.name}</h4>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{template.components.length}개
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h4 className="font-semibold text-gray-900 text-sm leading-tight">{template.name}</h4>
|
||||
<Badge variant="secondary" className="text-xs bg-blue-100 text-blue-700 border-0 ml-2 px-2 py-1 rounded-full font-medium">
|
||||
{template.components.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-gray-500">{template.description}</p>
|
||||
<div className="mt-2 flex items-center space-x-2 text-xs text-gray-400">
|
||||
<span>
|
||||
{template.defaultSize.width}×{template.defaultSize.height}
|
||||
<p className="text-xs text-gray-500 line-clamp-2 leading-relaxed mb-3">{template.description}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2 text-xs text-gray-400">
|
||||
<span className="bg-gradient-to-r from-gray-100 to-gray-200 px-3 py-1 rounded-full font-medium text-gray-600">
|
||||
{template.defaultSize.width}×{template.defaultSize.height}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-blue-600 capitalize bg-gradient-to-r from-blue-50 to-indigo-50 px-3 py-1 rounded-full border border-blue-200/50">
|
||||
{template.category}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span className="capitalize">{template.category}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -581,12 +605,14 @@ export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({ onDragStart }) =
|
|||
</div>
|
||||
|
||||
{/* 도움말 */}
|
||||
<div className="rounded-lg bg-blue-50 p-3">
|
||||
<div className="flex items-start space-x-2">
|
||||
<Info className="mt-0.5 h-4 w-4 flex-shrink-0 text-blue-600" />
|
||||
<div className="text-xs text-blue-700">
|
||||
<p className="mb-1 font-medium">사용 방법</p>
|
||||
<p>템플릿을 캔버스로 드래그하여 빠르게 화면을 구성하세요.</p>
|
||||
<div className="rounded-xl bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-100/60 p-4 mt-6">
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-100 text-blue-600">
|
||||
<Info className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="text-xs text-blue-800">
|
||||
<p className="font-semibold mb-1">사용 방법</p>
|
||||
<p className="text-blue-600 leading-relaxed">템플릿을 캔버스로 드래그하여 빠르게 화면을 구성하세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -504,6 +504,26 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
|||
[component.id]: updatedFiles
|
||||
}));
|
||||
|
||||
// RealtimePreview 동기화를 위한 추가 이벤트 발생
|
||||
if (typeof window !== 'undefined') {
|
||||
const eventDetail = {
|
||||
componentId: component.id,
|
||||
files: updatedFiles,
|
||||
fileCount: updatedFiles.length,
|
||||
action: 'upload',
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
console.log("🚀 FileUpload 위젯 이벤트 발생:", eventDetail);
|
||||
|
||||
const event = new CustomEvent('globalFileStateChanged', {
|
||||
detail: eventDetail
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
|
||||
console.log("✅ FileUpload globalFileStateChanged 이벤트 발생 완료");
|
||||
}
|
||||
|
||||
// 컴포넌트 업데이트 (옵셔널)
|
||||
if (onUpdateComponent) {
|
||||
onUpdateComponent({
|
||||
|
|
@ -583,6 +603,42 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
|||
[component.id]: filteredFiles
|
||||
}));
|
||||
|
||||
// 🎯 화면설계 모드와 동기화를 위한 전역 이벤트 발생
|
||||
if (typeof window !== 'undefined') {
|
||||
const eventDetail = {
|
||||
componentId: component.id,
|
||||
files: filteredFiles,
|
||||
fileCount: filteredFiles.length,
|
||||
action: 'delete',
|
||||
timestamp: Date.now(),
|
||||
source: 'realScreen' // 실제 화면에서 온 이벤트임을 표시
|
||||
};
|
||||
|
||||
console.log("🚀🚀🚀 FileUpload 위젯 삭제 이벤트 발생:", eventDetail);
|
||||
|
||||
const event = new CustomEvent('globalFileStateChanged', {
|
||||
detail: eventDetail
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
|
||||
console.log("✅✅✅ FileUpload 위젯 → 화면설계 모드 동기화 이벤트 발생 완료");
|
||||
|
||||
// 추가 지연 이벤트들
|
||||
setTimeout(() => {
|
||||
console.log("🔄 FileUpload 위젯 추가 삭제 이벤트 발생 (지연 100ms)");
|
||||
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||
detail: { ...eventDetail, delayed: true }
|
||||
}));
|
||||
}, 100);
|
||||
|
||||
setTimeout(() => {
|
||||
console.log("🔄 FileUpload 위젯 추가 삭제 이벤트 발생 (지연 300ms)");
|
||||
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||
detail: { ...eventDetail, delayed: true, attempt: 2 }
|
||||
}));
|
||||
}, 300);
|
||||
}
|
||||
|
||||
onUpdateComponent({
|
||||
uploadedFiles: filteredFiles,
|
||||
});
|
||||
|
|
@ -635,8 +691,8 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
|||
<div className="w-full space-y-4">
|
||||
{/* 드래그 앤 드롭 영역 */}
|
||||
<div
|
||||
className={`rounded-lg border-2 border-dashed p-6 text-center transition-colors ${
|
||||
isDragOver ? "border-blue-500 bg-blue-50" : "border-gray-300 hover:border-gray-400"
|
||||
className={`rounded-xl border-2 border-dashed p-8 text-center transition-all duration-300 ${
|
||||
isDragOver ? "border-blue-400 bg-gradient-to-br from-blue-50 to-indigo-50 shadow-sm" : "border-gray-300/60 hover:border-blue-400/60 hover:bg-gray-50/50 hover:shadow-sm"
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
|
|
@ -648,7 +704,7 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
|||
</p>
|
||||
<p className="mb-4 text-sm text-gray-500">또는 클릭하여 파일을 선택하세요</p>
|
||||
|
||||
<Button variant="outline" onClick={handleFileInputClick} className="mb-4">
|
||||
<Button variant="outline" onClick={handleFileInputClick} className="mb-4 rounded-lg border-gray-300/60 hover:border-blue-400/60 hover:bg-blue-50/50 transition-all duration-200">
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{fileConfig.uploadButtonText || "파일 선택"}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ export const FileWidget: React.FC<WebTypeComponentProps> = ({ component, value,
|
|||
<div className="h-full w-full space-y-2">
|
||||
{/* 파일 업로드 영역 */}
|
||||
<div
|
||||
className="border-muted-foreground/25 hover:border-muted-foreground/50 cursor-pointer rounded-lg border-2 border-dashed p-4 text-center transition-colors"
|
||||
className="border-gray-300/60 hover:border-blue-400/60 hover:bg-gray-50/50 cursor-pointer rounded-xl border-2 border-dashed p-6 text-center transition-all duration-300 hover:shadow-sm"
|
||||
onClick={handleFileSelect}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
|
|
|
|||
|
|
@ -1,56 +1,92 @@
|
|||
// 배치 관리 API 클라이언트
|
||||
// 작성일: 2024-12-23
|
||||
// 배치관리 API 클라이언트 (새로운 API로 업데이트)
|
||||
// 작성일: 2024-12-24
|
||||
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export interface BatchJob {
|
||||
export interface BatchConfig {
|
||||
id?: number;
|
||||
job_name: string;
|
||||
batch_name: string;
|
||||
description?: string;
|
||||
job_type: 'collection' | 'sync' | 'cleanup' | 'custom';
|
||||
schedule_cron?: string;
|
||||
is_active: string;
|
||||
config_json?: Record<string, any>;
|
||||
last_executed_at?: Date;
|
||||
next_execution_at?: Date;
|
||||
execution_count: number;
|
||||
success_count: number;
|
||||
failure_count: number;
|
||||
cron_schedule: string;
|
||||
is_active?: string;
|
||||
company_code?: string;
|
||||
created_date?: Date;
|
||||
created_by?: string;
|
||||
updated_date?: Date;
|
||||
updated_by?: string;
|
||||
company_code: string;
|
||||
batch_mappings?: BatchMapping[];
|
||||
}
|
||||
|
||||
export interface BatchJobFilter {
|
||||
job_name?: string;
|
||||
job_type?: string;
|
||||
export interface BatchMapping {
|
||||
id?: number;
|
||||
batch_config_id?: number;
|
||||
|
||||
// FROM 정보
|
||||
from_connection_type: 'internal' | 'external';
|
||||
from_connection_id?: number;
|
||||
from_table_name: string;
|
||||
from_column_name: string;
|
||||
from_column_type?: string;
|
||||
|
||||
// TO 정보
|
||||
to_connection_type: 'internal' | 'external';
|
||||
to_connection_id?: number;
|
||||
to_table_name: string;
|
||||
to_column_name: string;
|
||||
to_column_type?: string;
|
||||
|
||||
mapping_order?: number;
|
||||
created_date?: Date;
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
export interface BatchConfigFilter {
|
||||
batch_name?: string;
|
||||
is_active?: string;
|
||||
company_code?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface BatchExecution {
|
||||
id?: number;
|
||||
job_id: number;
|
||||
execution_status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
started_at?: Date;
|
||||
completed_at?: Date;
|
||||
execution_time_ms?: number;
|
||||
result_data?: Record<string, any>;
|
||||
error_message?: string;
|
||||
log_details?: string;
|
||||
export interface BatchJob {
|
||||
id: number;
|
||||
job_name: string;
|
||||
job_type: string;
|
||||
description?: string;
|
||||
cron_schedule: string;
|
||||
is_active: string;
|
||||
last_execution?: Date;
|
||||
next_execution?: Date;
|
||||
status?: string;
|
||||
created_date?: Date;
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
export interface BatchMonitoring {
|
||||
total_jobs: number;
|
||||
active_jobs: number;
|
||||
running_jobs: number;
|
||||
failed_jobs_today: number;
|
||||
successful_jobs_today: number;
|
||||
recent_executions: BatchExecution[];
|
||||
export interface ConnectionInfo {
|
||||
type: 'internal' | 'external';
|
||||
id?: number;
|
||||
name: string;
|
||||
db_type?: string;
|
||||
}
|
||||
|
||||
export interface ColumnInfo {
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable?: boolean;
|
||||
column_default?: string;
|
||||
}
|
||||
|
||||
export interface TableInfo {
|
||||
table_name: string;
|
||||
columns: ColumnInfo[];
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface BatchMappingRequest {
|
||||
batchName: string;
|
||||
description?: string;
|
||||
cronSchedule: string;
|
||||
mappings: BatchMapping[];
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
|
|
@ -61,25 +97,251 @@ export interface ApiResponse<T> {
|
|||
}
|
||||
|
||||
export class BatchAPI {
|
||||
private static readonly BASE_PATH = "/batch";
|
||||
private static readonly BASE_PATH = "";
|
||||
|
||||
/**
|
||||
* 배치 설정 목록 조회
|
||||
*/
|
||||
static async getBatchConfigs(filter: BatchConfigFilter = {}): Promise<{
|
||||
success: boolean;
|
||||
data: BatchConfig[];
|
||||
pagination?: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
message?: string;
|
||||
}> {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filter.is_active) params.append("is_active", filter.is_active);
|
||||
if (filter.company_code) params.append("company_code", filter.company_code);
|
||||
if (filter.search) params.append("search", filter.search);
|
||||
if (filter.page) params.append("page", filter.page.toString());
|
||||
if (filter.limit) params.append("limit", filter.limit.toString());
|
||||
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: BatchConfig[];
|
||||
pagination?: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
message?: string;
|
||||
}>(`/batch-configs?${params.toString()}`);
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("배치 설정 목록 조회 오류:", error);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
message: error instanceof Error ? error.message : "배치 설정 목록 조회에 실패했습니다."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 배치 설정 조회 (별칭)
|
||||
*/
|
||||
static async getBatchConfig(id: number): Promise<BatchConfig> {
|
||||
return this.getBatchConfigById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 배치 설정 조회
|
||||
*/
|
||||
static async getBatchConfigById(id: number): Promise<BatchConfig> {
|
||||
try {
|
||||
const response = await apiClient.get<ApiResponse<BatchConfig>>(
|
||||
`/batch-management/batch-configs/${id}`,
|
||||
);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "배치 설정 조회에 실패했습니다.");
|
||||
}
|
||||
|
||||
if (!response.data.data) {
|
||||
throw new Error("배치 설정을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
console.error("배치 설정 조회 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 설정 생성
|
||||
*/
|
||||
static async createBatchConfig(data: BatchMappingRequest): Promise<BatchConfig> {
|
||||
try {
|
||||
const response = await apiClient.post<ApiResponse<BatchConfig>>(
|
||||
`/batch-configs`,
|
||||
data,
|
||||
);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "배치 설정 생성에 실패했습니다.");
|
||||
}
|
||||
|
||||
if (!response.data.data) {
|
||||
throw new Error("배치 설정 생성 결과를 받을 수 없습니다.");
|
||||
}
|
||||
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
console.error("배치 설정 생성 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 설정 수정
|
||||
*/
|
||||
static async updateBatchConfig(
|
||||
id: number,
|
||||
data: Partial<BatchMappingRequest>
|
||||
): Promise<BatchConfig> {
|
||||
try {
|
||||
const response = await apiClient.put<ApiResponse<BatchConfig>>(
|
||||
`/batch-management/batch-configs/${id}`,
|
||||
data,
|
||||
);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "배치 설정 수정에 실패했습니다.");
|
||||
}
|
||||
|
||||
if (!response.data.data) {
|
||||
throw new Error("배치 설정 수정 결과를 받을 수 없습니다.");
|
||||
}
|
||||
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
console.error("배치 설정 수정 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 설정 삭제
|
||||
*/
|
||||
static async deleteBatchConfig(id: number): Promise<void> {
|
||||
try {
|
||||
const response = await apiClient.delete<ApiResponse<void>>(
|
||||
`/batch-configs/${id}`,
|
||||
);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "배치 설정 삭제에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("배치 설정 삭제 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용 가능한 커넥션 목록 조회
|
||||
*/
|
||||
static async getConnections(): Promise<ConnectionInfo[]> {
|
||||
try {
|
||||
console.log("[BatchAPI] getAvailableConnections 호출 시작");
|
||||
console.log("[BatchAPI] API URL:", `/batch-management/connections`);
|
||||
|
||||
const response = await apiClient.get<ApiResponse<ConnectionInfo[]>>(
|
||||
`/batch-management/connections`,
|
||||
);
|
||||
|
||||
console.log("[BatchAPI] API 응답:", response);
|
||||
console.log("[BatchAPI] 응답 데이터:", response.data);
|
||||
|
||||
if (!response.data.success) {
|
||||
console.error("[BatchAPI] API 응답 실패:", response.data);
|
||||
throw new Error(response.data.message || "커넥션 목록 조회에 실패했습니다.");
|
||||
}
|
||||
|
||||
const result = response.data.data || [];
|
||||
console.log("[BatchAPI] 최종 결과:", result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("[BatchAPI] 커넥션 목록 조회 오류:", error);
|
||||
console.error("[BatchAPI] 오류 상세:", {
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
stack: error instanceof Error ? error.stack : undefined
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 커넥션의 테이블 목록 조회
|
||||
*/
|
||||
static async getTablesFromConnection(
|
||||
connection: ConnectionInfo
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
let url = `/batch-management/connections/${connection.type}`;
|
||||
if (connection.type === 'external' && connection.id) {
|
||||
url += `/${connection.id}`;
|
||||
}
|
||||
url += '/tables';
|
||||
|
||||
const response = await apiClient.get<ApiResponse<TableInfo[]>>(url);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "테이블 목록 조회에 실패했습니다.");
|
||||
}
|
||||
|
||||
// TableInfo[]에서 table_name만 추출하여 string[]로 변환
|
||||
const tables = response.data.data || [];
|
||||
return tables.map(table => table.table_name);
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 조회 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 테이블의 컬럼 정보 조회
|
||||
*/
|
||||
static async getTableColumns(
|
||||
connection: ConnectionInfo,
|
||||
tableName: string
|
||||
): Promise<ColumnInfo[]> {
|
||||
try {
|
||||
let url = `/batch-management/connections/${connection.type}`;
|
||||
if (connection.type === 'external' && connection.id) {
|
||||
url += `/${connection.id}`;
|
||||
}
|
||||
url += `/tables/${tableName}/columns`;
|
||||
|
||||
const response = await apiClient.get<ApiResponse<ColumnInfo[]>>(url);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "컬럼 정보 조회에 실패했습니다.");
|
||||
}
|
||||
|
||||
return response.data.data || [];
|
||||
} catch (error) {
|
||||
console.error("컬럼 정보 조회 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 작업 목록 조회
|
||||
*/
|
||||
static async getBatchJobs(filter: BatchJobFilter = {}): Promise<BatchJob[]> {
|
||||
static async getBatchJobs(): Promise<BatchJob[]> {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filter.job_name) params.append("job_name", filter.job_name);
|
||||
if (filter.job_type) params.append("job_type", filter.job_type);
|
||||
if (filter.is_active) params.append("is_active", filter.is_active);
|
||||
if (filter.company_code) params.append("company_code", filter.company_code);
|
||||
if (filter.search) params.append("search", filter.search);
|
||||
|
||||
const response = await apiClient.get<ApiResponse<BatchJob[]>>(
|
||||
`${this.BASE_PATH}?${params.toString()}`
|
||||
);
|
||||
|
||||
const response = await apiClient.get<ApiResponse<BatchJob[]>>('/batch-management/jobs');
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "배치 작업 목록 조회에 실패했습니다.");
|
||||
}
|
||||
|
|
@ -92,206 +354,39 @@ export class BatchAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* 배치 작업 상세 조회
|
||||
* 배치 수동 실행
|
||||
*/
|
||||
static async getBatchJobById(id: number): Promise<BatchJob> {
|
||||
static async executeBatchConfig(batchId: number): Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data?: {
|
||||
batchId: string;
|
||||
totalRecords: number;
|
||||
successRecords: number;
|
||||
failedRecords: number;
|
||||
duration: number;
|
||||
};
|
||||
}> {
|
||||
try {
|
||||
const response = await apiClient.get<ApiResponse<BatchJob>>(`${this.BASE_PATH}/${id}`);
|
||||
const response = await apiClient.post<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data?: {
|
||||
batchId: string;
|
||||
totalRecords: number;
|
||||
successRecords: number;
|
||||
failedRecords: number;
|
||||
duration: number;
|
||||
};
|
||||
}>(`/batch-management/batch-configs/${batchId}/execute`);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "배치 작업 조회에 실패했습니다.");
|
||||
}
|
||||
|
||||
if (!response.data.data) {
|
||||
throw new Error("배치 작업을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
return response.data.data;
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("배치 작업 조회 오류:", error);
|
||||
console.error("배치 실행 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 작업 생성
|
||||
*/
|
||||
static async createBatchJob(data: BatchJob): Promise<BatchJob> {
|
||||
try {
|
||||
const response = await apiClient.post<ApiResponse<BatchJob>>(this.BASE_PATH, data);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "배치 작업 생성에 실패했습니다.");
|
||||
}
|
||||
|
||||
if (!response.data.data) {
|
||||
throw new Error("생성된 배치 작업 정보를 받을 수 없습니다.");
|
||||
}
|
||||
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
console.error("배치 작업 생성 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 작업 수정
|
||||
*/
|
||||
static async updateBatchJob(id: number, data: Partial<BatchJob>): Promise<BatchJob> {
|
||||
try {
|
||||
const response = await apiClient.put<ApiResponse<BatchJob>>(`${this.BASE_PATH}/${id}`, data);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "배치 작업 수정에 실패했습니다.");
|
||||
}
|
||||
|
||||
if (!response.data.data) {
|
||||
throw new Error("수정된 배치 작업 정보를 받을 수 없습니다.");
|
||||
}
|
||||
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
console.error("배치 작업 수정 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 작업 삭제
|
||||
*/
|
||||
static async deleteBatchJob(id: number): Promise<void> {
|
||||
try {
|
||||
const response = await apiClient.delete<ApiResponse<null>>(`${this.BASE_PATH}/${id}`);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "배치 작업 삭제에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("배치 작업 삭제 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 작업 수동 실행
|
||||
*/
|
||||
static async executeBatchJob(id: number): Promise<BatchExecution> {
|
||||
try {
|
||||
const response = await apiClient.post<ApiResponse<BatchExecution>>(`${this.BASE_PATH}/${id}/execute`);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "배치 작업 실행에 실패했습니다.");
|
||||
}
|
||||
|
||||
if (!response.data.data) {
|
||||
throw new Error("배치 실행 정보를 받을 수 없습니다.");
|
||||
}
|
||||
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
console.error("배치 작업 실행 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 실행 목록 조회
|
||||
*/
|
||||
static async getBatchExecutions(jobId?: number): Promise<BatchExecution[]> {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (jobId) params.append("job_id", jobId.toString());
|
||||
|
||||
const response = await apiClient.get<ApiResponse<BatchExecution[]>>(
|
||||
`${this.BASE_PATH}/executions/list?${params.toString()}`
|
||||
);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "배치 실행 목록 조회에 실패했습니다.");
|
||||
}
|
||||
|
||||
return response.data.data || [];
|
||||
} catch (error) {
|
||||
console.error("배치 실행 목록 조회 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 모니터링 정보 조회
|
||||
*/
|
||||
static async getBatchMonitoring(): Promise<BatchMonitoring> {
|
||||
try {
|
||||
const response = await apiClient.get<ApiResponse<BatchMonitoring>>(
|
||||
`${this.BASE_PATH}/monitoring/status`
|
||||
);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "배치 모니터링 조회에 실패했습니다.");
|
||||
}
|
||||
|
||||
if (!response.data.data) {
|
||||
throw new Error("배치 모니터링 정보를 받을 수 없습니다.");
|
||||
}
|
||||
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
console.error("배치 모니터링 조회 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 지원되는 작업 타입 조회
|
||||
*/
|
||||
static async getSupportedJobTypes(): Promise<Array<{ value: string; label: string }>> {
|
||||
try {
|
||||
const response = await apiClient.get<ApiResponse<{ types: Array<{ value: string; label: string }> }>>(
|
||||
`${this.BASE_PATH}/types/supported`
|
||||
);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "작업 타입 조회에 실패했습니다.");
|
||||
}
|
||||
|
||||
return response.data.data?.types || [];
|
||||
} catch (error) {
|
||||
console.error("작업 타입 조회 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 스케줄 프리셋 조회
|
||||
*/
|
||||
static async getSchedulePresets(): Promise<Array<{ value: string; label: string }>> {
|
||||
try {
|
||||
const response = await apiClient.get<ApiResponse<{ presets: Array<{ value: string; label: string }> }>>(
|
||||
`${this.BASE_PATH}/schedules/presets`
|
||||
);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "스케줄 프리셋 조회에 실패했습니다.");
|
||||
}
|
||||
|
||||
return response.data.data?.presets || [];
|
||||
} catch (error) {
|
||||
console.error("스케줄 프리셋 조회 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 실행 상태 옵션 조회
|
||||
*/
|
||||
static getExecutionStatusOptions() {
|
||||
return [
|
||||
{ value: 'pending', label: '대기 중' },
|
||||
{ value: 'running', label: '실행 중' },
|
||||
{ value: 'completed', label: '완료' },
|
||||
{ value: 'failed', label: '실패' },
|
||||
{ value: 'cancelled', label: '취소됨' },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// BatchJob export 추가 (이미 위에서 interface로 정의됨)
|
||||
export { BatchJob };
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue