제어관리 외부커넥션 설정기능
This commit is contained in:
@@ -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부터 순차적으로 구현**을 시작하겠습니다.
|
||||||
|
|
||||||
|
**첫 번째 작업**: 좌우 분할 레이아웃과 연결 타입 선택 컴포넌트 구현
|
||||||
|
|
||||||
|
구현을 시작하시겠어요? 🚀
|
||||||
@@ -20,7 +20,7 @@ import commonCodeRoutes from "./routes/commonCodeRoutes";
|
|||||||
import dynamicFormRoutes from "./routes/dynamicFormRoutes";
|
import dynamicFormRoutes from "./routes/dynamicFormRoutes";
|
||||||
import fileRoutes from "./routes/fileRoutes";
|
import fileRoutes from "./routes/fileRoutes";
|
||||||
import companyManagementRoutes from "./routes/companyManagementRoutes";
|
import companyManagementRoutes from "./routes/companyManagementRoutes";
|
||||||
// import dataflowRoutes from "./routes/dataflowRoutes"; // 임시 주석
|
import dataflowRoutes from "./routes/dataflowRoutes";
|
||||||
import dataflowDiagramRoutes from "./routes/dataflowDiagramRoutes";
|
import dataflowDiagramRoutes from "./routes/dataflowDiagramRoutes";
|
||||||
import webTypeStandardRoutes from "./routes/webTypeStandardRoutes";
|
import webTypeStandardRoutes from "./routes/webTypeStandardRoutes";
|
||||||
import buttonActionStandardRoutes from "./routes/buttonActionStandardRoutes";
|
import buttonActionStandardRoutes from "./routes/buttonActionStandardRoutes";
|
||||||
@@ -88,13 +88,17 @@ app.use(
|
|||||||
// Rate Limiting (개발 환경에서는 완화)
|
// Rate Limiting (개발 환경에서는 완화)
|
||||||
const limiter = rateLimit({
|
const limiter = rateLimit({
|
||||||
windowMs: 1 * 60 * 1000, // 1분
|
windowMs: 1 * 60 * 1000, // 1분
|
||||||
max: config.nodeEnv === "development" ? 1000 : 100, // 개발환경에서는 1000, 운영환경에서는 100
|
max: config.nodeEnv === "development" ? 5000 : 100, // 개발환경에서는 5000으로 증가, 운영환경에서는 100
|
||||||
message: {
|
message: {
|
||||||
error: "너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요.",
|
error: "너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요.",
|
||||||
},
|
},
|
||||||
skip: (req) => {
|
skip: (req) => {
|
||||||
// 헬스 체크는 Rate Limiting 제외
|
// 헬스 체크와 테이블/컬럼 조회는 Rate Limiting 완화
|
||||||
return req.path === "/health";
|
return (
|
||||||
|
req.path === "/health" ||
|
||||||
|
req.path.includes("/table-management/") ||
|
||||||
|
req.path.includes("/external-db-connections/")
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
app.use("/api/", limiter);
|
app.use("/api/", limiter);
|
||||||
@@ -120,7 +124,7 @@ app.use("/api/common-codes", commonCodeRoutes);
|
|||||||
app.use("/api/dynamic-form", dynamicFormRoutes);
|
app.use("/api/dynamic-form", dynamicFormRoutes);
|
||||||
app.use("/api/files", fileRoutes);
|
app.use("/api/files", fileRoutes);
|
||||||
app.use("/api/company-management", companyManagementRoutes);
|
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/dataflow-diagrams", dataflowDiagramRoutes);
|
||||||
app.use("/api/admin/web-types", webTypeStandardRoutes);
|
app.use("/api/admin/web-types", webTypeStandardRoutes);
|
||||||
app.use("/api/admin/button-actions", buttonActionStandardRoutes);
|
app.use("/api/admin/button-actions", buttonActionStandardRoutes);
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
import config from "./environment";
|
import config from "./environment";
|
||||||
|
|
||||||
// Prisma 클라이언트 인스턴스 생성
|
// Prisma 클라이언트 생성 함수
|
||||||
const prisma = new PrismaClient({
|
function createPrismaClient() {
|
||||||
datasources: {
|
return new PrismaClient({
|
||||||
db: {
|
datasources: {
|
||||||
url: config.databaseUrl,
|
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() {
|
async function testConnection() {
|
||||||
@@ -41,4 +46,5 @@ if (config.nodeEnv === "development") {
|
|||||||
testConnection();
|
testConnection();
|
||||||
}
|
}
|
||||||
|
|
||||||
export default prisma;
|
// 기본 내보내기
|
||||||
|
export = prisma;
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ const getCorsOrigin = (): string[] | boolean => {
|
|||||||
|
|
||||||
const config: Config = {
|
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",
|
host: process.env.HOST || "0.0.0.0",
|
||||||
nodeEnv: process.env.NODE_ENV || "development",
|
nodeEnv: process.env.NODE_ENV || "development",
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector';
|
import {
|
||||||
import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes';
|
DatabaseConnector,
|
||||||
import * as mysql from 'mysql2/promise';
|
ConnectionConfig,
|
||||||
|
QueryResult,
|
||||||
|
} from "../interfaces/DatabaseConnector";
|
||||||
|
import { ConnectionTestResult, TableInfo } from "../types/externalDbTypes";
|
||||||
|
import * as mysql from "mysql2/promise";
|
||||||
|
|
||||||
export class MariaDBConnector implements DatabaseConnector {
|
export class MariaDBConnector implements DatabaseConnector {
|
||||||
private connection: mysql.Connection | null = null;
|
private connection: mysql.Connection | null = null;
|
||||||
@@ -18,8 +22,18 @@ export class MariaDBConnector implements DatabaseConnector {
|
|||||||
user: this.config.user,
|
user: this.config.user,
|
||||||
password: this.config.password,
|
password: this.config.password,
|
||||||
database: this.config.database,
|
database: this.config.database,
|
||||||
connectTimeout: this.config.connectionTimeoutMillis,
|
// 🔧 MySQL2에서 지원하는 타임아웃 설정
|
||||||
ssl: typeof this.config.ssl === 'boolean' ? undefined : this.config.ssl,
|
connectTimeout: this.config.connectionTimeoutMillis || 30000, // 연결 타임아웃 30초
|
||||||
|
ssl: typeof this.config.ssl === "boolean" ? undefined : this.config.ssl,
|
||||||
|
// 🔧 MySQL2에서 지원하는 추가 설정
|
||||||
|
charset: "utf8mb4",
|
||||||
|
timezone: "Z",
|
||||||
|
supportBigNumbers: true,
|
||||||
|
bigNumberStrings: true,
|
||||||
|
// 🔧 연결 풀 설정 (단일 연결이지만 안정성을 위해)
|
||||||
|
dateStrings: true,
|
||||||
|
debug: false,
|
||||||
|
trace: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,7 +49,9 @@ export class MariaDBConnector implements DatabaseConnector {
|
|||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
try {
|
try {
|
||||||
await this.connect();
|
await this.connect();
|
||||||
const [rows] = await this.connection!.query("SELECT VERSION() as version");
|
const [rows] = await this.connection!.query(
|
||||||
|
"SELECT VERSION() as version"
|
||||||
|
);
|
||||||
const version = (rows as any[])[0]?.version || "Unknown";
|
const version = (rows as any[])[0]?.version || "Unknown";
|
||||||
const responseTime = Date.now() - startTime;
|
const responseTime = Date.now() - startTime;
|
||||||
await this.disconnect();
|
await this.disconnect();
|
||||||
@@ -63,7 +79,18 @@ export class MariaDBConnector implements DatabaseConnector {
|
|||||||
async executeQuery(query: string): Promise<QueryResult> {
|
async executeQuery(query: string): Promise<QueryResult> {
|
||||||
try {
|
try {
|
||||||
await this.connect();
|
await this.connect();
|
||||||
const [rows, fields] = await this.connection!.query(query);
|
|
||||||
|
// 🔧 쿼리 타임아웃 수동 구현 (60초)
|
||||||
|
const queryTimeout = this.config.queryTimeoutMillis || 60000;
|
||||||
|
const queryPromise = this.connection!.query(query);
|
||||||
|
const timeoutPromise = new Promise((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error("쿼리 실행 타임아웃")), queryTimeout);
|
||||||
|
});
|
||||||
|
|
||||||
|
const [rows, fields] = (await Promise.race([
|
||||||
|
queryPromise,
|
||||||
|
timeoutPromise,
|
||||||
|
])) as any;
|
||||||
await this.disconnect();
|
await this.disconnect();
|
||||||
return {
|
return {
|
||||||
rows: rows as any[],
|
rows: rows as any[],
|
||||||
@@ -106,17 +133,51 @@ export class MariaDBConnector implements DatabaseConnector {
|
|||||||
|
|
||||||
async getColumns(tableName: string): Promise<any[]> {
|
async getColumns(tableName: string): Promise<any[]> {
|
||||||
try {
|
try {
|
||||||
|
console.log(`🔍 MariaDB 컬럼 조회 시작: ${tableName}`);
|
||||||
await this.connect();
|
await this.connect();
|
||||||
const [rows] = await this.connection!.query(`
|
|
||||||
|
// 🔧 컬럼 조회 타임아웃 수동 구현 (30초)
|
||||||
|
const queryTimeout = this.config.queryTimeoutMillis || 30000;
|
||||||
|
// 스키마명을 명시적으로 확인
|
||||||
|
const schemaQuery = `SELECT DATABASE() as schema_name`;
|
||||||
|
const [schemaResult] = await this.connection!.query(schemaQuery);
|
||||||
|
const schemaName =
|
||||||
|
(schemaResult as any[])[0]?.schema_name || this.config.database;
|
||||||
|
|
||||||
|
console.log(`📋 사용할 스키마: ${schemaName}`);
|
||||||
|
|
||||||
|
const query = `
|
||||||
SELECT
|
SELECT
|
||||||
COLUMN_NAME as column_name,
|
COLUMN_NAME as column_name,
|
||||||
DATA_TYPE as data_type,
|
DATA_TYPE as data_type,
|
||||||
IS_NULLABLE as is_nullable,
|
IS_NULLABLE as is_nullable,
|
||||||
COLUMN_DEFAULT as column_default
|
COLUMN_DEFAULT as column_default,
|
||||||
|
COLUMN_COMMENT as column_comment
|
||||||
FROM information_schema.COLUMNS
|
FROM information_schema.COLUMNS
|
||||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?
|
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
|
||||||
ORDER BY ORDINAL_POSITION;
|
ORDER BY ORDINAL_POSITION;
|
||||||
`, [tableName]);
|
`;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`📋 실행할 쿼리: ${query.trim()}, 파라미터: [${schemaName}, ${tableName}]`
|
||||||
|
);
|
||||||
|
|
||||||
|
const queryPromise = this.connection!.query(query, [
|
||||||
|
schemaName,
|
||||||
|
tableName,
|
||||||
|
]);
|
||||||
|
const timeoutPromise = new Promise((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error("컬럼 조회 타임아웃")), queryTimeout);
|
||||||
|
});
|
||||||
|
|
||||||
|
const [rows] = (await Promise.race([
|
||||||
|
queryPromise,
|
||||||
|
timeoutPromise,
|
||||||
|
])) as any;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`✅ MariaDB 컬럼 조회 완료: ${tableName}, ${rows ? rows.length : 0}개 컬럼`
|
||||||
|
);
|
||||||
await this.disconnect();
|
await this.disconnect();
|
||||||
return rows as any[];
|
return rows as any[];
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -124,4 +185,4 @@ export class MariaDBConnector implements DatabaseConnector {
|
|||||||
throw new Error(`컬럼 정보 조회 실패: ${error.message}`);
|
throw new Error(`컬럼 정보 조회 실패: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -447,17 +447,28 @@ router.get(
|
|||||||
return res.status(400).json(externalConnections);
|
return res.status(400).json(externalConnections);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 외부 커넥션들에 대해 연결 테스트 수행 (병렬 처리)
|
// 외부 커넥션들에 대해 연결 테스트 수행 (병렬 처리, 타임아웃 5초)
|
||||||
const testedConnections = await Promise.all(
|
const testedConnections = await Promise.all(
|
||||||
(externalConnections.data || []).map(async (connection) => {
|
(externalConnections.data || []).map(async (connection) => {
|
||||||
try {
|
try {
|
||||||
const testResult =
|
// 개별 연결 테스트에 5초 타임아웃 적용
|
||||||
await ExternalDbConnectionService.testConnectionById(
|
const testPromise = ExternalDbConnectionService.testConnectionById(
|
||||||
connection.id!
|
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;
|
return testResult.success ? connection : null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`커넥션 테스트 실패 (ID: ${connection.id}):`, error);
|
console.warn(
|
||||||
|
`커넥션 테스트 실패 (ID: ${connection.id}):`,
|
||||||
|
error instanceof Error ? error.message : error
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
* GET /api/multi-connection/connections/:connectionId/tables/:tableName/columns
|
||||||
* 특정 커넥션의 테이블 컬럼 정보 조회 (메인 DB 포함)
|
* 특정 커넥션의 테이블 컬럼 정보 조회 (메인 DB 포함)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
// 🔧 Prisma 클라이언트 중복 생성 방지 - 기존 인스턴스 재사용
|
||||||
|
import prisma = require("../config/database");
|
||||||
|
|
||||||
export class AdminService {
|
export class AdminService {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { PrismaClient } from "@prisma/client";
|
// 🔧 Prisma 클라이언트 중복 생성 방지 - 기존 인스턴스 재사용
|
||||||
|
import prisma = require("../config/database");
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
export interface ControlCondition {
|
export interface ControlCondition {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -33,6 +32,16 @@ export interface ControlAction {
|
|||||||
sourceField?: string;
|
sourceField?: string;
|
||||||
targetField?: string;
|
targetField?: string;
|
||||||
};
|
};
|
||||||
|
// 🆕 다중 커넥션 지원 추가
|
||||||
|
fromConnection?: {
|
||||||
|
id: number;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
toConnection?: {
|
||||||
|
id: number;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
targetTable?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ControlPlan {
|
export interface ControlPlan {
|
||||||
@@ -84,13 +93,59 @@ export class DataflowControlService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 제어 규칙과 실행 계획 추출
|
// 제어 규칙과 실행 계획 추출 (기존 구조 + redesigned UI 구조 지원)
|
||||||
const controlRules = Array.isArray(diagram.control)
|
let controlRules: ControlRule[] = [];
|
||||||
? (diagram.control as unknown as ControlRule[])
|
let executionPlans: ControlPlan[] = [];
|
||||||
: [];
|
|
||||||
const executionPlans = Array.isArray(diagram.plan)
|
// 🆕 redesigned UI 구조 처리
|
||||||
? (diagram.plan as unknown as ControlPlan[])
|
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(`📋 제어 규칙:`, controlRules);
|
||||||
console.log(`📋 실행 계획:`, executionPlans);
|
console.log(`📋 실행 계획:`, executionPlans);
|
||||||
@@ -174,37 +229,29 @@ export class DataflowControlService {
|
|||||||
logicalOperator: action.logicalOperator,
|
logicalOperator: action.logicalOperator,
|
||||||
conditions: action.conditions,
|
conditions: action.conditions,
|
||||||
fieldMappings: action.fieldMappings,
|
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 actionResult = await this.executeMultiConnectionAction(
|
||||||
const actionConditionResult = await this.evaluateActionConditions(
|
action,
|
||||||
action,
|
sourceData,
|
||||||
sourceData,
|
targetPlan.sourceTable
|
||||||
tableName
|
);
|
||||||
);
|
|
||||||
|
|
||||||
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({
|
executedActions.push({
|
||||||
actionId: action.id,
|
actionId: action.id,
|
||||||
actionName: action.name,
|
actionName: action.name,
|
||||||
|
actionType: action.actionType,
|
||||||
result: actionResult,
|
result: actionResult,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
previousActionSuccess = true;
|
previousActionSuccess = actionResult?.success !== false;
|
||||||
shouldSkipRemainingActions = false; // 성공했으므로 다시 실행 가능
|
|
||||||
|
// 액션 조건 검증은 이미 위에서 처리됨 (중복 제거)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`❌ 액션 실행 오류: ${action.name}`, error);
|
console.error(`❌ 액션 실행 오류: ${action.name}`, error);
|
||||||
const errorMessage =
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 액션별 조건 평가 (동적 테이블 지원)
|
* 액션별 조건 평가 (동적 테이블 지원)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import {
|
|||||||
import { PasswordEncryption } from "../utils/passwordEncryption";
|
import { PasswordEncryption } from "../utils/passwordEncryption";
|
||||||
import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory";
|
import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
// 🔧 Prisma 클라이언트 중복 생성 방지 - 기존 인스턴스 재사용
|
||||||
|
import prisma = require("../config/database");
|
||||||
|
|
||||||
export class ExternalDbConnectionService {
|
export class ExternalDbConnectionService {
|
||||||
/**
|
/**
|
||||||
@@ -166,7 +167,7 @@ export class ExternalDbConnectionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 특정 외부 DB 연결 조회
|
* 특정 외부 DB 연결 조회 (비밀번호 마스킹)
|
||||||
*/
|
*/
|
||||||
static async getConnectionById(
|
static async getConnectionById(
|
||||||
id: number
|
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 연결 생성
|
* 새 외부 DB 연결 생성
|
||||||
*/
|
*/
|
||||||
@@ -547,10 +587,18 @@ export class ExternalDbConnectionService {
|
|||||||
`🔍 [연결테스트] 새 커넥터로 DB 연결 시도 - Host: ${config.host}, DB: ${config.database}, User: ${config.user}`
|
`🔍 [연결테스트] 새 커넥터로 DB 연결 시도 - Host: ${config.host}, DB: ${config.database}, User: ${config.user}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const testResult = await connector.testConnection();
|
let testResult;
|
||||||
console.log(
|
try {
|
||||||
`🔍 [연결테스트] 결과 - Success: ${testResult.success}, Message: ${testResult.message}`
|
testResult = await connector.testConnection();
|
||||||
);
|
console.log(
|
||||||
|
`🔍 [연결테스트] 결과 - Success: ${testResult.success}, Message: ${testResult.message}`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
// 🔧 연결 해제 추가 - 메모리 누수 방지
|
||||||
|
if (connector && typeof connector.disconnect === "function") {
|
||||||
|
await connector.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: testResult.success,
|
success: testResult.success,
|
||||||
@@ -700,7 +748,14 @@ export class ExternalDbConnectionService {
|
|||||||
config,
|
config,
|
||||||
id
|
id
|
||||||
);
|
);
|
||||||
const result = await connector.executeQuery(query);
|
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await connector.executeQuery(query);
|
||||||
|
} finally {
|
||||||
|
// 🔧 연결 해제 추가 - 메모리 누수 방지
|
||||||
|
await DatabaseConnectorFactory.closeConnector(id, connection.db_type);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -823,7 +878,14 @@ export class ExternalDbConnectionService {
|
|||||||
config,
|
config,
|
||||||
id
|
id
|
||||||
);
|
);
|
||||||
const tables = await connector.getTables();
|
|
||||||
|
let tables;
|
||||||
|
try {
|
||||||
|
tables = await connector.getTables();
|
||||||
|
} finally {
|
||||||
|
// 🔧 연결 해제 추가 - 메모리 누수 방지
|
||||||
|
await DatabaseConnectorFactory.closeConnector(id, connection.db_type);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -914,26 +976,70 @@ export class ExternalDbConnectionService {
|
|||||||
let client: any = null;
|
let client: any = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const connection = await this.getConnectionById(connectionId);
|
console.log(
|
||||||
|
`🔍 컬럼 조회 시작: connectionId=${connectionId}, tableName=${tableName}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const connection = await this.getConnectionByIdWithPassword(connectionId);
|
||||||
if (!connection.success || !connection.data) {
|
if (!connection.success || !connection.data) {
|
||||||
|
console.log(`❌ 연결 정보 조회 실패: connectionId=${connectionId}`);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "연결 정보를 찾을 수 없습니다.",
|
message: "연결 정보를 찾을 수 없습니다.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`✅ 연결 정보 조회 성공: ${connection.data.connection_name} (${connection.data.db_type})`
|
||||||
|
);
|
||||||
|
|
||||||
const connectionData = connection.data;
|
const connectionData = connection.data;
|
||||||
|
|
||||||
// 비밀번호 복호화 (실패 시 일반적인 패스워드들 시도)
|
// 비밀번호 복호화 (실패 시 일반적인 패스워드들 시도)
|
||||||
let decryptedPassword: string;
|
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 {
|
try {
|
||||||
decryptedPassword = PasswordEncryption.decrypt(connectionData.password);
|
decryptedPassword = PasswordEncryption.decrypt(connectionData.password);
|
||||||
console.log(`✅ 비밀번호 복호화 성공 (connectionId: ${connectionId})`);
|
console.log(`✅ 비밀번호 복호화 성공 (connectionId: ${connectionId})`);
|
||||||
} catch (decryptError) {
|
} catch (decryptError) {
|
||||||
// ConnectionId=2의 경우 알려진 패스워드 사용 (로그 최소화)
|
// ConnectionId별 알려진 패스워드 사용
|
||||||
if (connectionId === 2) {
|
if (connectionId === 2) {
|
||||||
decryptedPassword = "postgres"; // PostgreSQL 기본 패스워드
|
decryptedPassword = "postgres"; // PostgreSQL 기본 패스워드
|
||||||
console.log(`💡 ConnectionId=2: 기본 패스워드 사용`);
|
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 {
|
} else {
|
||||||
// 다른 연결들은 원본 패스워드 사용
|
// 다른 연결들은 원본 패스워드 사용
|
||||||
console.warn(
|
console.warn(
|
||||||
@@ -971,8 +1077,21 @@ export class ExternalDbConnectionService {
|
|||||||
connectionId
|
connectionId
|
||||||
);
|
);
|
||||||
|
|
||||||
// 컬럼 정보 조회
|
let columns;
|
||||||
const columns = await connector.getColumns(tableName);
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -6,12 +6,12 @@
|
|||||||
|
|
||||||
import { ExternalDbConnectionService } from "./externalDbConnectionService";
|
import { ExternalDbConnectionService } from "./externalDbConnectionService";
|
||||||
import { TableManagementService } from "./tableManagementService";
|
import { TableManagementService } from "./tableManagementService";
|
||||||
import { ExternalDbConnection } from "../types/externalDbTypes";
|
import { ExternalDbConnection, ApiResponse } from "../types/externalDbTypes";
|
||||||
import { ColumnTypeInfo, TableInfo } from "../types/tableManagement";
|
import { ColumnTypeInfo, TableInfo } from "../types/tableManagement";
|
||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
// 🔧 Prisma 클라이언트 중복 생성 방지 - 기존 인스턴스 재사용
|
||||||
|
import prisma = require("../config/database");
|
||||||
|
|
||||||
export interface ValidationResult {
|
export interface ValidationResult {
|
||||||
isValid: boolean;
|
isValid: boolean;
|
||||||
@@ -426,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 커넥션별 컬럼 정보 조회
|
* 커넥션별 컬럼 정보 조회
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import { WebType } from "../types/unified-web-types";
|
|||||||
import { entityJoinService } from "./entityJoinService";
|
import { entityJoinService } from "./entityJoinService";
|
||||||
import { referenceCacheService } from "./referenceCacheService";
|
import { referenceCacheService } from "./referenceCacheService";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
// 🔧 Prisma 클라이언트 중복 생성 방지 - 기존 인스턴스 재사용
|
||||||
|
import prisma = require("../config/database");
|
||||||
|
|
||||||
export class TableManagementService {
|
export class TableManagementService {
|
||||||
constructor() {}
|
constructor() {}
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import { useState } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { DataFlowDesigner } from "@/components/dataflow/DataFlowDesigner";
|
import { DataFlowDesigner } from "@/components/dataflow/DataFlowDesigner";
|
||||||
import DataFlowList from "@/components/dataflow/DataFlowList";
|
import DataFlowList from "@/components/dataflow/DataFlowList";
|
||||||
|
// 🎨 새로운 UI 컴포넌트 import
|
||||||
|
import DataConnectionDesigner from "@/components/dataflow/connection/redesigned/DataConnectionDesigner";
|
||||||
import { TableRelationship, DataFlowDiagram } from "@/lib/api/dataflow";
|
import { TableRelationship, DataFlowDiagram } from "@/lib/api/dataflow";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { loadDataflowRelationship } from "@/lib/api/dataflowSave";
|
||||||
import { Button } from "@/components/ui/button";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
type Step = "list" | "design";
|
type Step = "list" | "design";
|
||||||
|
|
||||||
@@ -16,6 +18,8 @@ export default function DataFlowPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [currentStep, setCurrentStep] = useState<Step>("list");
|
const [currentStep, setCurrentStep] = useState<Step>("list");
|
||||||
const [stepHistory, setStepHistory] = useState<Step[]>(["list"]);
|
const [stepHistory, setStepHistory] = useState<Step[]>(["list"]);
|
||||||
|
const [editingDiagram, setEditingDiagram] = useState<DataFlowDiagram | null>(null);
|
||||||
|
const [loadedRelationshipData, setLoadedRelationshipData] = useState<any>(null);
|
||||||
|
|
||||||
// 단계별 제목과 설명
|
// 단계별 제목과 설명
|
||||||
const stepConfig = {
|
const stepConfig = {
|
||||||
@@ -62,61 +66,70 @@ export default function DataFlowPage() {
|
|||||||
// 저장 후 목록으로 돌아가기 - 다음 렌더링 사이클로 지연
|
// 저장 후 목록으로 돌아가기 - 다음 렌더링 사이클로 지연
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
goToStep("list");
|
goToStep("list");
|
||||||
|
setEditingDiagram(null);
|
||||||
|
setLoadedRelationshipData(null);
|
||||||
}, 0);
|
}, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDesignDiagram = (diagram: DataFlowDiagram | null) => {
|
// 관계도 수정 핸들러
|
||||||
|
const handleDesignDiagram = async (diagram: DataFlowDiagram | null) => {
|
||||||
if (diagram) {
|
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 {
|
} else {
|
||||||
// 새 관계도 생성 - 현재 페이지에서 처리
|
// 새 관계도 생성 - 현재 페이지에서 처리
|
||||||
|
setEditingDiagram(null);
|
||||||
|
setLoadedRelationshipData(null);
|
||||||
goToNextStep("design");
|
goToNextStep("design");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="container mx-auto p-6 space-y-6">
|
<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>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">데이터 흐름 관리</h1>
|
<h1 className="text-3xl font-bold text-gray-900">데이터 흐름 관리</h1>
|
||||||
<p className="mt-2 text-gray-600">테이블 간 데이터 관계를 시각적으로 설계하고 관리합니다</p>
|
<p className="mt-2 text-gray-600">테이블 간 데이터 관계를 시각적으로 설계하고 관리합니다</p>
|
||||||
</div>
|
</div>
|
||||||
{currentStep !== "list" && (
|
|
||||||
<Button variant="outline" onClick={goToPreviousStep} className="flex items-center shadow-sm">
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
이전
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 단계별 내용 */}
|
{/* 단계별 내용 */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 관계도 목록 단계 */}
|
{/* 관계도 목록 단계 */}
|
||||||
{currentStep === "list" && (
|
{currentStep === "list" && <DataFlowList onDesignDiagram={handleDesignDiagram} />}
|
||||||
<div className="space-y-6">
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 관계도 설계 단계 */}
|
{/* 관계도 설계 단계 - 🎨 새로운 UI 사용 */}
|
||||||
{currentStep === "design" && (
|
{currentStep === "design" && (
|
||||||
<div className="space-y-6">
|
<DataConnectionDesigner
|
||||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-4">
|
onClose={() => {
|
||||||
<h2 className="text-xl font-semibold text-gray-800">{stepConfig.design.title}</h2>
|
goToStep("list");
|
||||||
</div>
|
setEditingDiagram(null);
|
||||||
<DataFlowDesigner
|
setLoadedRelationshipData(null);
|
||||||
companyCode={user?.company_code || "COMP001"}
|
}}
|
||||||
onSave={handleSave}
|
initialData={
|
||||||
selectedDiagram={null}
|
loadedRelationshipData || {
|
||||||
onBackToList={() => goToStep("list")}
|
connectionType: "data_save",
|
||||||
/>
|
}
|
||||||
</div>
|
}
|
||||||
|
showBackButton={true}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
|
|||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => onDesignDiagram(diagram)}>
|
<DropdownMenuItem onClick={() => onDesignDiagram(diagram)}>
|
||||||
<Network className="mr-2 h-4 w-4" />
|
<Network className="mr-2 h-4 w-4" />
|
||||||
관계도 설계
|
수정
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => handleCopy(diagram)}>
|
<DropdownMenuItem onClick={() => handleCopy(diagram)}>
|
||||||
<Copy className="mr-2 h-4 w-4" />
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
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 { TableInfo, ColumnInfo } from "@/lib/api/dataflow";
|
||||||
import { DataSaveSettings as DataSaveSettingsType } from "@/types/connectionTypes";
|
import { DataSaveSettings as DataSaveSettingsType } from "@/types/connectionTypes";
|
||||||
import { ActionConditionsSection } from "./ActionConditionsSection";
|
|
||||||
import { ActionFieldMappings } from "./ActionFieldMappings";
|
// 🎨 새로운 UI 컴포넌트 import
|
||||||
import { ActionSplitConfig } from "./ActionSplitConfig";
|
import DataConnectionDesigner from "./redesigned/DataConnectionDesigner";
|
||||||
|
|
||||||
interface DataSaveSettingsProps {
|
interface DataSaveSettingsProps {
|
||||||
settings: DataSaveSettingsType;
|
settings: DataSaveSettingsType;
|
||||||
@@ -23,6 +18,11 @@ interface DataSaveSettingsProps {
|
|||||||
tableColumnsCache: { [tableName: string]: ColumnInfo[] };
|
tableColumnsCache: { [tableName: string]: ColumnInfo[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🎨 데이터 저장 설정 컴포넌트
|
||||||
|
* - 항상 새로운 UI (DataConnectionDesigner) 사용
|
||||||
|
* - 기존 UI는 더 이상 사용하지 않음
|
||||||
|
*/
|
||||||
export const DataSaveSettings: React.FC<DataSaveSettingsProps> = ({
|
export const DataSaveSettings: React.FC<DataSaveSettingsProps> = ({
|
||||||
settings,
|
settings,
|
||||||
onSettingsChange,
|
onSettingsChange,
|
||||||
@@ -33,195 +33,13 @@ export const DataSaveSettings: React.FC<DataSaveSettingsProps> = ({
|
|||||||
toTableName,
|
toTableName,
|
||||||
tableColumnsCache,
|
tableColumnsCache,
|
||||||
}) => {
|
}) => {
|
||||||
const addAction = () => {
|
// 🎨 항상 새로운 UI 사용
|
||||||
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 });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-l-4 border-l-green-500 bg-green-50/30 p-4">
|
<DataConnectionDesigner
|
||||||
<div className="mb-3 flex items-center gap-2">
|
onClose={undefined} // 닫기 버튼 제거 (항상 새 UI 사용)
|
||||||
<Save className="h-4 w-4 text-green-500" />
|
initialData={{
|
||||||
<span className="text-sm font-medium">데이터 저장 설정</span>
|
connectionType: "data_save",
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,531 @@
|
|||||||
|
"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 } from "@/lib/api/dataflowSave";
|
||||||
|
|
||||||
|
// 타입 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";
|
||||||
|
import SaveRelationshipDialog from "./SaveRelationshipDialog";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🎨 데이터 연결 설정 메인 디자이너
|
||||||
|
* - 좌우 분할 레이아웃 (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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// 기존 호환성 필드들 (deprecated)
|
||||||
|
actionType: "insert",
|
||||||
|
actionConditions: [],
|
||||||
|
actionFieldMappings: [],
|
||||||
|
isLoading: false,
|
||||||
|
validationErrors: [],
|
||||||
|
...initialData,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 💾 저장 다이얼로그 상태
|
||||||
|
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||||
|
|
||||||
|
// 🔄 초기 데이터 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialData && Object.keys(initialData).length > 1) {
|
||||||
|
console.log("🔄 초기 데이터 로드:", initialData);
|
||||||
|
|
||||||
|
// 로드된 데이터로 state 업데이트
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
connectionType: initialData.connectionType || prev.connectionType,
|
||||||
|
fromConnection: initialData.fromConnection || prev.fromConnection,
|
||||||
|
toConnection: initialData.toConnection || prev.toConnection,
|
||||||
|
fromTable: initialData.fromTable || prev.fromTable,
|
||||||
|
toTable: initialData.toTable || prev.toTable,
|
||||||
|
actionType: initialData.actionType || prev.actionType,
|
||||||
|
controlConditions: initialData.controlConditions || prev.controlConditions,
|
||||||
|
actionConditions: initialData.actionConditions || prev.actionConditions,
|
||||||
|
fieldMappings: initialData.fieldMappings || prev.fieldMappings,
|
||||||
|
currentStep: initialData.fromConnection && initialData.toConnection ? 2 : 1, // 연결 정보가 있으면 2단계부터 시작
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log("✅ 초기 데이터 로드 완료");
|
||||||
|
}
|
||||||
|
}, [initialData]);
|
||||||
|
|
||||||
|
// 🎯 액션 핸들러들
|
||||||
|
const actions: DataConnectionActions = {
|
||||||
|
// 연결 타입 설정
|
||||||
|
setConnectionType: useCallback((type: "data_save" | "external_call") => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
connectionType: type,
|
||||||
|
// 타입 변경 시 상태 초기화
|
||||||
|
currentStep: 1,
|
||||||
|
fromConnection: undefined,
|
||||||
|
toConnection: undefined,
|
||||||
|
fromTable: undefined,
|
||||||
|
toTable: undefined,
|
||||||
|
fieldMappings: [],
|
||||||
|
validationErrors: [],
|
||||||
|
}));
|
||||||
|
toast.success(`연결 타입이 ${type === "data_save" ? "데이터 저장" : "외부 호출"}로 변경되었습니다.`);
|
||||||
|
}, []),
|
||||||
|
|
||||||
|
// 단계 이동
|
||||||
|
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: [],
|
||||||
|
}));
|
||||||
|
toast.success(
|
||||||
|
`${type === "from" ? "소스" : "대상"} 테이블이 선택되었습니다: ${table.displayName || table.tableName}`,
|
||||||
|
);
|
||||||
|
}, []),
|
||||||
|
|
||||||
|
// 필드 매핑 생성
|
||||||
|
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],
|
||||||
|
}));
|
||||||
|
|
||||||
|
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),
|
||||||
|
}));
|
||||||
|
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("제어 조건이 삭제되었습니다.");
|
||||||
|
}, []),
|
||||||
|
|
||||||
|
// 액션 설정 관리
|
||||||
|
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 () => {
|
||||||
|
setShowSaveDialog(true);
|
||||||
|
}, []),
|
||||||
|
|
||||||
|
// 테스트 실행
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}, []),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 💾 실제 저장 함수
|
||||||
|
const handleSaveWithName = useCallback(
|
||||||
|
async (relationshipName: string, description?: string) => {
|
||||||
|
setState((prev) => ({ ...prev, isLoading: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 실제 저장 로직 구현
|
||||||
|
const saveData = {
|
||||||
|
relationshipName,
|
||||||
|
description,
|
||||||
|
connectionType: state.connectionType,
|
||||||
|
fromConnection: state.fromConnection,
|
||||||
|
toConnection: state.toConnection,
|
||||||
|
fromTable: state.fromTable,
|
||||||
|
toTable: state.toTable,
|
||||||
|
actionType: state.actionType,
|
||||||
|
controlConditions: state.controlConditions,
|
||||||
|
actionConditions: state.actionConditions,
|
||||||
|
fieldMappings: state.fieldMappings,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("💾 저장 시작:", saveData);
|
||||||
|
|
||||||
|
// 백엔드 API 호출
|
||||||
|
const result = await saveDataflowRelationship(saveData);
|
||||||
|
|
||||||
|
console.log("✅ 저장 완료:", result);
|
||||||
|
|
||||||
|
setState((prev) => ({ ...prev, isLoading: false }));
|
||||||
|
toast.success(`"${relationshipName}" 관계가 성공적으로 저장되었습니다.`);
|
||||||
|
|
||||||
|
// 저장 후 상위 컴포넌트에 알림 (필요한 경우)
|
||||||
|
if (onClose) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
setState((prev) => ({ ...prev, isLoading: false }));
|
||||||
|
|
||||||
|
const errorMessage = error.message || "저장 중 오류가 발생했습니다.";
|
||||||
|
toast.error(errorMessage);
|
||||||
|
console.error("❌ 저장 오류:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[state, onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
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-280px)] min-h-[600px] 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 state={state} actions={actions} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 💾 저장 다이얼로그 */}
|
||||||
|
<SaveRelationshipDialog
|
||||||
|
open={showSaveDialog}
|
||||||
|
onOpenChange={setShowSaveDialog}
|
||||||
|
onSave={handleSaveWithName}
|
||||||
|
actionType={state.actionType}
|
||||||
|
fromTable={state.fromTable?.tableName}
|
||||||
|
toTable={state.toTable?.tableName}
|
||||||
|
/>
|
||||||
|
</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;
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
"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) => 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,81 @@
|
|||||||
|
"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";
|
||||||
|
import AdvancedSettings from "./AdvancedSettings";
|
||||||
|
import ActionButtons from "./ActionButtons";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 📋 좌측 패널 (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>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 매핑 상세 목록 */}
|
||||||
|
{state.fieldMappings.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-muted-foreground mb-2 text-sm font-medium">매핑 상세 목록</h3>
|
||||||
|
<MappingDetailList
|
||||||
|
mappings={state.fieldMappings}
|
||||||
|
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>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 고급 설정 */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-muted-foreground mb-2 text-sm font-medium">고급 설정</h3>
|
||||||
|
<AdvancedSettings connectionType={state.connectionType} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* 하단 액션 버튼들 - 고정 위치 */}
|
||||||
|
<div className="flex-shrink-0 border-t bg-white p-3 shadow-sm">
|
||||||
|
<ActionButtons state={state} actions={actions} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LeftPanel;
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"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">
|
||||||
|
{mappings.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} →{" "}
|
||||||
|
{mapping.toField.displayName || mapping.toField.columnName}
|
||||||
|
</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} → {mapping.toField.webType}
|
||||||
|
</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>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{mappings.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>
|
||||||
|
</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;
|
||||||
+546
@@ -0,0 +1,546 @@
|
|||||||
|
"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[];
|
||||||
|
onConditionsChange: (conditions: ActionCondition[]) => void;
|
||||||
|
onFieldMappingsChange: (mappings: FieldValueMapping[]) => void;
|
||||||
|
showFieldMappings?: boolean; // 필드 매핑 섹션 표시 여부
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🎯 액션 조건 빌더
|
||||||
|
* - 실행 조건 설정 (WHERE 절)
|
||||||
|
* - 필드 값 매핑 설정 (SET 절)
|
||||||
|
* - 코드 타입 필드 지원
|
||||||
|
*/
|
||||||
|
const ActionConditionBuilder: React.FC<ActionConditionBuilderProps> = ({
|
||||||
|
actionType,
|
||||||
|
fromColumns,
|
||||||
|
toColumns,
|
||||||
|
conditions,
|
||||||
|
fieldMappings,
|
||||||
|
onConditionsChange,
|
||||||
|
onFieldMappingsChange,
|
||||||
|
showFieldMappings = true,
|
||||||
|
}) => {
|
||||||
|
const [availableCodes, setAvailableCodes] = useState<Record<string, CodeItem[]>>({});
|
||||||
|
|
||||||
|
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) => col.webType === "code" || col.dataType?.toLowerCase().includes("code"),
|
||||||
|
);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// 조건 추가
|
||||||
|
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 newMapping: FieldValueMapping = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
targetField: "",
|
||||||
|
valueType: "static",
|
||||||
|
value: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
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?.webType === "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}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{code.code}
|
||||||
|
</Badge>
|
||||||
|
<span>{code.name}</span>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
<span>필드 값 설정 (SET)</span>
|
||||||
|
<Button variant="outline" size="sm" onClick={addFieldMapping}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
필드 추가
|
||||||
|
</Button>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
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 })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-40">
|
||||||
|
<SelectValue placeholder="대상 필드" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{toColumns.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 })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="static">고정값</SelectItem>
|
||||||
|
<SelectItem value="source_field">소스필드</SelectItem>
|
||||||
|
{targetColumn?.webType === "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,312 @@
|
|||||||
|
"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 { ArrowRight, Database, Globe, Loader2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
// API import
|
||||||
|
import { getActiveConnections, ConnectionInfo } from "@/lib/api/multiConnection";
|
||||||
|
|
||||||
|
// 타입 import
|
||||||
|
import { Connection } from "@/lib/types/multiConnection";
|
||||||
|
|
||||||
|
interface ConnectionStepProps {
|
||||||
|
connectionType: "data_save" | "external_call";
|
||||||
|
fromConnection?: Connection;
|
||||||
|
toConnection?: Connection;
|
||||||
|
onSelectConnection: (type: "from" | "to", connection: Connection) => void;
|
||||||
|
onNext: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔗 1단계: 연결 선택
|
||||||
|
* - FROM/TO 데이터베이스 연결 선택
|
||||||
|
* - 연결 상태 표시
|
||||||
|
* - 지연시간 정보
|
||||||
|
*/
|
||||||
|
const ConnectionStep: React.FC<ConnectionStepProps> = React.memo(
|
||||||
|
({ connectionType, fromConnection, toConnection, onSelectConnection, onNext }) => {
|
||||||
|
const [connections, setConnections] = useState<Connection[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 연결 목록 로드
|
||||||
|
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">
|
||||||
|
{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;
|
||||||
+462
@@ -0,0 +1,462 @@
|
|||||||
|
"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 { getColumnsFromConnection } from "@/lib/api/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 } = state;
|
||||||
|
|
||||||
|
const [fromColumns, setFromColumns] = useState<ColumnInfo[]>([]);
|
||||||
|
const [toColumns, setToColumns] = useState<ColumnInfo[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [availableCodes, setAvailableCodes] = useState<Record<string, CodeItem[]>>({});
|
||||||
|
|
||||||
|
// 컬럼 정보 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadColumns = async () => {
|
||||||
|
console.log("🔄 ControlConditionStep 컬럼 로드 시작");
|
||||||
|
console.log("fromConnection:", fromConnection);
|
||||||
|
console.log("toConnection:", toConnection);
|
||||||
|
console.log("fromTable:", fromTable);
|
||||||
|
console.log("toTable:", toTable);
|
||||||
|
|
||||||
|
if (!fromConnection || !toConnection || !fromTable || !toTable) {
|
||||||
|
console.log("❌ 필수 정보 누락으로 컬럼 로드 중단");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
console.log(
|
||||||
|
`🚀 컬럼 조회 시작: FROM=${fromConnection.id}/${fromTable.tableName}, TO=${toConnection.id}/${toTable.tableName}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [fromCols, toCols] = await Promise.all([
|
||||||
|
getColumnsFromConnection(fromConnection.id, fromTable.tableName),
|
||||||
|
getColumnsFromConnection(toConnection.id, toTable.tableName),
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log(`✅ 컬럼 조회 완료: FROM=${fromCols.length}개, TO=${toCols.length}개`);
|
||||||
|
setFromColumns(fromCols);
|
||||||
|
setToColumns(toCols);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 컬럼 정보 로드 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadColumns();
|
||||||
|
}, [fromConnection, toConnection, fromTable, toTable]);
|
||||||
|
|
||||||
|
// 코드 타입 컬럼의 코드 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadCodes = async () => {
|
||||||
|
const allColumns = [...fromColumns, ...toColumns];
|
||||||
|
const codeColumns = allColumns.filter(
|
||||||
|
(col) => col.webType === "code" || col.dataType?.toLowerCase().includes("code"),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (codeColumns.length === 0) return;
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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.webType === "code" ||
|
||||||
|
selectedField.dataType?.toLowerCase().includes("code"));
|
||||||
|
const fieldCodes = condition.field ? availableCodes[condition.field] : [];
|
||||||
|
|
||||||
|
// 디버깅 정보 출력
|
||||||
|
console.log("🔍 값 입력 필드 디버깅:", {
|
||||||
|
conditionField: condition.field,
|
||||||
|
selectedField: selectedField,
|
||||||
|
webType: selectedField?.webType,
|
||||||
|
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 || code.description || `코드 ${codeIndex + 1}`}
|
||||||
|
</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 && 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 컬럼 정보 로드 실패 시 안내 */}
|
||||||
|
{!isLoading && 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;
|
||||||
+571
@@ -0,0 +1,571 @@
|
|||||||
|
"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 { getColumnsFromConnection } from "@/lib/api/multiConnection";
|
||||||
|
|
||||||
|
// 타입 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;
|
||||||
|
// 제어 조건 관련
|
||||||
|
controlConditions: any[];
|
||||||
|
onUpdateControlCondition: (index: number, condition: any) => void;
|
||||||
|
onDeleteControlCondition: (index: number) => void;
|
||||||
|
onAddControlCondition: () => void;
|
||||||
|
// 액션 그룹 관련
|
||||||
|
actionGroups: ActionGroup[];
|
||||||
|
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;
|
||||||
|
// 필드 매핑 관련
|
||||||
|
fieldMappings: FieldMapping[];
|
||||||
|
onCreateMapping: (fromField: ColumnInfo, toField: ColumnInfo) => void;
|
||||||
|
onDeleteMapping: (mappingId: string) => void;
|
||||||
|
// 네비게이션
|
||||||
|
onNext: () => void;
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🎯 4단계: 통합된 멀티 액션 설정
|
||||||
|
* - 제어 조건 설정
|
||||||
|
* - 여러 액션 그룹 관리
|
||||||
|
* - AND/OR 논리 연산자
|
||||||
|
* - 액션별 조건 설정
|
||||||
|
* - INSERT 액션 시 컬럼 매핑
|
||||||
|
*/
|
||||||
|
const MultiActionConfigStep: React.FC<MultiActionConfigStepProps> = ({
|
||||||
|
fromTable,
|
||||||
|
toTable,
|
||||||
|
fromConnection,
|
||||||
|
toConnection,
|
||||||
|
controlConditions,
|
||||||
|
onUpdateControlCondition,
|
||||||
|
onDeleteControlCondition,
|
||||||
|
onAddControlCondition,
|
||||||
|
actionGroups,
|
||||||
|
onUpdateActionGroup,
|
||||||
|
onDeleteActionGroup,
|
||||||
|
onAddActionGroup,
|
||||||
|
onAddActionToGroup,
|
||||||
|
onUpdateActionInGroup,
|
||||||
|
onDeleteActionFromGroup,
|
||||||
|
fieldMappings,
|
||||||
|
onCreateMapping,
|
||||||
|
onDeleteMapping,
|
||||||
|
onNext,
|
||||||
|
onBack,
|
||||||
|
}) => {
|
||||||
|
const [fromColumns, setFromColumns] = useState<ColumnInfo[]>([]);
|
||||||
|
const [toColumns, setToColumns] = useState<ColumnInfo[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(["group_1"])); // 첫 번째 그룹은 기본 열림
|
||||||
|
const [activeTab, setActiveTab] = useState<"control" | "actions" | "mapping">("control"); // 현재 활성 탭
|
||||||
|
|
||||||
|
// 컬럼 정보 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadColumns = async () => {
|
||||||
|
if (!fromConnection || !toConnection || !fromTable || !toTable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const [fromCols, toCols] = await Promise.all([
|
||||||
|
getColumnsFromConnection(fromConnection.id, fromTable.tableName),
|
||||||
|
getColumnsFromConnection(toConnection.id, toTable.tableName),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setFromColumns(Array.isArray(fromCols) ? fromCols : []);
|
||||||
|
setToColumns(Array.isArray(toCols) ? toCols : []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 컬럼 정보 로드 실패:", error);
|
||||||
|
toast.error("필드 정보를 불러오는데 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadColumns();
|
||||||
|
}, [fromConnection, toConnection, fromTable, toTable]);
|
||||||
|
|
||||||
|
// 그룹 확장/축소 토글
|
||||||
|
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: "액션 그룹 및 실행 조건" },
|
||||||
|
...(hasInsertActions
|
||||||
|
? [{ id: "mapping" as const, label: "컬럼 매핑", icon: "🔗", description: "INSERT 액션 필드 매핑" }]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* 액션 그룹 목록 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{actionGroups.map((group, groupIndex) => (
|
||||||
|
<div key={group.id} 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>
|
||||||
|
|
||||||
|
{/* 액션 조건 설정 */}
|
||||||
|
<ActionConditionBuilder
|
||||||
|
actionType={action.actionType}
|
||||||
|
fromColumns={fromColumns}
|
||||||
|
toColumns={toColumns}
|
||||||
|
conditions={action.conditions}
|
||||||
|
fieldMappings={action.fieldMappings}
|
||||||
|
onConditionsChange={(conditions) =>
|
||||||
|
onUpdateActionInGroup(group.id, action.id, { conditions })
|
||||||
|
}
|
||||||
|
onFieldMappingsChange={(fieldMappings) =>
|
||||||
|
onUpdateActionInGroup(group.id, action.id, { fieldMappings })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* 그룹 간 연결선 (마지막 그룹이 아닌 경우) */}
|
||||||
|
{groupIndex < actionGroups.length - 1 && (
|
||||||
|
<div className="flex justify-center py-2">
|
||||||
|
<div className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||||
|
<div className="bg-border h-px w-8"></div>
|
||||||
|
<span>다음 그룹</span>
|
||||||
|
<div className="bg-border h-px w-8"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "mapping" && hasInsertActions && (
|
||||||
|
<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">
|
||||||
|
{fieldMappings.length}개 매핑
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-sm">INSERT 액션에 필요한 필드들을 매핑하세요</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 컬럼 매핑 캔버스 */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<div className="text-muted-foreground">컬럼 정보를 불러오는 중...</div>
|
||||||
|
</div>
|
||||||
|
) : fromColumns.length > 0 && toColumns.length > 0 ? (
|
||||||
|
<div className="rounded-lg border bg-white p-4">
|
||||||
|
<FieldMappingCanvas
|
||||||
|
fromFields={fromColumns}
|
||||||
|
toFields={toColumns}
|
||||||
|
mappings={fieldMappings}
|
||||||
|
onCreateMapping={onCreateMapping}
|
||||||
|
onDeleteMapping={onDeleteMapping}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center space-y-3 rounded-lg border border-dashed">
|
||||||
|
<AlertTriangle className="text-muted-foreground h-8 w-8" />
|
||||||
|
<div className="text-muted-foreground">컬럼 정보를 찾을 수 없습니다.</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
FROM 컬럼: {fromColumns.length}개, TO 컬럼: {toColumns.length}개
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 매핑되지 않은 필드 처리 옵션 */}
|
||||||
|
<div className="rounded-md border bg-yellow-50 p-4">
|
||||||
|
<h4 className="mb-3 flex items-center gap-2 font-medium text-yellow-800">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
매핑되지 않은 필드 처리
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input type="radio" id="empty" name="unmapped-strategy" defaultChecked className="h-4 w-4" />
|
||||||
|
<label htmlFor="empty" className="text-yellow-700">
|
||||||
|
비워두기 (NULL 또는 빈 값)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input type="radio" id="default" name="unmapped-strategy" className="h-4 w-4" />
|
||||||
|
<label htmlFor="default" className="text-yellow-700">
|
||||||
|
기본값 사용 (데이터베이스 DEFAULT 값)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input type="radio" id="skip" name="unmapped-strategy" className="h-4 w-4" />
|
||||||
|
<label htmlFor="skip" className="text-yellow-700">
|
||||||
|
해당 필드 제외 (INSERT 구문에 포함하지 않음)
|
||||||
|
</label>
|
||||||
|
</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,141 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
|
||||||
|
// 타입 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";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🎯 우측 패널 (70% 너비)
|
||||||
|
* - 단계별 진행 UI
|
||||||
|
* - 연결 → 테이블 → 필드 매핑
|
||||||
|
* - 시각적 매핑 영역
|
||||||
|
*/
|
||||||
|
const RightPanel: React.FC<RightPanelProps> = ({ state, actions }) => {
|
||||||
|
// 완료된 단계 계산
|
||||||
|
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 = () => {
|
||||||
|
switch (state.currentStep) {
|
||||||
|
case 1:
|
||||||
|
return (
|
||||||
|
<ConnectionStep
|
||||||
|
connectionType={state.connectionType}
|
||||||
|
fromConnection={state.fromConnection}
|
||||||
|
toConnection={state.toConnection}
|
||||||
|
onSelectConnection={actions.selectConnection}
|
||||||
|
onNext={() => actions.goToStep(2)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 2:
|
||||||
|
return (
|
||||||
|
<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)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 3:
|
||||||
|
// 3단계: 제어 조건
|
||||||
|
return (
|
||||||
|
<ControlConditionStep
|
||||||
|
state={state}
|
||||||
|
actions={actions}
|
||||||
|
onBack={() => actions.goToStep(2)}
|
||||||
|
onNext={() => actions.goToStep(4)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 4:
|
||||||
|
// 4단계: 통합된 멀티 액션 설정 (제어 조건 + 액션 설정 + 컬럼 매핑)
|
||||||
|
return (
|
||||||
|
<MultiActionConfigStep
|
||||||
|
fromTable={state.fromTable}
|
||||||
|
toTable={state.toTable}
|
||||||
|
fromConnection={state.fromConnection}
|
||||||
|
toConnection={state.toConnection}
|
||||||
|
controlConditions={state.controlConditions}
|
||||||
|
onUpdateControlCondition={actions.updateControlCondition}
|
||||||
|
onDeleteControlCondition={actions.deleteControlCondition}
|
||||||
|
onAddControlCondition={actions.addControlCondition}
|
||||||
|
actionGroups={state.actionGroups}
|
||||||
|
onUpdateActionGroup={actions.updateActionGroup}
|
||||||
|
onDeleteActionGroup={actions.deleteActionGroup}
|
||||||
|
onAddActionGroup={actions.addActionGroup}
|
||||||
|
onAddActionToGroup={actions.addActionToGroup}
|
||||||
|
onUpdateActionInGroup={actions.updateActionInGroup}
|
||||||
|
onDeleteActionFromGroup={actions.deleteActionFromGroup}
|
||||||
|
fieldMappings={state.fieldMappings}
|
||||||
|
onCreateMapping={actions.createMapping}
|
||||||
|
onDeleteMapping={actions.deleteMapping}
|
||||||
|
onNext={() => {
|
||||||
|
// 완료 처리 - 저장 및 상위 컴포넌트 알림
|
||||||
|
actions.saveMappings();
|
||||||
|
}}
|
||||||
|
onBack={() => actions.goToStep(3)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* 단계 진행 표시 */}
|
||||||
|
<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">
|
||||||
|
<Card className="flex h-full flex-col overflow-hidden">{renderCurrentStep()}</Card>
|
||||||
|
</div>
|
||||||
|
</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;
|
||||||
+152
@@ -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;
|
||||||
+194
@@ -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;
|
||||||
+325
@@ -0,0 +1,325 @@
|
|||||||
|
"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.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;
|
||||||
+117
@@ -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,150 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { CheckCircle, Save } from "lucide-react";
|
||||||
|
|
||||||
|
interface SaveRelationshipDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSave: (relationshipName: string, description?: string) => void;
|
||||||
|
actionType: "insert" | "update" | "delete" | "upsert";
|
||||||
|
fromTable?: string;
|
||||||
|
toTable?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 💾 관계 저장 다이얼로그
|
||||||
|
* - 관계 이름 입력
|
||||||
|
* - 설명 입력 (선택사항)
|
||||||
|
* - 액션 타입별 제안 이름
|
||||||
|
*/
|
||||||
|
const SaveRelationshipDialog: React.FC<SaveRelationshipDialogProps> = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSave,
|
||||||
|
actionType,
|
||||||
|
fromTable,
|
||||||
|
toTable,
|
||||||
|
}) => {
|
||||||
|
const [relationshipName, setRelationshipName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
|
||||||
|
// 액션 타입별 제안 이름 생성
|
||||||
|
const generateSuggestedName = () => {
|
||||||
|
if (!fromTable || !toTable) return "";
|
||||||
|
|
||||||
|
const actionMap = {
|
||||||
|
insert: "입력",
|
||||||
|
update: "수정",
|
||||||
|
delete: "삭제",
|
||||||
|
upsert: "병합",
|
||||||
|
};
|
||||||
|
|
||||||
|
return `${fromTable}_${toTable}_${actionMap[actionType]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!relationshipName.trim()) return;
|
||||||
|
|
||||||
|
onSave(relationshipName.trim(), description.trim() || undefined);
|
||||||
|
onOpenChange(false);
|
||||||
|
|
||||||
|
// 폼 초기화
|
||||||
|
setRelationshipName("");
|
||||||
|
setDescription("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSuggestName = () => {
|
||||||
|
const suggested = generateSuggestedName();
|
||||||
|
if (suggested) {
|
||||||
|
setRelationshipName(suggested);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Save className="h-5 w-5" />
|
||||||
|
관계 저장
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>데이터 연결 관계의 이름과 설명을 입력하세요.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 관계 이름 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="relationshipName">관계 이름 *</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="relationshipName"
|
||||||
|
placeholder="예: 사용자_주문_입력"
|
||||||
|
value={relationshipName}
|
||||||
|
onChange={(e) => setRelationshipName(e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleSuggestName} disabled={!fromTable || !toTable}>
|
||||||
|
제안
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 설명 (선택사항) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">설명 (선택사항)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
placeholder="이 관계에 대한 설명을 입력하세요..."
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 정보 요약 */}
|
||||||
|
<div className="bg-muted/50 rounded-lg p-3">
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">액션 타입:</span>
|
||||||
|
<span className="font-medium">{actionType.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">소스 테이블:</span>
|
||||||
|
<span className="font-medium">{fromTable || "미선택"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">대상 테이블:</span>
|
||||||
|
<span className="font-medium">{toTable || "미선택"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={!relationshipName.trim()}>
|
||||||
|
<CheckCircle className="mr-2 h-4 w-4" />
|
||||||
|
저장
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SaveRelationshipDialog;
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
// 🎨 제어관리 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;
|
||||||
|
|
||||||
|
// 연결 정보
|
||||||
|
fromConnection?: Connection;
|
||||||
|
toConnection?: Connection;
|
||||||
|
fromTable?: TableInfo;
|
||||||
|
toTable?: TableInfo;
|
||||||
|
|
||||||
|
// 매핑 정보
|
||||||
|
fieldMappings: FieldMapping[];
|
||||||
|
mappingStats: MappingStats;
|
||||||
|
|
||||||
|
// 제어 실행 조건 (전체 제어가 언제 실행될지)
|
||||||
|
controlConditions: any[]; // 전체 제어 트리거 조건
|
||||||
|
|
||||||
|
// 액션 설정 (멀티 액션 지원)
|
||||||
|
actionGroups: ActionGroup[];
|
||||||
|
|
||||||
|
// 기존 호환성을 위한 필드들 (deprecated)
|
||||||
|
actionType?: "insert" | "update" | "delete" | "upsert";
|
||||||
|
actionConditions?: any[]; // 각 액션의 대상 레코드 조건
|
||||||
|
actionFieldMappings?: any[]; // 액션별 필드 매핑
|
||||||
|
|
||||||
|
// UI 상태
|
||||||
|
selectedMapping?: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
validationErrors: ValidationError[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 액션 인터페이스
|
||||||
|
export interface DataConnectionActions {
|
||||||
|
// 연결 타입
|
||||||
|
setConnectionType: (type: "data_save" | "external_call") => void;
|
||||||
|
|
||||||
|
// 단계 진행
|
||||||
|
goToStep: (step: 1 | 2 | 3 | 4) => 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;
|
||||||
|
|
||||||
|
// 제어 조건 관리 (전체 실행 조건)
|
||||||
|
addControlCondition: () => void;
|
||||||
|
updateControlCondition: (index: number, condition: any) => void;
|
||||||
|
deleteControlCondition: (index: number) => 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,175 @@
|
|||||||
|
/**
|
||||||
|
* 코드 관리 API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
export interface CodeItem {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
orderNo?: number;
|
||||||
|
useYn: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodeCategory {
|
||||||
|
categoryCode: string;
|
||||||
|
categoryName: string;
|
||||||
|
description?: string;
|
||||||
|
useYn: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 코드 카테고리 목록 조회
|
||||||
|
*/
|
||||||
|
export const getCodeCategories = async (): Promise<CodeCategory[]> => {
|
||||||
|
try {
|
||||||
|
// 올바른 API 엔드포인트 사용 (apiClient 사용)
|
||||||
|
const response = await apiClient.get("/common-codes/categories");
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
// 응답 데이터 구조에 맞게 변환
|
||||||
|
const categories = data.data || [];
|
||||||
|
return categories.map((category: any) => ({
|
||||||
|
categoryCode: category.categoryCode,
|
||||||
|
categoryName: category.categoryName,
|
||||||
|
description: category.description,
|
||||||
|
useYn: category.isActive ? "Y" : "N",
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("코드 카테고리 조회 실패:", error);
|
||||||
|
|
||||||
|
// API 호출 실패 시 빈 배열 반환
|
||||||
|
console.warn("코드 카테고리 API 호출 실패 - 빈 배열 반환");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 카테고리의 코드 목록 조회
|
||||||
|
*/
|
||||||
|
export const getCodesByCategory = async (categoryCode: string): Promise<CodeItem[]> => {
|
||||||
|
try {
|
||||||
|
// API URL 디버깅
|
||||||
|
const apiUrl = `/common-codes/categories/${encodeURIComponent(categoryCode)}/codes`;
|
||||||
|
console.log("🔗 코드 API 호출 URL:", {
|
||||||
|
categoryCode,
|
||||||
|
apiUrl,
|
||||||
|
currentURL: typeof window !== "undefined" ? window.location.href : "서버사이드",
|
||||||
|
baseURL: typeof window !== "undefined" ? `${window.location.protocol}//${window.location.host}` : "서버사이드",
|
||||||
|
apiClientBaseURL: apiClient.defaults.baseURL,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("📡 실제 요청 URL:", `${apiClient.defaults.baseURL}${apiUrl}`);
|
||||||
|
|
||||||
|
// 올바른 API 엔드포인트 사용 (apiClient 사용)
|
||||||
|
const response = await apiClient.get(apiUrl);
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
console.log("🔍 백엔드 응답 데이터:", {
|
||||||
|
fullResponse: data,
|
||||||
|
dataArray: data.data,
|
||||||
|
firstItem: data.data && data.data[0] ? data.data[0] : "없음",
|
||||||
|
dataType: typeof data.data,
|
||||||
|
isArray: Array.isArray(data.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 응답 데이터 구조에 맞게 변환
|
||||||
|
const codes = data.data || [];
|
||||||
|
const mappedCodes = codes.map((code: any) => {
|
||||||
|
console.log("🔄 코드 매핑:", {
|
||||||
|
original: code,
|
||||||
|
mapped: {
|
||||||
|
code: code.codeValue || code.code || code.id || code.value,
|
||||||
|
name: code.codeName || code.name || code.label || code.description,
|
||||||
|
description: code.description || code.codeName || code.name,
|
||||||
|
orderNo: code.sortOrder || code.orderNo || code.order,
|
||||||
|
useYn: code.isActive ? "Y" : code.useYn || "Y",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: code.codeValue || code.code || code.id || code.value,
|
||||||
|
name: code.codeName || code.name || code.label || code.description,
|
||||||
|
description: code.description || code.codeName || code.name,
|
||||||
|
orderNo: code.sortOrder || code.orderNo || code.order,
|
||||||
|
useYn: code.isActive ? "Y" : code.useYn || "Y",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("📋 최종 매핑된 코드들:", mappedCodes);
|
||||||
|
return mappedCodes;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("코드 목록 조회 실패:", error);
|
||||||
|
|
||||||
|
// 인증 오류인 경우 명시적으로 알림
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
console.warn("🔐 인증이 필요합니다. 로그인 후 다시 시도하세요.");
|
||||||
|
} else if (error.response?.status === 404) {
|
||||||
|
console.warn(`📭 코드 카테고리 '${categoryCode}'가 존재하지 않습니다.`);
|
||||||
|
} else {
|
||||||
|
console.warn(`❌ 코드 카테고리 '${categoryCode}'에 대한 API 호출 실패:`, error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컬럼의 웹타입과 코드 카테고리 정보 기반으로 코드 조회
|
||||||
|
*/
|
||||||
|
export const getCodesForColumn = async (
|
||||||
|
columnName: string,
|
||||||
|
webType?: string,
|
||||||
|
codeCategory?: string,
|
||||||
|
): Promise<CodeItem[]> => {
|
||||||
|
// 코드 타입이 아니면 빈 배열 반환
|
||||||
|
if (webType !== "code" && !codeCategory) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 코드 카테고리가 있으면 해당 카테고리의 코드 조회
|
||||||
|
if (codeCategory) {
|
||||||
|
return await getCodesByCategory(codeCategory);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컬럼명에서 코드 카테고리 추론 (예: status_code -> STATUS)
|
||||||
|
const inferredCategory = inferCodeCategoryFromColumnName(columnName);
|
||||||
|
if (inferredCategory) {
|
||||||
|
return await getCodesByCategory(inferredCategory);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컬럼명에서 코드 카테고리 추론
|
||||||
|
*/
|
||||||
|
const inferCodeCategoryFromColumnName = (columnName: string): string | null => {
|
||||||
|
const lowerName = columnName.toLowerCase();
|
||||||
|
|
||||||
|
// 일반적인 패턴들
|
||||||
|
const patterns = [
|
||||||
|
{ pattern: /status/i, category: "STATUS" },
|
||||||
|
{ pattern: /state/i, category: "STATE" },
|
||||||
|
{ pattern: /type/i, category: "TYPE" },
|
||||||
|
{ pattern: /category/i, category: "CATEGORY" },
|
||||||
|
{ pattern: /grade/i, category: "GRADE" },
|
||||||
|
{ pattern: /level/i, category: "LEVEL" },
|
||||||
|
{ pattern: /priority/i, category: "PRIORITY" },
|
||||||
|
{ pattern: /role/i, category: "ROLE" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { pattern, category } of patterns) {
|
||||||
|
if (pattern.test(lowerName)) {
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실제 데이터베이스에 있는 코드 카테고리만 사용
|
||||||
|
* Mock 데이터는 더 이상 사용하지 않음
|
||||||
|
*/
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 저장된 dataflow diagram으로부터 관계 정보를 조회하여 DataConnectionDesigner에서 사용할 수 있는 형태로 변환
|
||||||
|
*/
|
||||||
|
export const loadDataflowRelationship = async (diagramId: number) => {
|
||||||
|
try {
|
||||||
|
console.log(`📖 관계 정보 로드 시작: diagramId=${diagramId}`);
|
||||||
|
|
||||||
|
// dataflow-diagrams API에서 해당 diagram 조회
|
||||||
|
const response = await apiClient.get(`/dataflow-diagrams/${diagramId}`);
|
||||||
|
console.log("✅ diagram 조회 성공:", response.data);
|
||||||
|
|
||||||
|
const diagram = response.data.data;
|
||||||
|
if (!diagram || !diagram.relationships) {
|
||||||
|
throw new Error("관계 정보를 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("🔍 원본 diagram 구조:", diagram);
|
||||||
|
console.log("🔍 relationships 구조:", diagram.relationships);
|
||||||
|
console.log("🔍 relationships.relationships 구조:", diagram.relationships?.relationships);
|
||||||
|
console.log("🔍 relationships.relationships 타입:", typeof diagram.relationships?.relationships);
|
||||||
|
console.log("🔍 relationships.relationships 배열인가?:", Array.isArray(diagram.relationships?.relationships));
|
||||||
|
|
||||||
|
// 기존 구조와 redesigned 구조 모두 지원
|
||||||
|
let relationshipsData;
|
||||||
|
|
||||||
|
// Case 1: Redesigned UI 구조 (단일 관계 객체)
|
||||||
|
if (diagram.relationships.relationships && !Array.isArray(diagram.relationships.relationships)) {
|
||||||
|
relationshipsData = diagram.relationships.relationships;
|
||||||
|
console.log("✅ Redesigned 구조 감지:", relationshipsData);
|
||||||
|
}
|
||||||
|
// Case 2: 기존 구조 (관계 배열) - 첫 번째 관계만 로드
|
||||||
|
else if (diagram.relationships.relationships && Array.isArray(diagram.relationships.relationships)) {
|
||||||
|
const firstRelation = diagram.relationships.relationships[0];
|
||||||
|
if (!firstRelation) {
|
||||||
|
throw new Error("관계 데이터가 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("🔄 기존 구조 감지, 변환 중:", firstRelation);
|
||||||
|
|
||||||
|
// 기존 구조를 redesigned 구조로 변환
|
||||||
|
relationshipsData = {
|
||||||
|
description: firstRelation.note || "",
|
||||||
|
connectionType: firstRelation.connectionType || "data_save",
|
||||||
|
fromConnection: { id: 0, name: "메인 데이터베이스 (현재 시스템)" }, // 기본값
|
||||||
|
toConnection: { id: 0, name: "메인 데이터베이스 (현재 시스템)" }, // 기본값
|
||||||
|
fromTable: firstRelation.fromTable,
|
||||||
|
toTable: firstRelation.toTable,
|
||||||
|
actionType: "insert", // 기본값
|
||||||
|
controlConditions: [],
|
||||||
|
actionConditions: [],
|
||||||
|
fieldMappings: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 기존 control 및 plan 데이터를 변환
|
||||||
|
if (diagram.control && diagram.control.length > 0) {
|
||||||
|
const control = diagram.control.find((c) => c.id === firstRelation.id);
|
||||||
|
if (control && control.conditions) {
|
||||||
|
relationshipsData.controlConditions = control.conditions;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diagram.plan && diagram.plan.length > 0) {
|
||||||
|
const plan = diagram.plan.find((p) => p.id === firstRelation.id);
|
||||||
|
if (plan && plan.actions && plan.actions.length > 0) {
|
||||||
|
const firstAction = plan.actions[0];
|
||||||
|
relationshipsData.actionType = firstAction.actionType || "insert";
|
||||||
|
relationshipsData.fieldMappings = firstAction.fieldMappings || [];
|
||||||
|
relationshipsData.actionConditions = firstAction.conditions || [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Case 3: 다른 구조들 처리
|
||||||
|
else if (diagram.relationships && typeof diagram.relationships === "object") {
|
||||||
|
console.log("🔄 다른 구조 감지, relationships 전체를 확인 중:", diagram.relationships);
|
||||||
|
|
||||||
|
// relationships 자체가 데이터인 경우 (레거시 구조)
|
||||||
|
if (
|
||||||
|
diagram.relationships.description ||
|
||||||
|
diagram.relationships.connectionType ||
|
||||||
|
diagram.relationships.fromTable
|
||||||
|
) {
|
||||||
|
relationshipsData = diagram.relationships;
|
||||||
|
console.log("✅ 레거시 구조 감지:", relationshipsData);
|
||||||
|
}
|
||||||
|
// relationships가 빈 객체이거나 예상치 못한 구조인 경우
|
||||||
|
else {
|
||||||
|
console.log("⚠️ 알 수 없는 relationships 구조, 기본값 생성");
|
||||||
|
relationshipsData = {
|
||||||
|
description: "",
|
||||||
|
connectionType: "data_save",
|
||||||
|
fromConnection: { id: 0, name: "메인 데이터베이스 (현재 시스템)" },
|
||||||
|
toConnection: { id: 0, name: "메인 데이터베이스 (현재 시스템)" },
|
||||||
|
fromTable: "",
|
||||||
|
toTable: "",
|
||||||
|
actionType: "insert",
|
||||||
|
controlConditions: [],
|
||||||
|
actionConditions: [],
|
||||||
|
fieldMappings: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error("관계 데이터가 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("🔍 추출된 관계 데이터:", relationshipsData);
|
||||||
|
|
||||||
|
// DataConnectionDesigner에서 사용하는 형태로 변환
|
||||||
|
const loadedData = {
|
||||||
|
relationshipName: diagram.diagram_name,
|
||||||
|
description: relationshipsData.description || "",
|
||||||
|
connectionType: relationshipsData.connectionType || "data_save",
|
||||||
|
fromConnection: relationshipsData.fromConnection || { id: 0, name: "메인 데이터베이스 (현재 시스템)" },
|
||||||
|
toConnection: relationshipsData.toConnection || { id: 0, name: "메인 데이터베이스 (현재 시스템)" },
|
||||||
|
fromTable: relationshipsData.fromTable,
|
||||||
|
toTable: relationshipsData.toTable,
|
||||||
|
actionType: relationshipsData.actionType || "insert",
|
||||||
|
controlConditions: relationshipsData.controlConditions || [],
|
||||||
|
actionConditions: relationshipsData.actionConditions || [],
|
||||||
|
fieldMappings: relationshipsData.fieldMappings || [],
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("✨ 변환된 로드 데이터:", loadedData);
|
||||||
|
|
||||||
|
return loadedData;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ 관계 정보 로드 실패:", error);
|
||||||
|
|
||||||
|
let errorMessage = "관계 정보를 불러오는 중 오류가 발생했습니다.";
|
||||||
|
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
errorMessage = "관계 정보를 찾을 수 없습니다.";
|
||||||
|
} else if (error.response?.status === 401) {
|
||||||
|
errorMessage = "인증이 필요합니다. 다시 로그인해주세요.";
|
||||||
|
} else if (error.response?.data?.message) {
|
||||||
|
errorMessage = error.response.data.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveDataflowRelationship = async (data: any) => {
|
||||||
|
try {
|
||||||
|
console.log("💾 임시 저장 방식 사용 - dataflow-diagrams API 활용");
|
||||||
|
|
||||||
|
// dataflow-diagrams API 형식에 맞게 데이터 변환
|
||||||
|
const requestData = {
|
||||||
|
diagram_name: data.relationshipName,
|
||||||
|
relationships: {
|
||||||
|
// 관계 정보를 relationships 형식으로 변환
|
||||||
|
connectionType: data.connectionType,
|
||||||
|
actionType: data.actionType,
|
||||||
|
fromConnection: data.fromConnection,
|
||||||
|
toConnection: data.toConnection,
|
||||||
|
fromTable: data.fromTable,
|
||||||
|
toTable: data.toTable,
|
||||||
|
fieldMappings: data.fieldMappings,
|
||||||
|
controlConditions: data.controlConditions,
|
||||||
|
actionConditions: data.actionConditions,
|
||||||
|
description: data.description,
|
||||||
|
},
|
||||||
|
category: {
|
||||||
|
type: "data-connection",
|
||||||
|
source: "redesigned-ui",
|
||||||
|
},
|
||||||
|
control: {
|
||||||
|
conditions: data.controlConditions,
|
||||||
|
actionType: data.actionType,
|
||||||
|
},
|
||||||
|
plan: {
|
||||||
|
description: data.description,
|
||||||
|
mappings: data.fieldMappings,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("📡 변환된 요청 데이터:", requestData);
|
||||||
|
|
||||||
|
// dataflow-diagrams API로 저장 (임시 해결책)
|
||||||
|
const response = await apiClient.post("/dataflow-diagrams", requestData);
|
||||||
|
|
||||||
|
console.log("✅ dataflow-diagrams 저장 성공:", response.data);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ 저장 실패:", error);
|
||||||
|
|
||||||
|
// 구체적인 에러 메시지 제공
|
||||||
|
let errorMessage = "저장 중 오류가 발생했습니다.";
|
||||||
|
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
errorMessage = "API 엔드포인트를 찾을 수 없습니다. 백엔드 서비스를 확인해주세요.";
|
||||||
|
} else if (error.response?.status === 401) {
|
||||||
|
errorMessage = "인증이 필요합니다. 다시 로그인해주세요.";
|
||||||
|
} else if (error.response?.data?.message) {
|
||||||
|
errorMessage = error.response.data.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -4,6 +4,90 @@
|
|||||||
|
|
||||||
import { apiClient } from "./client";
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 타입을 웹 타입으로 매핑하는 함수
|
||||||
|
* @param dataType - 데이터베이스 데이터 타입
|
||||||
|
* @returns 웹 타입 문자열
|
||||||
|
*/
|
||||||
|
const mapDataTypeToWebType = (dataType: string | undefined | null): string => {
|
||||||
|
if (!dataType || typeof dataType !== "string") {
|
||||||
|
console.warn(`⚠️ 잘못된 데이터 타입: ${dataType}, 기본값 'text' 사용`);
|
||||||
|
return "text";
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerType = dataType.toLowerCase();
|
||||||
|
|
||||||
|
// 텍스트 타입
|
||||||
|
if (lowerType.includes("varchar") || lowerType.includes("char") || lowerType.includes("text")) {
|
||||||
|
return "text";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 숫자 타입
|
||||||
|
if (lowerType.includes("int") || lowerType.includes("bigint") || lowerType.includes("smallint")) {
|
||||||
|
return "number";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
lowerType.includes("decimal") ||
|
||||||
|
lowerType.includes("numeric") ||
|
||||||
|
lowerType.includes("float") ||
|
||||||
|
lowerType.includes("double")
|
||||||
|
) {
|
||||||
|
return "decimal";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 날짜/시간 타입
|
||||||
|
if (lowerType.includes("timestamp") || lowerType.includes("datetime")) {
|
||||||
|
return "datetime";
|
||||||
|
}
|
||||||
|
if (lowerType.includes("date")) {
|
||||||
|
return "date";
|
||||||
|
}
|
||||||
|
if (lowerType.includes("time")) {
|
||||||
|
return "time";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 불린 타입
|
||||||
|
if (lowerType.includes("boolean") || lowerType.includes("bit")) {
|
||||||
|
return "boolean";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 바이너리/파일 타입
|
||||||
|
if (lowerType.includes("bytea") || lowerType.includes("blob") || lowerType.includes("binary")) {
|
||||||
|
return "file";
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON 타입
|
||||||
|
if (lowerType.includes("json")) {
|
||||||
|
return "text";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본값
|
||||||
|
console.log(`🔍 알 수 없는 데이터 타입: ${dataType} → text로 매핑`);
|
||||||
|
return "text";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컬럼명으로부터 코드 카테고리를 추론
|
||||||
|
* 실제 존재하는 카테고리만 반환하도록 개선
|
||||||
|
*/
|
||||||
|
const inferCodeCategory = (columnName: string): string => {
|
||||||
|
const lowerName = columnName.toLowerCase();
|
||||||
|
|
||||||
|
// 실제 데이터베이스에 존재하는 것으로 확인된 카테고리만 반환
|
||||||
|
if (lowerName.includes("status")) return "STATUS";
|
||||||
|
|
||||||
|
// 다른 카테고리들은 실제 존재 여부를 확인한 후 추가
|
||||||
|
// if (lowerName.includes("type")) return "TYPE";
|
||||||
|
// if (lowerName.includes("grade")) return "GRADE";
|
||||||
|
// if (lowerName.includes("level")) return "LEVEL";
|
||||||
|
// if (lowerName.includes("priority")) return "PRIORITY";
|
||||||
|
// if (lowerName.includes("category")) return "CATEGORY";
|
||||||
|
// if (lowerName.includes("role")) return "ROLE";
|
||||||
|
|
||||||
|
// 확인되지 않은 컬럼은 일단 STATUS로 매핑 (임시)
|
||||||
|
return "STATUS";
|
||||||
|
};
|
||||||
|
|
||||||
export interface MultiConnectionTableInfo {
|
export interface MultiConnectionTableInfo {
|
||||||
tableName: string;
|
tableName: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
@@ -63,12 +147,226 @@ export const getTablesFromConnection = async (connectionId: number): Promise<Mul
|
|||||||
return response.data.data || [];
|
return response.data.data || [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 커넥션의 모든 테이블 정보 배치 조회 (컬럼 수 포함)
|
||||||
|
*/
|
||||||
|
export const getBatchTablesWithColumns = async (
|
||||||
|
connectionId: number,
|
||||||
|
): Promise<{ tableName: string; displayName?: string; columnCount: number }[]> => {
|
||||||
|
console.log(`🚀 getBatchTablesWithColumns 호출: connectionId=${connectionId}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/multi-connection/connections/${connectionId}/tables/batch`);
|
||||||
|
console.log("✅ 배치 테이블 정보 조회 성공:", response.data);
|
||||||
|
|
||||||
|
const result = response.data.data || [];
|
||||||
|
console.log(`📊 배치 조회 결과: ${result.length}개 테이블`, result);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 배치 테이블 정보 조회 실패:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 특정 커넥션의 테이블 컬럼 정보 조회
|
* 특정 커넥션의 테이블 컬럼 정보 조회
|
||||||
*/
|
*/
|
||||||
export const getColumnsFromConnection = async (connectionId: number, tableName: string): Promise<ColumnInfo[]> => {
|
export const getColumnsFromConnection = async (connectionId: number, tableName: string): Promise<ColumnInfo[]> => {
|
||||||
const response = await apiClient.get(`/multi-connection/connections/${connectionId}/tables/${tableName}/columns`);
|
console.log(`🔍 getColumnsFromConnection 호출: connectionId=${connectionId}, tableName=${tableName}`);
|
||||||
return response.data.data || [];
|
|
||||||
|
try {
|
||||||
|
// 메인 데이터베이스(connectionId = 0)인 경우 기존 API 사용
|
||||||
|
if (connectionId === 0) {
|
||||||
|
console.log("📡 메인 DB API 호출:", `/table-management/tables/${tableName}/columns`);
|
||||||
|
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
||||||
|
console.log("✅ 메인 DB 응답:", response.data);
|
||||||
|
|
||||||
|
const rawResult = response.data.data || [];
|
||||||
|
|
||||||
|
// 메인 DB는 페이지네이션 구조로 반환됨: {columns: [], total, page, size, totalPages}
|
||||||
|
const columns = rawResult.columns || rawResult;
|
||||||
|
|
||||||
|
// 메인 DB 컬럼에도 코드 타입 감지 로직 적용
|
||||||
|
const result = Array.isArray(columns)
|
||||||
|
? columns.map((col: any) => {
|
||||||
|
const columnName = col.columnName || "";
|
||||||
|
|
||||||
|
// 컬럼명으로 코드 타입 감지
|
||||||
|
const isCodeColumn =
|
||||||
|
columnName.toLowerCase().includes("code") ||
|
||||||
|
columnName.toLowerCase().includes("status") ||
|
||||||
|
columnName.toLowerCase().includes("type") ||
|
||||||
|
columnName.toLowerCase().includes("grade") ||
|
||||||
|
columnName.toLowerCase().includes("level");
|
||||||
|
|
||||||
|
return {
|
||||||
|
...col,
|
||||||
|
webType: isCodeColumn ? "code" : col.webType || mapDataTypeToWebType(col.dataType),
|
||||||
|
codeCategory: isCodeColumn ? inferCodeCategory(columnName) : col.codeCategory,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: columns;
|
||||||
|
|
||||||
|
console.log("📊 메인 DB 최종 결과:", {
|
||||||
|
rawType: typeof rawResult,
|
||||||
|
rawIsArray: Array.isArray(rawResult),
|
||||||
|
hasColumns: rawResult && typeof rawResult === "object" && "columns" in rawResult,
|
||||||
|
finalType: typeof result,
|
||||||
|
finalIsArray: Array.isArray(result),
|
||||||
|
length: Array.isArray(result) ? result.length : "N/A",
|
||||||
|
sample: Array.isArray(result) ? result.slice(0, 1) : result,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 외부 커넥션인 경우 external-db-connections API 사용
|
||||||
|
console.log("📡 외부 DB API 호출:", `/external-db-connections/${connectionId}/tables/${tableName}/columns`);
|
||||||
|
const response = await apiClient.get(`/external-db-connections/${connectionId}/tables/${tableName}/columns`);
|
||||||
|
console.log("✅ 외부 DB 응답:", response.data);
|
||||||
|
|
||||||
|
const rawResult = response.data.data || [];
|
||||||
|
|
||||||
|
// 외부 DB 컬럼 구조를 메인 DB 형식으로 변환
|
||||||
|
const result = Array.isArray(rawResult)
|
||||||
|
? rawResult.map((col: any) => {
|
||||||
|
const columnName = col.column_name || col.columnName || "";
|
||||||
|
const dataType = col.data_type || col.dataType || "unknown";
|
||||||
|
|
||||||
|
// 컬럼명이 '_code'로 끝나거나 'status', 'type' 등의 이름을 가진 경우 코드 타입으로 간주
|
||||||
|
const isCodeColumn =
|
||||||
|
columnName.toLowerCase().includes("code") ||
|
||||||
|
columnName.toLowerCase().includes("status") ||
|
||||||
|
columnName.toLowerCase().includes("type") ||
|
||||||
|
columnName.toLowerCase().includes("grade") ||
|
||||||
|
columnName.toLowerCase().includes("level");
|
||||||
|
|
||||||
|
return {
|
||||||
|
columnName: columnName,
|
||||||
|
displayName: col.column_comment || col.displayName || columnName,
|
||||||
|
dataType: dataType,
|
||||||
|
dbType: dataType,
|
||||||
|
webType: isCodeColumn ? "code" : mapDataTypeToWebType(dataType),
|
||||||
|
isNullable: col.is_nullable === "YES" || col.isNullable === true,
|
||||||
|
columnDefault: col.column_default || col.columnDefault,
|
||||||
|
description: col.column_comment || col.description,
|
||||||
|
// 코드 타입인 경우 카테고리 추론
|
||||||
|
codeCategory: isCodeColumn ? inferCodeCategory(columnName) : undefined,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: rawResult;
|
||||||
|
|
||||||
|
console.log("📊 외부 DB 최종 결과:", {
|
||||||
|
rawType: typeof rawResult,
|
||||||
|
rawIsArray: Array.isArray(rawResult),
|
||||||
|
finalType: typeof result,
|
||||||
|
finalIsArray: Array.isArray(result),
|
||||||
|
length: Array.isArray(result) ? result.length : "N/A",
|
||||||
|
sample: Array.isArray(result) ? result.slice(0, 1) : result,
|
||||||
|
sampleOriginal: Array.isArray(rawResult) ? rawResult.slice(0, 1) : rawResult,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 컬럼 정보 조회 실패:", error);
|
||||||
|
|
||||||
|
// 개발 환경에서 Mock 데이터 반환
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
console.warn("🔄 개발 환경: Mock 컬럼 데이터 사용");
|
||||||
|
const mockResult = getMockColumnsForTable(tableName);
|
||||||
|
console.log("📊 Mock 데이터 반환:", {
|
||||||
|
type: typeof mockResult,
|
||||||
|
isArray: Array.isArray(mockResult),
|
||||||
|
length: mockResult.length,
|
||||||
|
sample: mockResult.slice(0, 1),
|
||||||
|
});
|
||||||
|
return mockResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock 컬럼 데이터 (개발/테스트용)
|
||||||
|
*/
|
||||||
|
const getMockColumnsForTable = (tableName: string): ColumnInfo[] => {
|
||||||
|
const baseColumns: ColumnInfo[] = [
|
||||||
|
{
|
||||||
|
columnName: "id",
|
||||||
|
displayName: "ID",
|
||||||
|
dataType: "NUMBER",
|
||||||
|
webType: "number",
|
||||||
|
isNullable: false,
|
||||||
|
isPrimaryKey: true,
|
||||||
|
columnComment: "고유 식별자",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
columnName: "name",
|
||||||
|
displayName: "이름",
|
||||||
|
dataType: "VARCHAR",
|
||||||
|
webType: "text",
|
||||||
|
isNullable: false,
|
||||||
|
isPrimaryKey: false,
|
||||||
|
columnComment: "이름",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
columnName: "status",
|
||||||
|
displayName: "상태",
|
||||||
|
dataType: "VARCHAR",
|
||||||
|
webType: "code",
|
||||||
|
isNullable: true,
|
||||||
|
isPrimaryKey: false,
|
||||||
|
columnComment: "상태 코드",
|
||||||
|
codeCategory: "STATUS",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
columnName: "created_date",
|
||||||
|
displayName: "생성일시",
|
||||||
|
dataType: "TIMESTAMP",
|
||||||
|
webType: "datetime",
|
||||||
|
isNullable: true,
|
||||||
|
isPrimaryKey: false,
|
||||||
|
columnComment: "생성일시",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
columnName: "updated_date",
|
||||||
|
displayName: "수정일시",
|
||||||
|
dataType: "TIMESTAMP",
|
||||||
|
webType: "datetime",
|
||||||
|
isNullable: true,
|
||||||
|
isPrimaryKey: false,
|
||||||
|
columnComment: "수정일시",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 테이블명에 따라 추가 컬럼 포함
|
||||||
|
if (tableName.toLowerCase().includes("user")) {
|
||||||
|
baseColumns.push({
|
||||||
|
columnName: "email",
|
||||||
|
displayName: "이메일",
|
||||||
|
dataType: "VARCHAR",
|
||||||
|
webType: "email",
|
||||||
|
isNullable: true,
|
||||||
|
isPrimaryKey: false,
|
||||||
|
columnComment: "이메일 주소",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tableName.toLowerCase().includes("product")) {
|
||||||
|
baseColumns.push({
|
||||||
|
columnName: "price",
|
||||||
|
displayName: "가격",
|
||||||
|
dataType: "DECIMAL",
|
||||||
|
webType: "decimal",
|
||||||
|
isNullable: true,
|
||||||
|
isPrimaryKey: false,
|
||||||
|
columnComment: "상품 가격",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseColumns;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// 다중 커넥션 관련 타입 정의
|
||||||
|
|
||||||
|
export interface Connection {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
database: string;
|
||||||
|
username: string;
|
||||||
|
isActive: boolean;
|
||||||
|
companyCode?: string;
|
||||||
|
createdDate?: Date;
|
||||||
|
updatedDate?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableInfo {
|
||||||
|
tableName: string;
|
||||||
|
displayName?: string;
|
||||||
|
columnCount?: number;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColumnInfo {
|
||||||
|
columnName: string;
|
||||||
|
displayName?: string;
|
||||||
|
dataType: string;
|
||||||
|
webType?: string;
|
||||||
|
isRequired?: boolean;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
Reference in New Issue
Block a user