feat: 노드 기반 데이터 플로우 시스템 구현
- 노드 에디터 UI 구현 (React Flow 기반) - TableSource, DataTransform, INSERT, UPDATE, DELETE, UPSERT 노드 - 드래그앤드롭 노드 추가 및 연결 - 속성 패널을 통한 노드 설정 - 실시간 필드 라벨 표시 (column_labels 테이블 연동) - 데이터 변환 노드 (DataTransform) 기능 - EXPLODE: 구분자로 1개 행 → 여러 행 확장 - UPPERCASE, LOWERCASE, TRIM, CONCAT, SPLIT, REPLACE 등 12가지 변환 타입 - In-place 변환 지원 (타겟 필드 생략 시 소스 필드 덮어쓰기) - 변환된 필드가 하위 액션 노드에 자동 전달 - 노드 플로우 실행 엔진 - 위상 정렬을 통한 노드 실행 순서 결정 - 레벨별 병렬 실행 (Promise.allSettled) - 부분 실패 허용 (한 노드 실패 시 연결된 하위 노드만 스킵) - 트랜잭션 기반 안전한 데이터 처리 - UPSERT 액션 로직 구현 - DB 제약 조건 없이 SELECT → UPDATE or INSERT 방식 - 복합 충돌 키 지원 (예: sales_no + product_name) - 파라미터 인덱스 정확한 매핑 - 데이터 소스 자동 감지 - 테이블 선택 데이터 (selectedRowsData) 자동 주입 - 폼 입력 데이터 (formData) 자동 주입 - TableSource 노드가 외부 데이터 우선 사용 - 버튼 컴포넌트 통합 - 기존 관계 실행 + 새 노드 플로우 실행 하이브리드 지원 - 노드 플로우 선택 UI 추가 - API 클라이언트 통합 (Axios) - 개발 문서 작성 - 노드 기반 제어 시스템 개선 계획 - 노드 연결 규칙 설계 - 노드 실행 엔진 설계 - 노드 구조 개선안 - 버튼 통합 분석
This commit is contained in:
parent
db25b0435f
commit
0743786f9b
|
|
@ -0,0 +1,237 @@
|
|||
/**
|
||||
* 노드 기반 데이터 플로우 API
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from "express";
|
||||
import { query, queryOne } from "../../database/db";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { NodeFlowExecutionService } from "../../services/nodeFlowExecutionService";
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* 플로우 목록 조회
|
||||
*/
|
||||
router.get("/", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const flows = await query(
|
||||
`
|
||||
SELECT
|
||||
flow_id as "flowId",
|
||||
flow_name as "flowName",
|
||||
flow_description as "flowDescription",
|
||||
created_at as "createdAt",
|
||||
updated_at as "updatedAt"
|
||||
FROM node_flows
|
||||
ORDER BY updated_at DESC
|
||||
`,
|
||||
[]
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: flows,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("플로우 목록 조회 실패:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "플로우 목록을 조회하지 못했습니다.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 플로우 상세 조회
|
||||
*/
|
||||
router.get("/:flowId", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { flowId } = req.params;
|
||||
|
||||
const flow = await queryOne(
|
||||
`
|
||||
SELECT
|
||||
flow_id as "flowId",
|
||||
flow_name as "flowName",
|
||||
flow_description as "flowDescription",
|
||||
flow_data as "flowData",
|
||||
created_at as "createdAt",
|
||||
updated_at as "updatedAt"
|
||||
FROM node_flows
|
||||
WHERE flow_id = $1
|
||||
`,
|
||||
[flowId]
|
||||
);
|
||||
|
||||
if (!flow) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "플로우를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: flow,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("플로우 조회 실패:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "플로우를 조회하지 못했습니다.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 플로우 저장 (신규)
|
||||
*/
|
||||
router.post("/", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { flowName, flowDescription, flowData } = req.body;
|
||||
|
||||
if (!flowName || !flowData) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "플로우 이름과 데이터는 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await queryOne(
|
||||
`
|
||||
INSERT INTO node_flows (flow_name, flow_description, flow_data)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING flow_id as "flowId"
|
||||
`,
|
||||
[flowName, flowDescription || "", flowData]
|
||||
);
|
||||
|
||||
logger.info(`플로우 저장 성공: ${result.flowId}`);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "플로우가 저장되었습니다.",
|
||||
data: {
|
||||
flowId: result.flowId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("플로우 저장 실패:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "플로우를 저장하지 못했습니다.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 플로우 수정
|
||||
*/
|
||||
router.put("/", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { flowId, flowName, flowDescription, flowData } = req.body;
|
||||
|
||||
if (!flowId || !flowName || !flowData) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "플로우 ID, 이름, 데이터는 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
await query(
|
||||
`
|
||||
UPDATE node_flows
|
||||
SET flow_name = $1,
|
||||
flow_description = $2,
|
||||
flow_data = $3,
|
||||
updated_at = NOW()
|
||||
WHERE flow_id = $4
|
||||
`,
|
||||
[flowName, flowDescription || "", flowData, flowId]
|
||||
);
|
||||
|
||||
logger.info(`플로우 수정 성공: ${flowId}`);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "플로우가 수정되었습니다.",
|
||||
data: {
|
||||
flowId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("플로우 수정 실패:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "플로우를 수정하지 못했습니다.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 플로우 삭제
|
||||
*/
|
||||
router.delete("/:flowId", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { flowId } = req.params;
|
||||
|
||||
await query(
|
||||
`
|
||||
DELETE FROM node_flows
|
||||
WHERE flow_id = $1
|
||||
`,
|
||||
[flowId]
|
||||
);
|
||||
|
||||
logger.info(`플로우 삭제 성공: ${flowId}`);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "플로우가 삭제되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("플로우 삭제 실패:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "플로우를 삭제하지 못했습니다.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 플로우 실행
|
||||
* POST /api/dataflow/node-flows/:flowId/execute
|
||||
*/
|
||||
router.post("/:flowId/execute", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { flowId } = req.params;
|
||||
const contextData = req.body;
|
||||
|
||||
logger.info(`🚀 플로우 실행 요청: flowId=${flowId}`, {
|
||||
contextDataKeys: Object.keys(contextData),
|
||||
});
|
||||
|
||||
// 플로우 실행
|
||||
const result = await NodeFlowExecutionService.executeFlow(
|
||||
parseInt(flowId, 10),
|
||||
contextData
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: result.success,
|
||||
message: result.message,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("플로우 실행 실패:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "플로우 실행 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -21,6 +21,7 @@ import {
|
|||
testConditionalConnection,
|
||||
executeConditionalActions,
|
||||
} from "../controllers/conditionalConnectionController";
|
||||
import nodeFlowsRouter from "./dataflow/node-flows";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
|
|
@ -146,4 +147,10 @@ router.post("/diagrams/:diagramId/test-conditions", testConditionalConnection);
|
|||
*/
|
||||
router.post("/diagrams/:diagramId/execute-actions", executeConditionalActions);
|
||||
|
||||
/**
|
||||
* 노드 기반 플로우 관리
|
||||
* /api/dataflow/node-flows/*
|
||||
*/
|
||||
router.use("/node-flows", nodeFlowsRouter);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,481 @@
|
|||
# 노드 구조 개선안 - FROM/TO 테이블 명확화
|
||||
|
||||
**작성일**: 2025-01-02
|
||||
**버전**: 1.0
|
||||
**상태**: 🤔 검토 중
|
||||
|
||||
---
|
||||
|
||||
## 📋 문제 인식
|
||||
|
||||
### 현재 설계의 한계
|
||||
|
||||
```
|
||||
현재 플로우:
|
||||
TableSource(user_info) → FieldMapping → InsertAction(targetTable: "orders")
|
||||
```
|
||||
|
||||
**문제점**:
|
||||
|
||||
1. 타겟 테이블(orders)이 노드로 표현되지 않음
|
||||
2. InsertAction의 속성으로만 존재 → 시각적으로 불명확
|
||||
3. FROM(user_info)과 TO(orders)의 관계가 직관적이지 않음
|
||||
4. 타겟 테이블의 스키마 정보를 참조하기 어려움
|
||||
|
||||
---
|
||||
|
||||
## 💡 개선 방안
|
||||
|
||||
### 옵션 1: TableTarget 노드 추가 (권장 ⭐)
|
||||
|
||||
**새로운 플로우**:
|
||||
|
||||
```
|
||||
TableSource(user_info) → FieldMapping → TableTarget(orders) → InsertAction
|
||||
```
|
||||
|
||||
**노드 추가**:
|
||||
|
||||
- `TableTarget` - 타겟 테이블을 명시적으로 표현
|
||||
|
||||
**장점**:
|
||||
|
||||
- ✅ FROM/TO가 시각적으로 명확
|
||||
- ✅ 타겟 테이블 스키마를 미리 로드 가능
|
||||
- ✅ FieldMapping에서 타겟 필드 자동 완성 가능
|
||||
- ✅ 데이터 흐름이 직관적
|
||||
|
||||
**단점**:
|
||||
|
||||
- ⚠️ 노드 개수 증가 (복잡도 증가)
|
||||
- ⚠️ 기존 설계와 호환성 문제
|
||||
|
||||
---
|
||||
|
||||
### 옵션 2: Action 노드에 Target 속성 유지 (현재 방식)
|
||||
|
||||
**현재 플로우 유지**:
|
||||
|
||||
```
|
||||
TableSource(user_info) → FieldMapping → InsertAction(targetTable: "orders")
|
||||
```
|
||||
|
||||
**개선 방법**:
|
||||
|
||||
- Action 노드에서 타겟 테이블을 더 명확히 표시
|
||||
- 노드 UI에 타겟 테이블명을 크게 표시
|
||||
- Properties Panel에서 타겟 테이블 선택 시 스키마 정보 제공
|
||||
|
||||
**장점**:
|
||||
|
||||
- ✅ 기존 설계 유지 (구현 완료된 상태)
|
||||
- ✅ 노드 개수가 적음 (간결함)
|
||||
- ✅ 빠른 플로우 구성 가능
|
||||
|
||||
**단점**:
|
||||
|
||||
- ❌ 시각적으로 FROM/TO 관계가 불명확
|
||||
- ❌ FieldMapping 단계에서 타겟 필드 정보 접근이 어려움
|
||||
|
||||
---
|
||||
|
||||
### 옵션 3: 가상 노드 자동 표시 (신규 제안 ⭐⭐)
|
||||
|
||||
**개념**:
|
||||
Action 노드에서 targetTable 속성을 설정하면, **시각적으로만** 타겟 테이블 노드를 자동 생성
|
||||
|
||||
**실제 플로우 (저장되는 구조)**:
|
||||
|
||||
```
|
||||
TableSource(user_info) → FieldMapping → InsertAction(targetTable: "orders")
|
||||
```
|
||||
|
||||
**시각적 표시 (화면에 보이는 모습)**:
|
||||
|
||||
```
|
||||
TableSource(user_info)
|
||||
→ FieldMapping
|
||||
→ InsertAction(targetTable: "orders")
|
||||
→ 👻 orders (가상 노드, 자동 생성)
|
||||
```
|
||||
|
||||
**특징**:
|
||||
|
||||
- 가상 노드는 선택/이동/삭제 불가능
|
||||
- 반투명하게 표시하여 가상임을 명확히 표시
|
||||
- Action 노드의 targetTable 속성 변경 시 자동 업데이트
|
||||
- 저장 시에는 가상 노드 제외
|
||||
|
||||
**장점**:
|
||||
|
||||
- ✅ 사용자는 기존대로 사용 (노드 추가 불필요)
|
||||
- ✅ 시각적으로 FROM/TO 관계 명확
|
||||
- ✅ 기존 설계 100% 유지
|
||||
- ✅ 구현 복잡도 낮음
|
||||
- ✅ 기존 플로우와 완벽 호환
|
||||
|
||||
**단점**:
|
||||
|
||||
- ⚠️ 가상 노드의 상호작용 제한 필요
|
||||
- ⚠️ "왜 클릭이 안 되지?" 혼란 가능성
|
||||
- ⚠️ 가상 노드 렌더링 로직 추가
|
||||
|
||||
---
|
||||
|
||||
### 옵션 4: 하이브리드 방식
|
||||
|
||||
**조건부 사용**:
|
||||
|
||||
```
|
||||
// 단순 케이스: TableTarget 생략
|
||||
TableSource → FieldMapping → InsertAction(targetTable 지정)
|
||||
|
||||
// 복잡한 케이스: TableTarget 사용
|
||||
TableSource → FieldMapping → TableTarget → InsertAction
|
||||
```
|
||||
|
||||
**장점**:
|
||||
|
||||
- ✅ 유연성 제공
|
||||
- ✅ 단순/복잡한 케이스 모두 대응
|
||||
|
||||
**단점**:
|
||||
|
||||
- ❌ 사용자 혼란 가능성
|
||||
- ❌ 검증 로직 복잡
|
||||
|
||||
---
|
||||
|
||||
## 🎯 권장 방안 비교
|
||||
|
||||
### 옵션 재평가
|
||||
|
||||
| 항목 | 옵션 1<br/>(TableTarget) | 옵션 2<br/>(현재 방식) | 옵션 3<br/>(가상 노드) ⭐ |
|
||||
| ----------------- | ------------------------ | ---------------------- | ------------------------- |
|
||||
| **시각적 명확성** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||
| **구현 복잡도** | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐ |
|
||||
| **사용자 편의성** | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||
| **기존 호환성** | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||
| **자동 완성** | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐ |
|
||||
| **유지보수성** | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ |
|
||||
| **학습 곡선** | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||
|
||||
### 최종 권장: **옵션 3 (가상 노드 자동 표시)** ⭐⭐
|
||||
|
||||
**선택 이유**:
|
||||
|
||||
1. ✅ **최고의 시각적 명확성** - FROM/TO 관계가 한눈에 보임
|
||||
2. ✅ **사용자 편의성** - 기존 방식 그대로, 노드 추가 불필요
|
||||
3. ✅ **완벽한 호환성** - 기존 플로우 수정 불필요
|
||||
4. ✅ **낮은 학습 곡선** - 새로운 노드 타입 학습 불필요
|
||||
5. ✅ **적절한 구현 복잡도** - React Flow의 커스텀 렌더링 활용
|
||||
|
||||
**구현 방식**:
|
||||
|
||||
```typescript
|
||||
// Action 노드가 있으면 자동으로 가상 타겟 노드 생성
|
||||
function generateVirtualTargetNodes(nodes: FlowNode[]): VirtualNode[] {
|
||||
return nodes
|
||||
.filter((node) => isActionNode(node.type) && node.data.targetTable)
|
||||
.map((actionNode) => ({
|
||||
id: `virtual-target-${actionNode.id}`,
|
||||
type: "virtualTarget",
|
||||
position: {
|
||||
x: actionNode.position.x,
|
||||
y: actionNode.position.y + 150,
|
||||
},
|
||||
data: {
|
||||
tableName: actionNode.data.targetTable,
|
||||
sourceActionId: actionNode.id,
|
||||
isVirtual: true,
|
||||
},
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 대안: 옵션 1 (TableTarget 추가)
|
||||
|
||||
### 새로운 노드 타입 추가
|
||||
|
||||
#### TableTarget 노드
|
||||
|
||||
**타입**: `tableTarget`
|
||||
|
||||
**데이터 구조**:
|
||||
|
||||
```typescript
|
||||
interface TableTargetNodeData {
|
||||
tableName: string; // 타겟 테이블명
|
||||
schema?: string; // 스키마 (선택)
|
||||
columns?: Array<{
|
||||
// 타겟 컬럼 정보
|
||||
name: string;
|
||||
type: string;
|
||||
nullable: boolean;
|
||||
primaryKey: boolean;
|
||||
}>;
|
||||
displayName?: string;
|
||||
}
|
||||
```
|
||||
|
||||
**특징**:
|
||||
|
||||
- 입력: FieldMapping, DataTransform 등에서 받음
|
||||
- 출력: Action 노드로 전달
|
||||
- 타겟 테이블 스키마를 미리 로드하여 검증 가능
|
||||
|
||||
**시각적 표현**:
|
||||
|
||||
```
|
||||
┌────────────────────┐
|
||||
│ 📊 Table Target │
|
||||
├────────────────────┤
|
||||
│ orders │
|
||||
│ schema: public │
|
||||
├────────────────────┤
|
||||
│ 컬럼: │
|
||||
│ • order_id (PK) │
|
||||
│ • customer_id │
|
||||
│ • order_date │
|
||||
│ • total_amount │
|
||||
└────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 개선된 연결 규칙
|
||||
|
||||
#### TableTarget 추가 시 연결 규칙
|
||||
|
||||
**허용되는 연결**:
|
||||
|
||||
```
|
||||
✅ FieldMapping → TableTarget
|
||||
✅ DataTransform → TableTarget
|
||||
✅ Condition → TableTarget
|
||||
✅ TableTarget → InsertAction
|
||||
✅ TableTarget → UpdateAction
|
||||
✅ TableTarget → UpsertAction
|
||||
```
|
||||
|
||||
**금지되는 연결**:
|
||||
|
||||
```
|
||||
❌ TableSource → TableTarget (직접 연결 불가)
|
||||
❌ TableTarget → DeleteAction (DELETE는 타겟 불필요)
|
||||
❌ TableTarget → TableTarget
|
||||
```
|
||||
|
||||
**새로운 검증 규칙**:
|
||||
|
||||
1. Action 노드는 TableTarget 또는 targetTable 속성 중 하나 필수
|
||||
2. TableTarget이 있으면 Action의 targetTable 속성 무시
|
||||
3. FieldMapping 이후에 TableTarget이 오면 자동 필드 매칭 제안
|
||||
|
||||
---
|
||||
|
||||
### 실제 사용 예시
|
||||
|
||||
#### 예시 1: 단순 데이터 복사
|
||||
|
||||
**기존 방식**:
|
||||
|
||||
```
|
||||
TableSource(user_info)
|
||||
→ FieldMapping(user_id → customer_id, user_name → name)
|
||||
→ InsertAction(targetTable: "customers")
|
||||
```
|
||||
|
||||
**개선 방식**:
|
||||
|
||||
```
|
||||
TableSource(user_info)
|
||||
→ FieldMapping(user_id → customer_id)
|
||||
→ TableTarget(customers)
|
||||
→ InsertAction
|
||||
```
|
||||
|
||||
**장점**:
|
||||
|
||||
- customers 테이블 스키마를 FieldMapping에서 참조 가능
|
||||
- 필드 자동 완성 제공
|
||||
|
||||
---
|
||||
|
||||
#### 예시 2: 조건부 데이터 처리
|
||||
|
||||
**개선 방식**:
|
||||
|
||||
```
|
||||
TableSource(user_info)
|
||||
→ Condition(age >= 18)
|
||||
├─ TRUE → TableTarget(adult_users) → InsertAction
|
||||
└─ FALSE → TableTarget(minor_users) → InsertAction
|
||||
```
|
||||
|
||||
**장점**:
|
||||
|
||||
- TRUE/FALSE 분기마다 다른 타겟 테이블 명확히 표시
|
||||
|
||||
---
|
||||
|
||||
#### 예시 3: 멀티 소스 + 단일 타겟
|
||||
|
||||
**개선 방식**:
|
||||
|
||||
```
|
||||
┌─ TableSource(users) ────┐
|
||||
│ ↓
|
||||
└─ ExternalDB(orders) ─→ FieldMapping → TableTarget(user_orders) → InsertAction
|
||||
```
|
||||
|
||||
**장점**:
|
||||
|
||||
- 여러 소스에서 데이터를 받아 하나의 타겟으로 통합
|
||||
- 타겟 테이블이 시각적으로 명확
|
||||
|
||||
---
|
||||
|
||||
## 🔧 구현 계획
|
||||
|
||||
### Phase 1: TableTarget 노드 구현
|
||||
|
||||
**작업 항목**:
|
||||
|
||||
1. ✅ `TableTargetNodeData` 인터페이스 정의
|
||||
2. ✅ `TableTargetNode.tsx` 컴포넌트 생성
|
||||
3. ✅ `TableTargetProperties.tsx` 속성 패널 생성
|
||||
4. ✅ Node Palette에 추가
|
||||
5. ✅ FlowEditor에 등록
|
||||
|
||||
**예상 시간**: 2시간
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 연결 규칙 업데이트
|
||||
|
||||
**작업 항목**:
|
||||
|
||||
1. ✅ `validateConnection`에 TableTarget 규칙 추가
|
||||
2. ✅ Action 노드가 TableTarget 입력을 받도록 수정
|
||||
3. ✅ 검증 로직 업데이트
|
||||
|
||||
**예상 시간**: 1시간
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: 자동 필드 매핑 개선
|
||||
|
||||
**작업 항목**:
|
||||
|
||||
1. ✅ TableTarget이 연결되면 타겟 스키마 자동 로드
|
||||
2. ✅ FieldMapping에서 타겟 필드 자동 완성 제공
|
||||
3. ✅ 필드 타입 호환성 검증
|
||||
|
||||
**예상 시간**: 2시간
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: 기존 플로우 마이그레이션
|
||||
|
||||
**작업 항목**:
|
||||
|
||||
1. ✅ 기존 InsertAction의 targetTable을 TableTarget으로 변환
|
||||
2. ✅ 자동 마이그레이션 스크립트 작성
|
||||
3. ✅ 호환성 유지 모드 제공
|
||||
|
||||
**예상 시간**: 2시간
|
||||
|
||||
---
|
||||
|
||||
## 🤔 고려사항
|
||||
|
||||
### 1. 기존 플로우와의 호환성
|
||||
|
||||
**문제**: 이미 저장된 플로우는 TableTarget 없이 구성됨
|
||||
|
||||
**해결 방안**:
|
||||
|
||||
- **옵션 A**: 자동 마이그레이션
|
||||
|
||||
- 플로우 로드 시 InsertAction의 targetTable을 TableTarget 노드로 변환
|
||||
- 기존 데이터는 보존
|
||||
|
||||
- **옵션 B**: 호환성 모드
|
||||
- TableTarget 없이도 동작하도록 유지
|
||||
- 새 플로우만 TableTarget 사용 권장
|
||||
|
||||
**권장**: 옵션 B (호환성 모드)
|
||||
|
||||
---
|
||||
|
||||
### 2. 사용자 경험
|
||||
|
||||
**우려**: 노드가 하나 더 추가되어 복잡해짐
|
||||
|
||||
**완화 방안**:
|
||||
|
||||
- 템플릿 제공: "TableSource → FieldMapping → TableTarget → InsertAction" 세트를 템플릿으로 제공
|
||||
- 자동 생성: InsertAction 생성 시 TableTarget 자동 생성 옵션
|
||||
- 가이드: 처음 사용자를 위한 튜토리얼
|
||||
|
||||
---
|
||||
|
||||
### 3. 성능
|
||||
|
||||
**우려**: TableTarget이 스키마를 로드하면 성능 저하 가능성
|
||||
|
||||
**완화 방안**:
|
||||
|
||||
- 캐싱: 한 번 로드한 스키마는 캐싱
|
||||
- 지연 로딩: 필요할 때만 스키마 로드
|
||||
- 백그라운드 로딩: 비동기로 스키마 로드
|
||||
|
||||
---
|
||||
|
||||
## 📊 비교 분석
|
||||
|
||||
| 항목 | 옵션 1 (TableTarget) | 옵션 2 (현재 방식) |
|
||||
| ------------------- | -------------------- | ------------------ |
|
||||
| **시각적 명확성** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
|
||||
| **구현 복잡도** | ⭐⭐⭐⭐ | ⭐⭐ |
|
||||
| **사용자 학습곡선** | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||
| **자동 완성 지원** | ⭐⭐⭐⭐⭐ | ⭐⭐ |
|
||||
| **유지보수성** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
|
||||
| **기존 호환성** | ⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 결론
|
||||
|
||||
### 권장 사항: **옵션 1 (TableTarget 추가)**
|
||||
|
||||
**이유**:
|
||||
|
||||
1. ✅ 데이터 흐름이 시각적으로 명확
|
||||
2. ✅ 스키마 기반 자동 완성 가능
|
||||
3. ✅ 향후 확장성 우수
|
||||
4. ✅ 복잡한 데이터 흐름에서 특히 유용
|
||||
|
||||
**단계적 도입**:
|
||||
|
||||
- Phase 1: TableTarget 노드 추가 (선택 사항)
|
||||
- Phase 2: 기존 방식과 공존
|
||||
- Phase 3: 사용자 피드백 수집
|
||||
- Phase 4: 장기적으로 TableTarget 방식 권장
|
||||
|
||||
---
|
||||
|
||||
## 📝 다음 단계
|
||||
|
||||
1. **의사 결정**: 옵션 1 vs 옵션 2 선택
|
||||
2. **프로토타입**: TableTarget 노드 간단히 구현
|
||||
3. **테스트**: 실제 사용 시나리오로 검증
|
||||
4. **문서화**: 사용 가이드 작성
|
||||
5. **배포**: 단계적 릴리스
|
||||
|
||||
---
|
||||
|
||||
**피드백 환영**: 이 설계에 대한 의견을 주시면 개선하겠습니다! 💬
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,939 @@
|
|||
# 노드 시스템 - 버튼 통합 호환성 분석
|
||||
|
||||
**작성일**: 2025-01-02
|
||||
**버전**: 1.0
|
||||
**상태**: 🔍 분석 완료
|
||||
|
||||
---
|
||||
|
||||
## 📋 목차
|
||||
|
||||
1. [개요](#개요)
|
||||
2. [현재 시스템 분석](#현재-시스템-분석)
|
||||
3. [호환성 분석](#호환성-분석)
|
||||
4. [통합 전략](#통합-전략)
|
||||
5. [마이그레이션 계획](#마이그레이션-계획)
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
### 목적
|
||||
|
||||
화면관리의 버튼 컴포넌트에 할당된 기존 제어 시스템을 새로운 노드 기반 제어 시스템으로 전환하기 위한 호환성 분석
|
||||
|
||||
### 비교 대상
|
||||
|
||||
- **현재**: `relationshipId` 기반 제어 시스템
|
||||
- **신규**: `flowId` 기반 노드 제어 시스템
|
||||
|
||||
---
|
||||
|
||||
## 현재 시스템 분석
|
||||
|
||||
### 1. 데이터 구조
|
||||
|
||||
#### ButtonDataflowConfig
|
||||
|
||||
```typescript
|
||||
interface ButtonDataflowConfig {
|
||||
controlMode: "relationship" | "none";
|
||||
|
||||
relationshipConfig?: {
|
||||
relationshipId: string; // 🔑 핵심: 관계 ID
|
||||
relationshipName: string;
|
||||
executionTiming: "before" | "after" | "replace";
|
||||
contextData?: Record<string, any>;
|
||||
};
|
||||
|
||||
controlDataSource?: "form" | "table-selection" | "both";
|
||||
executionOptions?: ExecutionOptions;
|
||||
}
|
||||
```
|
||||
|
||||
#### 관계 데이터 구조
|
||||
|
||||
```typescript
|
||||
{
|
||||
relationshipId: "rel-123",
|
||||
conditions: [
|
||||
{
|
||||
field: "status",
|
||||
operator: "equals",
|
||||
value: "active"
|
||||
}
|
||||
],
|
||||
actionGroups: [
|
||||
{
|
||||
name: "메인 액션",
|
||||
actions: [
|
||||
{
|
||||
type: "database",
|
||||
operation: "INSERT",
|
||||
tableName: "users",
|
||||
fields: [...]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 실행 흐름
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 1. 버튼 클릭 │
|
||||
│ OptimizedButtonComponent.tsx │
|
||||
└─────────────┬───────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ 2. executeButtonAction() │
|
||||
│ ImprovedButtonActionExecutor.ts │
|
||||
│ - executionPlan 생성 │
|
||||
│ - before/after/replace 구분 │
|
||||
└─────────────┬───────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ 3. executeControls() │
|
||||
│ - relationshipId로 관계 조회 │
|
||||
│ - 조건 검증 │
|
||||
└─────────────┬───────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ 4. evaluateConditions() │
|
||||
│ - formData 검증 │
|
||||
│ - selectedRowsData 검증 │
|
||||
└─────────────┬───────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ 5. executeDataAction() │
|
||||
│ - INSERT/UPDATE/DELETE 실행 │
|
||||
│ - 순차적 액션 실행 │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 데이터 전달 방식
|
||||
|
||||
#### 입력 데이터
|
||||
|
||||
```typescript
|
||||
{
|
||||
formData: {
|
||||
name: "김철수",
|
||||
email: "test@example.com",
|
||||
status: "active"
|
||||
},
|
||||
selectedRowsData: [
|
||||
{ id: 1, name: "이영희" },
|
||||
{ id: 2, name: "박민수" }
|
||||
],
|
||||
context: {
|
||||
buttonId: "btn-1",
|
||||
screenId: 123,
|
||||
companyCode: "COMPANY_A",
|
||||
userId: "user-1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 액션 실행 시
|
||||
|
||||
```typescript
|
||||
// 각 액션에 전체 데이터 전달
|
||||
executeDataAction(action, {
|
||||
formData,
|
||||
selectedRowsData,
|
||||
context,
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 새로운 노드 시스템 분석
|
||||
|
||||
### 1. 데이터 구조
|
||||
|
||||
#### FlowData
|
||||
|
||||
```typescript
|
||||
interface FlowData {
|
||||
flowId: number;
|
||||
flowName: string;
|
||||
flowDescription: string;
|
||||
nodes: FlowNode[]; // 🔑 핵심: 노드 배열
|
||||
edges: FlowEdge[]; // 🔑 핵심: 연결 정보
|
||||
}
|
||||
```
|
||||
|
||||
#### 노드 예시
|
||||
|
||||
```typescript
|
||||
// 소스 노드
|
||||
{
|
||||
id: "source-1",
|
||||
type: "tableSource",
|
||||
data: {
|
||||
tableName: "users",
|
||||
schema: "public",
|
||||
outputFields: [...]
|
||||
}
|
||||
}
|
||||
|
||||
// 조건 노드
|
||||
{
|
||||
id: "condition-1",
|
||||
type: "condition",
|
||||
data: {
|
||||
conditions: [{
|
||||
field: "status",
|
||||
operator: "equals",
|
||||
value: "active"
|
||||
}],
|
||||
logic: "AND"
|
||||
}
|
||||
}
|
||||
|
||||
// 액션 노드
|
||||
{
|
||||
id: "insert-1",
|
||||
type: "insertAction",
|
||||
data: {
|
||||
targetTable: "users",
|
||||
fieldMappings: [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 연결 예시
|
||||
|
||||
```typescript
|
||||
// 엣지 (노드 간 연결)
|
||||
{
|
||||
id: "edge-1",
|
||||
source: "source-1",
|
||||
target: "condition-1"
|
||||
},
|
||||
{
|
||||
id: "edge-2",
|
||||
source: "condition-1",
|
||||
target: "insert-1",
|
||||
sourceHandle: "true" // TRUE 분기
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 실행 흐름
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 1. 버튼 클릭 │
|
||||
│ FlowEditor 또는 Button Component │
|
||||
└─────────────┬───────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ 2. executeFlow() │
|
||||
│ - flowId로 플로우 조회 │
|
||||
│ - nodes + edges 로드 │
|
||||
└─────────────┬───────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ 3. topologicalSort() │
|
||||
│ - 노드 의존성 분석 │
|
||||
│ - 실행 순서 결정 │
|
||||
│ Result: [["source"], ["insert", "update"]] │
|
||||
└─────────────┬───────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ 4. executeLevel() │
|
||||
│ - 같은 레벨 노드 병렬 실행 │
|
||||
│ - Promise.allSettled 사용 │
|
||||
└─────────────┬───────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ 5. executeNode() │
|
||||
│ - 부모 노드 상태 확인 │
|
||||
│ - 실패 시 스킵 │
|
||||
└─────────────┬───────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ 6. executeActionWithTransaction() │
|
||||
│ - 독립 트랜잭션 시작 │
|
||||
│ - 액션 실행 │
|
||||
│ - 성공 시 커밋, 실패 시 롤백 │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 데이터 전달 방식
|
||||
|
||||
#### ExecutionContext
|
||||
|
||||
```typescript
|
||||
{
|
||||
sourceData: [
|
||||
{ id: 1, name: "김철수", status: "active" },
|
||||
{ id: 2, name: "이영희", status: "inactive" }
|
||||
],
|
||||
nodeResults: Map<string, NodeResult> {
|
||||
"source-1" => { status: "success", data: [...] },
|
||||
"condition-1" => { status: "success", data: true },
|
||||
"insert-1" => { status: "success", data: { insertedCount: 1 } }
|
||||
},
|
||||
executionOrder: ["source-1", "condition-1", "insert-1"]
|
||||
}
|
||||
```
|
||||
|
||||
#### 노드 실행 시
|
||||
|
||||
```typescript
|
||||
// 부모 노드 결과 전달
|
||||
const inputData = prepareInputData(node, parents, context);
|
||||
|
||||
// 부모가 하나면 부모의 결과 데이터
|
||||
// 부모가 여러 개면 모든 부모의 데이터 병합
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 호환성 분석
|
||||
|
||||
### ✅ 호환 가능한 부분
|
||||
|
||||
#### 1. 조건 검증
|
||||
|
||||
**현재**:
|
||||
|
||||
```typescript
|
||||
{
|
||||
field: "status",
|
||||
operator: "equals",
|
||||
value: "active"
|
||||
}
|
||||
```
|
||||
|
||||
**신규**:
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: "condition",
|
||||
data: {
|
||||
conditions: [
|
||||
{
|
||||
field: "status",
|
||||
operator: "equals",
|
||||
value: "active"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**결론**: ✅ **조건 구조가 거의 동일** → 마이그레이션 쉬움
|
||||
|
||||
---
|
||||
|
||||
#### 2. 액션 실행
|
||||
|
||||
**현재**:
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: "database",
|
||||
operation: "INSERT",
|
||||
tableName: "users",
|
||||
fields: [
|
||||
{ name: "name", value: "김철수" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**신규**:
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: "insertAction",
|
||||
data: {
|
||||
targetTable: "users",
|
||||
fieldMappings: [
|
||||
{ sourceField: "name", targetField: "name" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**결론**: ✅ **액션 개념이 동일** → 필드명만 변환하면 됨
|
||||
|
||||
---
|
||||
|
||||
#### 3. 데이터 소스
|
||||
|
||||
**현재**:
|
||||
|
||||
```typescript
|
||||
controlDataSource: "form" | "table-selection" | "both";
|
||||
```
|
||||
|
||||
**신규**:
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: "tableSource", // 테이블 선택 데이터
|
||||
// 또는
|
||||
type: "manualInput", // 폼 데이터
|
||||
}
|
||||
```
|
||||
|
||||
**결론**: ✅ **소스 타입 매핑 가능**
|
||||
|
||||
---
|
||||
|
||||
### ⚠️ 차이점 및 주의사항
|
||||
|
||||
#### 1. 실행 타이밍
|
||||
|
||||
**현재**:
|
||||
|
||||
```typescript
|
||||
executionTiming: "before" | "after" | "replace";
|
||||
```
|
||||
|
||||
**신규**:
|
||||
|
||||
```
|
||||
노드 그래프 자체가 실행 순서를 정의
|
||||
타이밍은 노드 연결로 표현됨
|
||||
```
|
||||
|
||||
**문제점**:
|
||||
|
||||
- `before/after` 개념이 노드에 없음
|
||||
- 버튼의 기본 액션과 제어를 어떻게 조합할지?
|
||||
|
||||
**해결 방안**:
|
||||
|
||||
```
|
||||
Option A: 버튼 액션을 노드로 표현
|
||||
Button → [Before Nodes] → [Button Action Node] → [After Nodes]
|
||||
|
||||
Option B: 실행 시점 지정
|
||||
flowConfig: {
|
||||
flowId: 123,
|
||||
timing: "before" | "after" | "replace"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 2. ActionGroups vs 병렬 실행
|
||||
|
||||
**현재**:
|
||||
|
||||
```typescript
|
||||
actionGroups: [
|
||||
{
|
||||
name: "그룹1",
|
||||
actions: [action1, action2], // 순차 실행
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
**신규**:
|
||||
|
||||
```
|
||||
소스
|
||||
↓
|
||||
├─→ INSERT (병렬)
|
||||
├─→ UPDATE (병렬)
|
||||
└─→ DELETE (병렬)
|
||||
```
|
||||
|
||||
**문제점**:
|
||||
|
||||
- 현재는 "그룹 내 순차, 그룹 간 조건부"
|
||||
- 신규는 "레벨별 병렬, 연쇄 중단"
|
||||
|
||||
**해결 방안**:
|
||||
|
||||
```
|
||||
노드 연결로 순차/병렬 표현:
|
||||
|
||||
순차: INSERT → UPDATE → DELETE
|
||||
병렬: Source → INSERT
|
||||
→ UPDATE
|
||||
→ DELETE
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 3. 데이터 전달 방식
|
||||
|
||||
**현재**:
|
||||
|
||||
```typescript
|
||||
// 모든 액션에 동일한 데이터 전달
|
||||
executeDataAction(action, {
|
||||
formData,
|
||||
selectedRowsData,
|
||||
context,
|
||||
});
|
||||
```
|
||||
|
||||
**신규**:
|
||||
|
||||
```typescript
|
||||
// 부모 노드 결과를 자식에게 전달
|
||||
const inputData = parentResult.data || sourceData;
|
||||
```
|
||||
|
||||
**문제점**:
|
||||
|
||||
- 현재는 "원본 데이터 공유"
|
||||
- 신규는 "결과 데이터 체이닝"
|
||||
|
||||
**해결 방안**:
|
||||
|
||||
```typescript
|
||||
// 버튼 실행 시 초기 데이터 설정
|
||||
context.sourceData = {
|
||||
formData,
|
||||
selectedRowsData,
|
||||
};
|
||||
|
||||
// 각 노드는 필요에 따라 선택
|
||||
- formData 사용
|
||||
- 부모 결과 사용
|
||||
- 둘 다 사용
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 4. 컨텍스트 정보
|
||||
|
||||
**현재**:
|
||||
|
||||
```typescript
|
||||
{
|
||||
buttonId: "btn-1",
|
||||
screenId: 123,
|
||||
companyCode: "COMPANY_A",
|
||||
userId: "user-1"
|
||||
}
|
||||
```
|
||||
|
||||
**신규**:
|
||||
|
||||
```typescript
|
||||
// ExecutionContext에 추가 필요
|
||||
{
|
||||
sourceData: [...],
|
||||
nodeResults: Map(),
|
||||
// 🆕 추가 필요
|
||||
buttonContext?: {
|
||||
buttonId: string,
|
||||
screenId: number,
|
||||
companyCode: string,
|
||||
userId: string
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**결론**: ✅ **컨텍스트 확장 가능**
|
||||
|
||||
---
|
||||
|
||||
## 통합 전략
|
||||
|
||||
### 전략 1: 하이브리드 방식 (권장 ⭐⭐⭐)
|
||||
|
||||
#### 개념
|
||||
|
||||
버튼 설정에서 `relationshipId` 대신 `flowId`를 저장하고, 기존 타이밍 개념 유지
|
||||
|
||||
#### 버튼 설정
|
||||
|
||||
```typescript
|
||||
interface ButtonDataflowConfig {
|
||||
controlMode: "flow"; // 🆕 신규 모드
|
||||
|
||||
flowConfig?: {
|
||||
flowId: number; // 🔑 노드 플로우 ID
|
||||
flowName: string;
|
||||
executionTiming: "before" | "after" | "replace"; // 기존 유지
|
||||
contextData?: Record<string, any>;
|
||||
};
|
||||
|
||||
controlDataSource?: "form" | "table-selection" | "both";
|
||||
}
|
||||
```
|
||||
|
||||
#### 실행 로직
|
||||
|
||||
```typescript
|
||||
async function executeButtonWithFlow(
|
||||
buttonConfig: ButtonDataflowConfig,
|
||||
formData: Record<string, any>,
|
||||
context: ButtonExecutionContext
|
||||
) {
|
||||
const { flowConfig } = buttonConfig;
|
||||
|
||||
// 1. 플로우 조회
|
||||
const flow = await getNodeFlow(flowConfig.flowId);
|
||||
|
||||
// 2. 초기 데이터 준비
|
||||
const executionContext: ExecutionContext = {
|
||||
sourceData: prepareSourceData(formData, context),
|
||||
nodeResults: new Map(),
|
||||
executionOrder: [],
|
||||
buttonContext: {
|
||||
// 🆕 버튼 컨텍스트 추가
|
||||
buttonId: context.buttonId,
|
||||
screenId: context.screenId,
|
||||
companyCode: context.companyCode,
|
||||
userId: context.userId,
|
||||
},
|
||||
};
|
||||
|
||||
// 3. 타이밍에 따라 실행
|
||||
switch (flowConfig.executionTiming) {
|
||||
case "before":
|
||||
await executeFlow(flow, executionContext);
|
||||
await executeOriginalButtonAction(buttonConfig, context);
|
||||
break;
|
||||
|
||||
case "after":
|
||||
await executeOriginalButtonAction(buttonConfig, context);
|
||||
await executeFlow(flow, executionContext);
|
||||
break;
|
||||
|
||||
case "replace":
|
||||
await executeFlow(flow, executionContext);
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 소스 데이터 준비
|
||||
|
||||
```typescript
|
||||
function prepareSourceData(
|
||||
formData: Record<string, any>,
|
||||
context: ButtonExecutionContext
|
||||
): any[] {
|
||||
const { controlDataSource, selectedRowsData } = context;
|
||||
|
||||
switch (controlDataSource) {
|
||||
case "form":
|
||||
return [formData]; // 폼 데이터를 배열로
|
||||
|
||||
case "table-selection":
|
||||
return selectedRowsData || []; // 테이블 선택 데이터
|
||||
|
||||
case "both":
|
||||
return [
|
||||
{ source: "form", data: formData },
|
||||
{ source: "table", data: selectedRowsData },
|
||||
];
|
||||
|
||||
default:
|
||||
return [formData];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 전략 2: 완전 전환 방식
|
||||
|
||||
#### 개념
|
||||
|
||||
버튼 액션 자체를 노드로 표현 (버튼 = 플로우 트리거)
|
||||
|
||||
#### 플로우 구조
|
||||
|
||||
```
|
||||
ManualInput (formData)
|
||||
↓
|
||||
Condition (status == "active")
|
||||
↓
|
||||
┌─┴─┐
|
||||
TRUE FALSE
|
||||
↓ ↓
|
||||
INSERT CANCEL
|
||||
↓
|
||||
ButtonAction (원래 버튼 액션)
|
||||
```
|
||||
|
||||
#### 장점
|
||||
|
||||
- ✅ 시스템 단순화 (노드만 존재)
|
||||
- ✅ 시각적으로 명확
|
||||
- ✅ 유연한 워크플로우
|
||||
|
||||
#### 단점
|
||||
|
||||
- ⚠️ 기존 버튼 개념 변경
|
||||
- ⚠️ 마이그레이션 복잡
|
||||
- ⚠️ UI 학습 곡선
|
||||
|
||||
---
|
||||
|
||||
## 마이그레이션 계획
|
||||
|
||||
### Phase 1: 하이브리드 지원
|
||||
|
||||
#### 목표
|
||||
|
||||
기존 `relationshipId` 방식과 새로운 `flowId` 방식 모두 지원
|
||||
|
||||
#### 작업
|
||||
|
||||
1. **ButtonDataflowConfig 확장**
|
||||
|
||||
```typescript
|
||||
interface ButtonDataflowConfig {
|
||||
controlMode: "relationship" | "flow" | "none";
|
||||
|
||||
// 기존 (하위 호환)
|
||||
relationshipConfig?: {
|
||||
relationshipId: string;
|
||||
executionTiming: "before" | "after" | "replace";
|
||||
};
|
||||
|
||||
// 🆕 신규
|
||||
flowConfig?: {
|
||||
flowId: number;
|
||||
executionTiming: "before" | "after" | "replace";
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
2. **실행 로직 분기**
|
||||
|
||||
```typescript
|
||||
if (buttonConfig.controlMode === "flow") {
|
||||
await executeButtonWithFlow(buttonConfig, formData, context);
|
||||
} else if (buttonConfig.controlMode === "relationship") {
|
||||
await executeButtonWithRelationship(buttonConfig, formData, context);
|
||||
}
|
||||
```
|
||||
|
||||
3. **UI 업데이트**
|
||||
|
||||
- 버튼 설정에 "제어 방식 선택" 추가
|
||||
- "기존 관계" vs "노드 플로우" 선택 가능
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 마이그레이션 도구
|
||||
|
||||
#### 관계 → 플로우 변환기
|
||||
|
||||
```typescript
|
||||
async function migrateRelationshipToFlow(
|
||||
relationshipId: string
|
||||
): Promise<number> {
|
||||
// 1. 기존 관계 조회
|
||||
const relationship = await getRelationship(relationshipId);
|
||||
|
||||
// 2. 노드 생성
|
||||
const nodes: FlowNode[] = [];
|
||||
const edges: FlowEdge[] = [];
|
||||
|
||||
// 소스 노드 (formData 또는 table)
|
||||
const sourceNode = {
|
||||
id: "source-1",
|
||||
type: "manualInput",
|
||||
data: { fields: extractFields(relationship) },
|
||||
};
|
||||
nodes.push(sourceNode);
|
||||
|
||||
// 조건 노드
|
||||
if (relationship.conditions.length > 0) {
|
||||
const conditionNode = {
|
||||
id: "condition-1",
|
||||
type: "condition",
|
||||
data: {
|
||||
conditions: relationship.conditions,
|
||||
logic: relationship.logic || "AND",
|
||||
},
|
||||
};
|
||||
nodes.push(conditionNode);
|
||||
edges.push({ id: "e1", source: "source-1", target: "condition-1" });
|
||||
}
|
||||
|
||||
// 액션 노드들
|
||||
let lastNodeId =
|
||||
relationship.conditions.length > 0 ? "condition-1" : "source-1";
|
||||
|
||||
relationship.actionGroups.forEach((group, groupIdx) => {
|
||||
group.actions.forEach((action, actionIdx) => {
|
||||
const actionNodeId = `action-${groupIdx}-${actionIdx}`;
|
||||
const actionNode = convertActionToNode(action, actionNodeId);
|
||||
nodes.push(actionNode);
|
||||
|
||||
edges.push({
|
||||
id: `e-${actionNodeId}`,
|
||||
source: lastNodeId,
|
||||
target: actionNodeId,
|
||||
});
|
||||
|
||||
// 순차 실행인 경우
|
||||
if (group.sequential) {
|
||||
lastNodeId = actionNodeId;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 3. 플로우 저장
|
||||
const flowData = {
|
||||
flowName: `Migrated: ${relationship.name}`,
|
||||
flowDescription: `Migrated from relationship ${relationshipId}`,
|
||||
flowData: JSON.stringify({ nodes, edges }),
|
||||
};
|
||||
|
||||
const { flowId } = await createNodeFlow(flowData);
|
||||
|
||||
// 4. 버튼 설정 업데이트
|
||||
await updateButtonConfig(relationshipId, {
|
||||
controlMode: "flow",
|
||||
flowConfig: {
|
||||
flowId,
|
||||
executionTiming: relationship.timing || "before",
|
||||
},
|
||||
});
|
||||
|
||||
return flowId;
|
||||
}
|
||||
```
|
||||
|
||||
#### 액션 변환 로직
|
||||
|
||||
```typescript
|
||||
function convertActionToNode(action: DataflowAction, nodeId: string): FlowNode {
|
||||
switch (action.operation) {
|
||||
case "INSERT":
|
||||
return {
|
||||
id: nodeId,
|
||||
type: "insertAction",
|
||||
data: {
|
||||
targetTable: action.tableName,
|
||||
fieldMappings: action.fields.map((f) => ({
|
||||
sourceField: f.name,
|
||||
targetField: f.name,
|
||||
staticValue: f.type === "static" ? f.value : undefined,
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
case "UPDATE":
|
||||
return {
|
||||
id: nodeId,
|
||||
type: "updateAction",
|
||||
data: {
|
||||
targetTable: action.tableName,
|
||||
whereConditions: action.conditions,
|
||||
fieldMappings: action.fields.map((f) => ({
|
||||
sourceField: f.name,
|
||||
targetField: f.name,
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
case "DELETE":
|
||||
return {
|
||||
id: nodeId,
|
||||
type: "deleteAction",
|
||||
data: {
|
||||
targetTable: action.tableName,
|
||||
whereConditions: action.conditions,
|
||||
},
|
||||
};
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported operation: ${action.operation}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: 완전 전환
|
||||
|
||||
#### 목표
|
||||
|
||||
모든 버튼이 노드 플로우 방식 사용
|
||||
|
||||
#### 작업
|
||||
|
||||
1. **마이그레이션 스크립트 실행**
|
||||
|
||||
```sql
|
||||
-- 모든 관계를 플로우로 변환
|
||||
SELECT migrate_all_relationships_to_flows();
|
||||
```
|
||||
|
||||
2. **UI에서 관계 모드 제거**
|
||||
|
||||
```typescript
|
||||
// controlMode에서 "relationship" 제거
|
||||
type ControlMode = "flow" | "none";
|
||||
```
|
||||
|
||||
3. **레거시 코드 정리**
|
||||
|
||||
- `executeButtonWithRelationship()` 제거
|
||||
- `RelationshipService` 제거 (또는 읽기 전용)
|
||||
|
||||
---
|
||||
|
||||
## 결론
|
||||
|
||||
### ✅ 호환 가능
|
||||
|
||||
노드 시스템과 버튼 제어 시스템은 **충분히 호환 가능**합니다!
|
||||
|
||||
### 🎯 권장 방안
|
||||
|
||||
**하이브리드 방식 (전략 1)**으로 점진적 마이그레이션
|
||||
|
||||
#### 이유
|
||||
|
||||
1. ✅ **기존 시스템 유지** - 서비스 중단 없음
|
||||
2. ✅ **점진적 전환** - 리스크 최소화
|
||||
3. ✅ **유연성** - 두 방식 모두 활용 가능
|
||||
4. ✅ **학습 곡선** - 사용자가 천천히 적응
|
||||
|
||||
### 📋 다음 단계
|
||||
|
||||
1. **Phase 1 구현** (예상: 2일)
|
||||
|
||||
- `ButtonDataflowConfig` 확장
|
||||
- `executeButtonWithFlow()` 구현
|
||||
- UI 선택 옵션 추가
|
||||
|
||||
2. **Phase 2 도구 개발** (예상: 1일)
|
||||
|
||||
- 마이그레이션 스크립트
|
||||
- 자동 변환 로직
|
||||
|
||||
3. **Phase 3 전환** (예상: 1일)
|
||||
- 데이터 마이그레이션
|
||||
- 레거시 제거
|
||||
|
||||
### 총 소요 시간
|
||||
|
||||
**약 4일**
|
||||
|
||||
---
|
||||
|
||||
**참고 문서**:
|
||||
|
||||
- [노드\_실행\_엔진\_설계.md](./노드_실행_엔진_설계.md)
|
||||
- [노드\_기반\_제어\_시스템\_개선\_계획.md](./노드_기반_제어_시스템_개선_계획.md)
|
||||
|
|
@ -0,0 +1,617 @@
|
|||
# 노드 실행 엔진 설계
|
||||
|
||||
**작성일**: 2025-01-02
|
||||
**버전**: 1.0
|
||||
**상태**: ✅ 확정
|
||||
|
||||
---
|
||||
|
||||
## 📋 목차
|
||||
|
||||
1. [개요](#개요)
|
||||
2. [실행 방식](#실행-방식)
|
||||
3. [데이터 흐름](#데이터-흐름)
|
||||
4. [오류 처리](#오류-처리)
|
||||
5. [구현 계획](#구현-계획)
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
### 목적
|
||||
|
||||
노드 기반 데이터 플로우의 실행 엔진을 설계하여:
|
||||
|
||||
- 효율적인 병렬 처리
|
||||
- 안정적인 오류 처리
|
||||
- 명확한 데이터 흐름
|
||||
|
||||
### 핵심 원칙
|
||||
|
||||
1. **독립적 트랜잭션**: 각 액션 노드는 독립적인 트랜잭션
|
||||
2. **부분 실패 허용**: 일부 실패해도 성공한 노드는 커밋
|
||||
3. **연쇄 중단**: 부모 노드 실패 시 자식 노드 스킵
|
||||
4. **병렬 실행**: 의존성 없는 노드는 병렬 실행
|
||||
|
||||
---
|
||||
|
||||
## 실행 방식
|
||||
|
||||
### 1. 기본 구조
|
||||
|
||||
```typescript
|
||||
interface ExecutionContext {
|
||||
sourceData: any[]; // 원본 데이터
|
||||
nodeResults: Map<string, NodeResult>; // 각 노드 실행 결과
|
||||
executionOrder: string[]; // 실행 순서
|
||||
}
|
||||
|
||||
interface NodeResult {
|
||||
nodeId: string;
|
||||
status: "pending" | "success" | "failed" | "skipped";
|
||||
data?: any;
|
||||
error?: Error;
|
||||
startTime: number;
|
||||
endTime?: number;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 실행 단계
|
||||
|
||||
#### Step 1: 위상 정렬 (Topological Sort)
|
||||
|
||||
노드 간 의존성을 파악하여 실행 순서 결정
|
||||
|
||||
```typescript
|
||||
function topologicalSort(nodes: FlowNode[], edges: FlowEdge[]): string[][] {
|
||||
// DAG(Directed Acyclic Graph) 순회
|
||||
// 같은 레벨의 노드들은 배열로 그룹화
|
||||
|
||||
return [
|
||||
["tableSource-1"], // Level 0: 소스
|
||||
["insert-1", "update-1", "delete-1"], // Level 1: 병렬 실행 가능
|
||||
["update-2"], // Level 2: insert-1에 의존
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 2: 레벨별 실행
|
||||
|
||||
```typescript
|
||||
async function executeFlow(
|
||||
nodes: FlowNode[],
|
||||
edges: FlowEdge[]
|
||||
): Promise<ExecutionResult> {
|
||||
const levels = topologicalSort(nodes, edges);
|
||||
const context: ExecutionContext = {
|
||||
sourceData: [],
|
||||
nodeResults: new Map(),
|
||||
executionOrder: [],
|
||||
};
|
||||
|
||||
for (const level of levels) {
|
||||
// 같은 레벨의 노드들은 병렬 실행
|
||||
await executeLevel(level, nodes, context);
|
||||
}
|
||||
|
||||
return generateExecutionReport(context);
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 3: 레벨 내 병렬 실행
|
||||
|
||||
```typescript
|
||||
async function executeLevel(
|
||||
nodeIds: string[],
|
||||
nodes: FlowNode[],
|
||||
context: ExecutionContext
|
||||
): Promise<void> {
|
||||
// Promise.allSettled로 병렬 실행
|
||||
const results = await Promise.allSettled(
|
||||
nodeIds.map((nodeId) => executeNode(nodeId, nodes, context))
|
||||
);
|
||||
|
||||
// 결과 저장
|
||||
results.forEach((result, index) => {
|
||||
const nodeId = nodeIds[index];
|
||||
if (result.status === "fulfilled") {
|
||||
context.nodeResults.set(nodeId, result.value);
|
||||
} else {
|
||||
context.nodeResults.set(nodeId, {
|
||||
nodeId,
|
||||
status: "failed",
|
||||
error: result.reason,
|
||||
startTime: Date.now(),
|
||||
endTime: Date.now(),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 데이터 흐름
|
||||
|
||||
### 1. 소스 노드 실행
|
||||
|
||||
```typescript
|
||||
async function executeSourceNode(node: TableSourceNode): Promise<any[]> {
|
||||
const { tableName, schema, whereConditions } = node.data;
|
||||
|
||||
// 데이터베이스 쿼리 실행
|
||||
const query = buildSelectQuery(tableName, schema, whereConditions);
|
||||
const data = await executeQuery(query);
|
||||
|
||||
return data;
|
||||
}
|
||||
```
|
||||
|
||||
**결과 예시**:
|
||||
|
||||
```json
|
||||
[
|
||||
{ "id": 1, "name": "김철수", "age": 30 },
|
||||
{ "id": 2, "name": "이영희", "age": 25 },
|
||||
{ "id": 3, "name": "박민수", "age": 35 }
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 액션 노드 실행
|
||||
|
||||
#### 데이터 전달 방식
|
||||
|
||||
```typescript
|
||||
async function executeNode(
|
||||
nodeId: string,
|
||||
nodes: FlowNode[],
|
||||
context: ExecutionContext
|
||||
): Promise<NodeResult> {
|
||||
const node = nodes.find((n) => n.id === nodeId);
|
||||
const parents = getParentNodes(nodeId, edges);
|
||||
|
||||
// 1️⃣ 부모 노드 상태 확인
|
||||
const parentFailed = parents.some((p) => {
|
||||
const parentResult = context.nodeResults.get(p.id);
|
||||
return parentResult?.status === "failed";
|
||||
});
|
||||
|
||||
if (parentFailed) {
|
||||
return {
|
||||
nodeId,
|
||||
status: "skipped",
|
||||
error: new Error("Parent node failed"),
|
||||
startTime: Date.now(),
|
||||
endTime: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// 2️⃣ 입력 데이터 준비
|
||||
const inputData = prepareInputData(node, parents, context);
|
||||
|
||||
// 3️⃣ 액션 실행 (독립 트랜잭션)
|
||||
return await executeActionWithTransaction(node, inputData);
|
||||
}
|
||||
```
|
||||
|
||||
#### 입력 데이터 준비
|
||||
|
||||
```typescript
|
||||
function prepareInputData(
|
||||
node: FlowNode,
|
||||
parents: FlowNode[],
|
||||
context: ExecutionContext
|
||||
): any {
|
||||
if (parents.length === 0) {
|
||||
// 소스 노드
|
||||
return null;
|
||||
} else if (parents.length === 1) {
|
||||
// 단일 부모: 부모의 결과 데이터 전달
|
||||
const parentResult = context.nodeResults.get(parents[0].id);
|
||||
return parentResult?.data || context.sourceData;
|
||||
} else {
|
||||
// 다중 부모: 모든 부모의 데이터 병합
|
||||
return parents.map((p) => {
|
||||
const result = context.nodeResults.get(p.id);
|
||||
return result?.data || context.sourceData;
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 병렬 실행 예시
|
||||
|
||||
```
|
||||
TableSource
|
||||
(100개 레코드)
|
||||
↓
|
||||
┌──────┼──────┐
|
||||
↓ ↓ ↓
|
||||
INSERT UPDATE DELETE
|
||||
(독립) (독립) (독립)
|
||||
```
|
||||
|
||||
**실행 과정**:
|
||||
|
||||
```typescript
|
||||
// 1. TableSource 실행
|
||||
const sourceData = await executeTableSource();
|
||||
// → [100개 레코드]
|
||||
|
||||
// 2. 병렬 실행 (Promise.allSettled)
|
||||
const results = await Promise.allSettled([
|
||||
executeInsertAction(insertNode, sourceData),
|
||||
executeUpdateAction(updateNode, sourceData),
|
||||
executeDeleteAction(deleteNode, sourceData),
|
||||
]);
|
||||
|
||||
// 3. 각 액션은 독립 트랜잭션
|
||||
// - INSERT 실패 → INSERT만 롤백
|
||||
// - UPDATE 성공 → UPDATE 커밋
|
||||
// - DELETE 성공 → DELETE 커밋
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. 연쇄 실행 예시
|
||||
|
||||
```
|
||||
TableSource
|
||||
↓
|
||||
INSERT
|
||||
❌ (실패)
|
||||
↓
|
||||
UPDATE-2
|
||||
⏭️ (스킵)
|
||||
```
|
||||
|
||||
**실행 과정**:
|
||||
|
||||
```typescript
|
||||
// 1. TableSource 실행
|
||||
const sourceData = await executeTableSource();
|
||||
// → 성공 ✅
|
||||
|
||||
// 2. INSERT 실행
|
||||
const insertResult = await executeInsertAction(insertNode, sourceData);
|
||||
// → 실패 ❌ (롤백됨)
|
||||
|
||||
// 3. UPDATE-2 실행 시도
|
||||
const parentFailed = insertResult.status === "failed";
|
||||
if (parentFailed) {
|
||||
return {
|
||||
status: "skipped",
|
||||
reason: "Parent INSERT failed",
|
||||
};
|
||||
// → 스킬 ⏭️
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 오류 처리
|
||||
|
||||
### 1. 독립 트랜잭션
|
||||
|
||||
각 액션 노드는 자체 트랜잭션을 가짐
|
||||
|
||||
```typescript
|
||||
async function executeActionWithTransaction(
|
||||
node: FlowNode,
|
||||
inputData: any
|
||||
): Promise<NodeResult> {
|
||||
// 트랜잭션 시작
|
||||
const transaction = await db.beginTransaction();
|
||||
|
||||
try {
|
||||
const result = await performAction(node, inputData, transaction);
|
||||
|
||||
// 성공 시 커밋
|
||||
await transaction.commit();
|
||||
|
||||
return {
|
||||
nodeId: node.id,
|
||||
status: "success",
|
||||
data: result,
|
||||
startTime: Date.now(),
|
||||
endTime: Date.now(),
|
||||
};
|
||||
} catch (error) {
|
||||
// 실패 시 롤백
|
||||
await transaction.rollback();
|
||||
|
||||
return {
|
||||
nodeId: node.id,
|
||||
status: "failed",
|
||||
error: error,
|
||||
startTime: Date.now(),
|
||||
endTime: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 부분 실패 허용
|
||||
|
||||
```typescript
|
||||
// Promise.allSettled 사용
|
||||
const results = await Promise.allSettled([action1(), action2(), action3()]);
|
||||
|
||||
// 결과 수집
|
||||
const summary = {
|
||||
total: results.length,
|
||||
success: results.filter((r) => r.status === "fulfilled").length,
|
||||
failed: results.filter((r) => r.status === "rejected").length,
|
||||
details: results,
|
||||
};
|
||||
```
|
||||
|
||||
**예시 결과**:
|
||||
|
||||
```json
|
||||
{
|
||||
"total": 3,
|
||||
"success": 2,
|
||||
"failed": 1,
|
||||
"details": [
|
||||
{ "status": "rejected", "reason": "Duplicate key error" },
|
||||
{ "status": "fulfilled", "value": { "updatedCount": 100 } },
|
||||
{ "status": "fulfilled", "value": { "deletedCount": 50 } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 연쇄 중단
|
||||
|
||||
부모 노드 실패 시 자식 노드 자동 스킵
|
||||
|
||||
```typescript
|
||||
function shouldSkipNode(node: FlowNode, context: ExecutionContext): boolean {
|
||||
const parents = getParentNodes(node.id);
|
||||
|
||||
return parents.some((parent) => {
|
||||
const parentResult = context.nodeResults.get(parent.id);
|
||||
return parentResult?.status === "failed";
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. 오류 메시지
|
||||
|
||||
```typescript
|
||||
interface ExecutionError {
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
errorType: "validation" | "execution" | "connection" | "timeout";
|
||||
message: string;
|
||||
details?: any;
|
||||
timestamp: number;
|
||||
}
|
||||
```
|
||||
|
||||
**오류 메시지 예시**:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodeId": "insert-1",
|
||||
"nodeName": "INSERT 액션",
|
||||
"errorType": "execution",
|
||||
"message": "Duplicate key error: 'email' already exists",
|
||||
"details": {
|
||||
"table": "users",
|
||||
"constraint": "users_email_unique",
|
||||
"value": "test@example.com"
|
||||
},
|
||||
"timestamp": 1704182400000
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 구현 계획
|
||||
|
||||
### Phase 1: 기본 실행 엔진 (우선순위: 높음)
|
||||
|
||||
**작업 항목**:
|
||||
|
||||
1. ✅ 위상 정렬 알고리즘 구현
|
||||
2. ✅ 레벨별 실행 로직
|
||||
3. ✅ Promise.allSettled 기반 병렬 실행
|
||||
4. ✅ 독립 트랜잭션 처리
|
||||
5. ✅ 연쇄 중단 로직
|
||||
|
||||
**예상 시간**: 1일
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 소스 노드 실행 (우선순위: 높음)
|
||||
|
||||
**작업 항목**:
|
||||
|
||||
1. ✅ TableSource 실행기
|
||||
2. ✅ ExternalDBSource 실행기
|
||||
3. ✅ RestAPISource 실행기
|
||||
4. ✅ 데이터 캐싱
|
||||
|
||||
**예상 시간**: 1일
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: 액션 노드 실행 (우선순위: 높음)
|
||||
|
||||
**작업 항목**:
|
||||
|
||||
1. ✅ INSERT 액션 실행기
|
||||
2. ✅ UPDATE 액션 실행기
|
||||
3. ✅ DELETE 액션 실행기
|
||||
4. ✅ UPSERT 액션 실행기
|
||||
5. ✅ 필드 매핑 적용
|
||||
|
||||
**예상 시간**: 2일
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: 변환 노드 실행 (우선순위: 중간)
|
||||
|
||||
**작업 항목**:
|
||||
|
||||
1. ✅ FieldMapping 실행기
|
||||
2. ✅ DataTransform 실행기
|
||||
3. ✅ Condition 분기 처리
|
||||
|
||||
**예상 시간**: 1일
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: 오류 처리 및 모니터링 (우선순위: 중간)
|
||||
|
||||
**작업 항목**:
|
||||
|
||||
1. ✅ 상세 오류 메시지
|
||||
2. ✅ 실행 결과 리포트
|
||||
3. ✅ 실행 로그 저장
|
||||
4. ✅ 실시간 진행 상태 표시
|
||||
|
||||
**예상 시간**: 1일
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: 최적화 (우선순위: 낮음)
|
||||
|
||||
**작업 항목**:
|
||||
|
||||
1. ⏳ 데이터 스트리밍 (대용량 데이터)
|
||||
2. ⏳ 배치 처리 최적화
|
||||
3. ⏳ 병렬 처리 튜닝
|
||||
4. ⏳ 캐싱 전략
|
||||
|
||||
**예상 시간**: 2일
|
||||
|
||||
---
|
||||
|
||||
## 실행 결과 예시
|
||||
|
||||
### 성공 케이스
|
||||
|
||||
```json
|
||||
{
|
||||
"flowId": "flow-123",
|
||||
"flowName": "사용자 데이터 동기화",
|
||||
"status": "completed",
|
||||
"startTime": "2025-01-02T10:00:00Z",
|
||||
"endTime": "2025-01-02T10:00:05Z",
|
||||
"duration": 5000,
|
||||
"nodes": [
|
||||
{
|
||||
"nodeId": "source-1",
|
||||
"nodeName": "TableSource",
|
||||
"status": "success",
|
||||
"recordCount": 100,
|
||||
"duration": 500
|
||||
},
|
||||
{
|
||||
"nodeId": "insert-1",
|
||||
"nodeName": "INSERT",
|
||||
"status": "success",
|
||||
"insertedCount": 100,
|
||||
"duration": 2000
|
||||
},
|
||||
{
|
||||
"nodeId": "update-1",
|
||||
"nodeName": "UPDATE",
|
||||
"status": "success",
|
||||
"updatedCount": 80,
|
||||
"duration": 1500
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"total": 3,
|
||||
"success": 3,
|
||||
"failed": 0,
|
||||
"skipped": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 부분 실패 케이스
|
||||
|
||||
```json
|
||||
{
|
||||
"flowId": "flow-124",
|
||||
"flowName": "데이터 처리",
|
||||
"status": "partial_success",
|
||||
"startTime": "2025-01-02T11:00:00Z",
|
||||
"endTime": "2025-01-02T11:00:08Z",
|
||||
"duration": 8000,
|
||||
"nodes": [
|
||||
{
|
||||
"nodeId": "source-1",
|
||||
"nodeName": "TableSource",
|
||||
"status": "success",
|
||||
"recordCount": 100
|
||||
},
|
||||
{
|
||||
"nodeId": "insert-1",
|
||||
"nodeName": "INSERT",
|
||||
"status": "failed",
|
||||
"error": "Duplicate key error",
|
||||
"details": "email 'test@example.com' already exists"
|
||||
},
|
||||
{
|
||||
"nodeId": "update-2",
|
||||
"nodeName": "UPDATE-2",
|
||||
"status": "skipped",
|
||||
"reason": "Parent INSERT failed"
|
||||
},
|
||||
{
|
||||
"nodeId": "update-1",
|
||||
"nodeName": "UPDATE",
|
||||
"status": "success",
|
||||
"updatedCount": 50
|
||||
},
|
||||
{
|
||||
"nodeId": "delete-1",
|
||||
"nodeName": "DELETE",
|
||||
"status": "success",
|
||||
"deletedCount": 20
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"total": 5,
|
||||
"success": 3,
|
||||
"failed": 1,
|
||||
"skipped": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 다음 단계
|
||||
|
||||
1. ✅ 데이터 처리 방식 확정 (완료)
|
||||
2. ⏳ 실행 엔진 구현 시작
|
||||
3. ⏳ 테스트 케이스 작성
|
||||
4. ⏳ UI에서 실행 결과 표시
|
||||
|
||||
---
|
||||
|
||||
**참고 문서**:
|
||||
|
||||
- [노드*기반*제어*시스템*개선\_계획.md](./노드_기반_제어_시스템_개선_계획.md)
|
||||
- [노드*연결*규칙\_설계.md](./노드_연결_규칙_설계.md)
|
||||
- [노드*구조*개선안.md](./노드_구조_개선안.md)
|
||||
|
|
@ -0,0 +1,431 @@
|
|||
# 노드 연결 규칙 설계
|
||||
|
||||
**작성일**: 2025-01-02
|
||||
**버전**: 1.0
|
||||
**상태**: 🔄 설계 중
|
||||
|
||||
---
|
||||
|
||||
## 📋 목차
|
||||
|
||||
1. [개요](#개요)
|
||||
2. [노드 분류](#노드-분류)
|
||||
3. [연결 규칙 매트릭스](#연결-규칙-매트릭스)
|
||||
4. [상세 연결 규칙](#상세-연결-규칙)
|
||||
5. [구현 계획](#구현-계획)
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
### 목적
|
||||
|
||||
노드 간 연결 가능 여부를 명확히 정의하여:
|
||||
|
||||
- 사용자의 실수 방지
|
||||
- 논리적으로 올바른 플로우만 생성 가능
|
||||
- 명확한 오류 메시지 제공
|
||||
|
||||
### 기본 원칙
|
||||
|
||||
1. **데이터 흐름 방향**: 소스 → 변환 → 액션
|
||||
2. **타입 안전성**: 출력과 입력 타입이 호환되어야 함
|
||||
3. **논리적 정합성**: 의미 없는 연결 방지
|
||||
|
||||
---
|
||||
|
||||
## 노드 분류
|
||||
|
||||
### 1. 데이터 소스 노드 (Source)
|
||||
|
||||
**역할**: 데이터를 생성하는 시작점
|
||||
|
||||
- `tableSource` - 내부 테이블
|
||||
- `externalDBSource` - 외부 DB
|
||||
- `restAPISource` - REST API
|
||||
|
||||
**특징**:
|
||||
|
||||
- ✅ 출력만 가능 (소스 핸들)
|
||||
- ❌ 입력 불가능
|
||||
- 플로우의 시작점
|
||||
|
||||
---
|
||||
|
||||
### 2. 변환/조건 노드 (Transform)
|
||||
|
||||
**역할**: 데이터를 가공하거나 흐름을 제어
|
||||
|
||||
#### 2.1 데이터 변환
|
||||
|
||||
- `fieldMapping` - 필드 매핑
|
||||
- `dataTransform` - 데이터 변환
|
||||
|
||||
**특징**:
|
||||
|
||||
- ✅ 입력 가능 (타겟 핸들)
|
||||
- ✅ 출력 가능 (소스 핸들)
|
||||
- 중간 파이프라인 역할
|
||||
|
||||
#### 2.2 조건 분기
|
||||
|
||||
- `condition` - 조건 분기
|
||||
|
||||
**특징**:
|
||||
|
||||
- ✅ 입력 가능 (타겟 핸들)
|
||||
- ✅ 출력 가능 (TRUE/FALSE 2개의 소스 핸들)
|
||||
- 흐름을 분기
|
||||
|
||||
---
|
||||
|
||||
### 3. 액션 노드 (Action)
|
||||
|
||||
**역할**: 실제 데이터베이스 작업 수행
|
||||
|
||||
- `insertAction` - INSERT
|
||||
- `updateAction` - UPDATE
|
||||
- `deleteAction` - DELETE
|
||||
- `upsertAction` - UPSERT
|
||||
|
||||
**특징**:
|
||||
|
||||
- ✅ 입력 가능 (타겟 핸들)
|
||||
- ⚠️ 출력 제한적 (성공/실패 결과만)
|
||||
- 플로우의 종착점 또는 중간 액션
|
||||
|
||||
---
|
||||
|
||||
### 4. 유틸리티 노드 (Utility)
|
||||
|
||||
**역할**: 보조적인 기능 제공
|
||||
|
||||
- `log` - 로그 출력
|
||||
- `comment` - 주석
|
||||
|
||||
**특징**:
|
||||
|
||||
- `log`: 입력/출력 모두 가능 (패스스루)
|
||||
- `comment`: 연결 불가능 (독립 노드)
|
||||
|
||||
---
|
||||
|
||||
## 연결 규칙 매트릭스
|
||||
|
||||
### 출력(From) → 입력(To) 연결 가능 여부
|
||||
|
||||
| From ↓ / To → | tableSource | externalDB | restAPI | condition | fieldMapping | dataTransform | insert | update | delete | upsert | log | comment |
|
||||
| ----------------- | ----------- | ---------- | ------- | --------- | ------------ | ------------- | ------ | ------ | ------ | ------ | --- | ------- |
|
||||
| **tableSource** | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| **externalDB** | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| **restAPI** | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| **condition** | ❌ | ❌ | ❌ | ⚠️ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| **fieldMapping** | ❌ | ❌ | ❌ | ✅ | ⚠️ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| **dataTransform** | ❌ | ❌ | ❌ | ✅ | ✅ | ⚠️ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| **insert** | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ✅ | ❌ |
|
||||
| **update** | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ✅ | ❌ |
|
||||
| **delete** | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ✅ | ❌ |
|
||||
| **upsert** | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ✅ | ❌ |
|
||||
| **log** | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| **comment** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
|
||||
**범례**:
|
||||
|
||||
- ✅ 허용
|
||||
- ❌ 금지
|
||||
- ⚠️ 조건부 허용 (경고 메시지와 함께)
|
||||
|
||||
---
|
||||
|
||||
## 상세 연결 규칙
|
||||
|
||||
### 규칙 1: 소스 노드는 입력을 받을 수 없음
|
||||
|
||||
**금지되는 연결**:
|
||||
|
||||
```
|
||||
❌ 어떤 노드 → tableSource
|
||||
❌ 어떤 노드 → externalDBSource
|
||||
❌ 어떤 노드 → restAPISource
|
||||
```
|
||||
|
||||
**이유**: 소스 노드는 데이터의 시작점이므로 외부 입력이 의미 없음
|
||||
|
||||
**오류 메시지**:
|
||||
|
||||
```
|
||||
"소스 노드는 입력을 받을 수 없습니다. 소스 노드는 플로우의 시작점입니다."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 규칙 2: 소스 노드끼리 연결 불가
|
||||
|
||||
**금지되는 연결**:
|
||||
|
||||
```
|
||||
❌ tableSource → externalDBSource
|
||||
❌ restAPISource → tableSource
|
||||
```
|
||||
|
||||
**이유**: 소스 노드는 독립적으로 데이터를 생성하므로 서로 연결 불필요
|
||||
|
||||
**오류 메시지**:
|
||||
|
||||
```
|
||||
"소스 노드끼리는 연결할 수 없습니다. 각 소스는 독립적으로 동작합니다."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 규칙 3: Comment 노드는 연결 불가
|
||||
|
||||
**금지되는 연결**:
|
||||
|
||||
```
|
||||
❌ 어떤 노드 → comment
|
||||
❌ comment → 어떤 노드
|
||||
```
|
||||
|
||||
**이유**: Comment는 설명 전용 노드로 데이터 흐름에 영향을 주지 않음
|
||||
|
||||
**오류 메시지**:
|
||||
|
||||
```
|
||||
"주석 노드는 연결할 수 없습니다. 주석은 플로우 설명 용도로만 사용됩니다."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 규칙 4: 동일한 타입의 변환 노드 연속 연결 경고
|
||||
|
||||
**경고가 필요한 연결**:
|
||||
|
||||
```
|
||||
⚠️ fieldMapping → fieldMapping
|
||||
⚠️ dataTransform → dataTransform
|
||||
⚠️ condition → condition
|
||||
```
|
||||
|
||||
**이유**: 논리적으로 가능하지만 비효율적일 수 있음
|
||||
|
||||
**경고 메시지**:
|
||||
|
||||
```
|
||||
"동일한 타입의 변환 노드가 연속으로 연결되었습니다. 하나의 노드로 통합하는 것이 효율적입니다."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 규칙 5: 액션 노드 연속 연결 경고
|
||||
|
||||
**경고가 필요한 연결**:
|
||||
|
||||
```
|
||||
⚠️ insertAction → updateAction
|
||||
⚠️ updateAction → deleteAction
|
||||
⚠️ deleteAction → insertAction
|
||||
```
|
||||
|
||||
**이유**: 트랜잭션 관리나 성능에 영향을 줄 수 있음
|
||||
|
||||
**경고 메시지**:
|
||||
|
||||
```
|
||||
"액션 노드가 연속으로 연결되었습니다. 트랜잭션과 성능을 고려해주세요."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 규칙 6: 자기 자신에게 연결 금지
|
||||
|
||||
**금지되는 연결**:
|
||||
|
||||
```
|
||||
❌ 모든 노드 → 자기 자신
|
||||
```
|
||||
|
||||
**이유**: 무한 루프 방지
|
||||
|
||||
**오류 메시지**:
|
||||
|
||||
```
|
||||
"노드는 자기 자신에게 연결할 수 없습니다."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 규칙 7: Log 노드는 패스스루
|
||||
|
||||
**허용되는 연결**:
|
||||
|
||||
```
|
||||
✅ 모든 노드 → log → 모든 노드 (소스 제외)
|
||||
```
|
||||
|
||||
**특징**:
|
||||
|
||||
- Log 노드는 데이터를 그대로 전달
|
||||
- 디버깅 및 모니터링 용도
|
||||
- 데이터 흐름에 영향 없음
|
||||
|
||||
---
|
||||
|
||||
## 구현 계획
|
||||
|
||||
### Phase 1: 기본 금지 규칙 (우선순위: 높음)
|
||||
|
||||
**구현 위치**: `frontend/lib/stores/flowEditorStore.ts` - `validateConnection` 함수
|
||||
|
||||
```typescript
|
||||
function validateConnection(
|
||||
connection: Connection,
|
||||
nodes: FlowNode[]
|
||||
): { valid: boolean; error?: string } {
|
||||
const sourceNode = nodes.find((n) => n.id === connection.source);
|
||||
const targetNode = nodes.find((n) => n.id === connection.target);
|
||||
|
||||
if (!sourceNode || !targetNode) {
|
||||
return { valid: false, error: "노드를 찾을 수 없습니다" };
|
||||
}
|
||||
|
||||
// 규칙 1: 소스 노드는 입력을 받을 수 없음
|
||||
if (isSourceNode(targetNode.type)) {
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
"소스 노드는 입력을 받을 수 없습니다. 소스 노드는 플로우의 시작점입니다.",
|
||||
};
|
||||
}
|
||||
|
||||
// 규칙 2: 소스 노드끼리 연결 불가
|
||||
if (isSourceNode(sourceNode.type) && isSourceNode(targetNode.type)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "소스 노드끼리는 연결할 수 없습니다.",
|
||||
};
|
||||
}
|
||||
|
||||
// 규칙 3: Comment 노드는 연결 불가
|
||||
if (sourceNode.type === "comment" || targetNode.type === "comment") {
|
||||
return {
|
||||
valid: false,
|
||||
error: "주석 노드는 연결할 수 없습니다.",
|
||||
};
|
||||
}
|
||||
|
||||
// 규칙 6: 자기 자신에게 연결 금지
|
||||
if (connection.source === connection.target) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "노드는 자기 자신에게 연결할 수 없습니다.",
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
```
|
||||
|
||||
**예상 작업 시간**: 30분
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 경고 규칙 (우선순위: 중간)
|
||||
|
||||
**구현 방법**: 연결은 허용하되 경고 표시
|
||||
|
||||
```typescript
|
||||
function getConnectionWarning(
|
||||
connection: Connection,
|
||||
nodes: FlowNode[]
|
||||
): string | null {
|
||||
const sourceNode = nodes.find((n) => n.id === connection.source);
|
||||
const targetNode = nodes.find((n) => n.id === connection.target);
|
||||
|
||||
if (!sourceNode || !targetNode) return null;
|
||||
|
||||
// 규칙 4: 동일한 타입의 변환 노드 연속 연결
|
||||
if (sourceNode.type === targetNode.type && isTransformNode(sourceNode.type)) {
|
||||
return "동일한 타입의 변환 노드가 연속으로 연결되었습니다. 하나로 통합하는 것이 효율적입니다.";
|
||||
}
|
||||
|
||||
// 규칙 5: 액션 노드 연속 연결
|
||||
if (isActionNode(sourceNode.type) && isActionNode(targetNode.type)) {
|
||||
return "액션 노드가 연속으로 연결되었습니다. 트랜잭션과 성능을 고려해주세요.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
**UI 구현**:
|
||||
|
||||
- 경고 아이콘을 연결선 위에 표시
|
||||
- 호버 시 경고 메시지 툴팁 표시
|
||||
|
||||
**예상 작업 시간**: 1시간
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: 시각적 피드백 (우선순위: 낮음)
|
||||
|
||||
**기능**:
|
||||
|
||||
1. 드래그 중 호환 가능한 노드 하이라이트
|
||||
2. 불가능한 연결 시도 시 빨간색 표시
|
||||
3. 경고가 있는 연결은 노란색 표시
|
||||
|
||||
**예상 작업 시간**: 2시간
|
||||
|
||||
---
|
||||
|
||||
## 테스트 케이스
|
||||
|
||||
### 금지 테스트
|
||||
|
||||
- [ ] tableSource → tableSource (금지)
|
||||
- [ ] fieldMapping → comment (금지)
|
||||
- [ ] 자기 자신 → 자기 자신 (금지)
|
||||
|
||||
### 경고 테스트
|
||||
|
||||
- [ ] fieldMapping → fieldMapping (경고)
|
||||
- [ ] insertAction → updateAction (경고)
|
||||
|
||||
### 정상 테스트
|
||||
|
||||
- [ ] tableSource → fieldMapping → insertAction
|
||||
- [ ] externalDBSource → condition → (TRUE) → updateAction
|
||||
- [ ] restAPISource → log → dataTransform → upsertAction
|
||||
|
||||
---
|
||||
|
||||
## 향후 확장
|
||||
|
||||
### 추가 고려사항
|
||||
|
||||
1. **핸들별 제약**:
|
||||
|
||||
- Condition 노드의 TRUE/FALSE 출력 구분
|
||||
- 특정 핸들만 특정 노드 타입과 연결 가능
|
||||
|
||||
2. **데이터 타입 검증**:
|
||||
|
||||
- 숫자 필드만 계산 노드로 연결 가능
|
||||
- 문자열 필드만 텍스트 변환 노드로 연결 가능
|
||||
|
||||
3. **순서 제약**:
|
||||
- UPDATE/DELETE 전에 반드시 SELECT 필요
|
||||
- 특정 변환 순서 강제
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 버전 | 날짜 | 변경 내용 | 작성자 |
|
||||
| ---- | ---------- | --------- | ------ |
|
||||
| 1.0 | 2025-01-02 | 초안 작성 | AI |
|
||||
|
||||
---
|
||||
|
||||
**다음 단계**: Phase 1 구현 시작
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 노드 기반 제어 시스템 페이지
|
||||
*/
|
||||
|
||||
import { FlowEditor } from "@/components/dataflow/node-editor/FlowEditor";
|
||||
|
||||
export default function NodeEditorPage() {
|
||||
return (
|
||||
<div className="h-screen bg-gray-50">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="border-b bg-white p-4">
|
||||
<div className="mx-auto">
|
||||
<h1 className="text-2xl font-bold text-gray-900">노드 기반 제어 시스템</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
드래그 앤 드롭으로 데이터 제어 플로우를 시각적으로 설계하고 관리합니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 에디터 */}
|
||||
<FlowEditor />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 노드 기반 플로우 에디터 메인 컴포넌트
|
||||
*/
|
||||
|
||||
import { useCallback, useRef } from "react";
|
||||
import ReactFlow, { Background, Controls, MiniMap, Panel, ReactFlowProvider, useReactFlow } from "reactflow";
|
||||
import "reactflow/dist/style.css";
|
||||
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { NodePalette } from "./sidebar/NodePalette";
|
||||
import { PropertiesPanel } from "./panels/PropertiesPanel";
|
||||
import { FlowToolbar } from "./FlowToolbar";
|
||||
import { TableSourceNode } from "./nodes/TableSourceNode";
|
||||
import { ExternalDBSourceNode } from "./nodes/ExternalDBSourceNode";
|
||||
import { ConditionNode } from "./nodes/ConditionNode";
|
||||
import { FieldMappingNode } from "./nodes/FieldMappingNode";
|
||||
import { InsertActionNode } from "./nodes/InsertActionNode";
|
||||
import { UpdateActionNode } from "./nodes/UpdateActionNode";
|
||||
import { DeleteActionNode } from "./nodes/DeleteActionNode";
|
||||
import { UpsertActionNode } from "./nodes/UpsertActionNode";
|
||||
import { DataTransformNode } from "./nodes/DataTransformNode";
|
||||
import { RestAPISourceNode } from "./nodes/RestAPISourceNode";
|
||||
import { CommentNode } from "./nodes/CommentNode";
|
||||
import { LogNode } from "./nodes/LogNode";
|
||||
|
||||
// 노드 타입들
|
||||
const nodeTypes = {
|
||||
// 데이터 소스
|
||||
tableSource: TableSourceNode,
|
||||
externalDBSource: ExternalDBSourceNode,
|
||||
restAPISource: RestAPISourceNode,
|
||||
// 변환/조건
|
||||
condition: ConditionNode,
|
||||
fieldMapping: FieldMappingNode,
|
||||
dataTransform: DataTransformNode,
|
||||
// 액션
|
||||
insertAction: InsertActionNode,
|
||||
updateAction: UpdateActionNode,
|
||||
deleteAction: DeleteActionNode,
|
||||
upsertAction: UpsertActionNode,
|
||||
// 유틸리티
|
||||
comment: CommentNode,
|
||||
log: LogNode,
|
||||
};
|
||||
|
||||
/**
|
||||
* FlowEditor 내부 컴포넌트
|
||||
*/
|
||||
function FlowEditorInner() {
|
||||
const reactFlowWrapper = useRef<HTMLDivElement>(null);
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
|
||||
const {
|
||||
nodes,
|
||||
edges,
|
||||
onNodesChange,
|
||||
onEdgesChange,
|
||||
onConnect,
|
||||
addNode,
|
||||
showPropertiesPanel,
|
||||
selectNodes,
|
||||
selectedNodes,
|
||||
removeNodes,
|
||||
} = useFlowEditorStore();
|
||||
|
||||
/**
|
||||
* 노드 선택 변경 핸들러
|
||||
*/
|
||||
const onSelectionChange = useCallback(
|
||||
({ nodes: selectedNodes }: { nodes: any[] }) => {
|
||||
const selectedIds = selectedNodes.map((node) => node.id);
|
||||
selectNodes(selectedIds);
|
||||
console.log("🔍 선택된 노드:", selectedIds);
|
||||
},
|
||||
[selectNodes],
|
||||
);
|
||||
|
||||
/**
|
||||
* 키보드 이벤트 핸들러 (Delete/Backspace 키로 노드 삭제)
|
||||
*/
|
||||
const onKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
if ((event.key === "Delete" || event.key === "Backspace") && selectedNodes.length > 0) {
|
||||
event.preventDefault();
|
||||
console.log("🗑️ 선택된 노드 삭제:", selectedNodes);
|
||||
removeNodes(selectedNodes);
|
||||
}
|
||||
},
|
||||
[selectedNodes, removeNodes],
|
||||
);
|
||||
|
||||
/**
|
||||
* 드래그 앤 드롭 핸들러
|
||||
*/
|
||||
const onDragOver = useCallback((event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
}, []);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const type = event.dataTransfer.getData("application/reactflow");
|
||||
if (!type) return;
|
||||
|
||||
const position = screenToFlowPosition({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
|
||||
const newNode: any = {
|
||||
id: `node_${Date.now()}`,
|
||||
type,
|
||||
position,
|
||||
data: {
|
||||
displayName: `새 ${type} 노드`,
|
||||
},
|
||||
};
|
||||
|
||||
addNode(newNode);
|
||||
},
|
||||
[screenToFlowPosition, addNode],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full">
|
||||
{/* 좌측 노드 팔레트 */}
|
||||
<div className="w-[250px] border-r bg-white">
|
||||
<NodePalette />
|
||||
</div>
|
||||
|
||||
{/* 중앙 캔버스 */}
|
||||
<div className="relative flex-1" ref={reactFlowWrapper} onKeyDown={onKeyDown} tabIndex={0}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onSelectionChange={onSelectionChange}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
className="bg-gray-50"
|
||||
deleteKeyCode={["Delete", "Backspace"]}
|
||||
>
|
||||
{/* 배경 그리드 */}
|
||||
<Background gap={16} size={1} color="#E5E7EB" />
|
||||
|
||||
{/* 컨트롤 버튼 */}
|
||||
<Controls className="bg-white shadow-md" />
|
||||
|
||||
{/* 미니맵 */}
|
||||
<MiniMap
|
||||
className="bg-white shadow-md"
|
||||
nodeColor={(node) => {
|
||||
// 노드 타입별 색상 (추후 구현)
|
||||
return "#3B82F6";
|
||||
}}
|
||||
maskColor="rgba(0, 0, 0, 0.1)"
|
||||
/>
|
||||
|
||||
{/* 상단 툴바 */}
|
||||
<Panel position="top-center" className="pointer-events-auto">
|
||||
<FlowToolbar />
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
{/* 우측 속성 패널 */}
|
||||
{showPropertiesPanel && (
|
||||
<div className="w-[350px] border-l bg-white">
|
||||
<PropertiesPanel />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* FlowEditor 메인 컴포넌트 (Provider로 감싸기)
|
||||
*/
|
||||
export function FlowEditor() {
|
||||
return (
|
||||
<div className="h-[calc(100vh-200px)] min-h-[700px] w-full">
|
||||
<ReactFlowProvider>
|
||||
<FlowEditorInner />
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 플로우 에디터 상단 툴바
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Play, Save, FileCheck, Undo2, Redo2, ZoomIn, ZoomOut, FolderOpen, Download, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { useReactFlow } from "reactflow";
|
||||
import { LoadFlowDialog } from "./dialogs/LoadFlowDialog";
|
||||
import { getNodeFlow } from "@/lib/api/nodeFlows";
|
||||
|
||||
export function FlowToolbar() {
|
||||
const { zoomIn, zoomOut, fitView } = useReactFlow();
|
||||
const {
|
||||
flowName,
|
||||
setFlowName,
|
||||
validateFlow,
|
||||
saveFlow,
|
||||
exportFlow,
|
||||
isExecuting,
|
||||
isSaving,
|
||||
selectedNodes,
|
||||
removeNodes,
|
||||
} = useFlowEditorStore();
|
||||
const [showLoadDialog, setShowLoadDialog] = useState(false);
|
||||
|
||||
const handleValidate = () => {
|
||||
const result = validateFlow();
|
||||
if (result.valid) {
|
||||
alert("✅ 검증 성공! 오류가 없습니다.");
|
||||
} else {
|
||||
alert(`❌ 검증 실패\n\n${result.errors.map((e) => `- ${e.message}`).join("\n")}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const result = await saveFlow();
|
||||
if (result.success) {
|
||||
alert(`✅ ${result.message}\nFlow ID: ${result.flowId}`);
|
||||
} else {
|
||||
alert(`❌ 저장 실패\n\n${result.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const json = exportFlow();
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${flowName || "flow"}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
alert("✅ JSON 파일로 내보내기 완료!");
|
||||
};
|
||||
|
||||
const handleLoad = async (flowId: number) => {
|
||||
try {
|
||||
const flow = await getNodeFlow(flowId);
|
||||
|
||||
// flowData가 이미 객체인지 문자열인지 확인
|
||||
const parsedData = typeof flow.flowData === "string" ? JSON.parse(flow.flowData) : flow.flowData;
|
||||
|
||||
// Zustand 스토어의 loadFlow 함수 호출
|
||||
useFlowEditorStore
|
||||
.getState()
|
||||
.loadFlow(flow.flowId, flow.flowName, flow.flowDescription, parsedData.nodes, parsedData.edges);
|
||||
alert(`✅ "${flow.flowName}" 플로우를 불러왔습니다!`);
|
||||
} catch (error) {
|
||||
console.error("플로우 불러오기 오류:", error);
|
||||
alert(error instanceof Error ? error.message : "플로우를 불러올 수 없습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecute = () => {
|
||||
// TODO: 실행 로직 구현
|
||||
alert("실행 기능 구현 예정");
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (selectedNodes.length === 0) {
|
||||
alert("삭제할 노드를 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirm(`선택된 ${selectedNodes.length}개 노드를 삭제하시겠습니까?`)) {
|
||||
removeNodes(selectedNodes);
|
||||
alert(`✅ ${selectedNodes.length}개 노드가 삭제되었습니다.`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoadFlowDialog open={showLoadDialog} onOpenChange={setShowLoadDialog} onLoad={handleLoad} />
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-white p-2 shadow-md">
|
||||
{/* 플로우 이름 */}
|
||||
<Input
|
||||
value={flowName}
|
||||
onChange={(e) => setFlowName(e.target.value)}
|
||||
className="h-8 w-[200px] text-sm"
|
||||
placeholder="플로우 이름"
|
||||
/>
|
||||
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
|
||||
{/* 실행 취소/다시 실행 */}
|
||||
<Button variant="ghost" size="sm" title="실행 취소" disabled>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" title="다시 실행" disabled>
|
||||
<Redo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
disabled={selectedNodes.length === 0}
|
||||
title={selectedNodes.length > 0 ? `${selectedNodes.length}개 노드 삭제` : "삭제할 노드를 선택하세요"}
|
||||
className="gap-1 text-red-600 hover:bg-red-50 hover:text-red-700 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{selectedNodes.length > 0 && <span className="text-xs">({selectedNodes.length})</span>}
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
|
||||
{/* 줌 컨트롤 */}
|
||||
<Button variant="ghost" size="sm" onClick={() => zoomIn()} title="확대">
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => zoomOut()} title="축소">
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => fitView()} title="전체 보기">
|
||||
<span className="text-xs">전체</span>
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
|
||||
{/* 불러오기 */}
|
||||
<Button variant="outline" size="sm" onClick={() => setShowLoadDialog(true)} className="gap-1">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<span className="text-xs">불러오기</span>
|
||||
</Button>
|
||||
|
||||
{/* 저장 */}
|
||||
<Button variant="outline" size="sm" onClick={handleSave} disabled={isSaving} className="gap-1">
|
||||
<Save className="h-4 w-4" />
|
||||
<span className="text-xs">{isSaving ? "저장 중..." : "저장"}</span>
|
||||
</Button>
|
||||
|
||||
{/* 내보내기 */}
|
||||
<Button variant="outline" size="sm" onClick={handleExport} className="gap-1">
|
||||
<Download className="h-4 w-4" />
|
||||
<span className="text-xs">JSON</span>
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
|
||||
{/* 검증 */}
|
||||
<Button variant="outline" size="sm" onClick={handleValidate} className="gap-1">
|
||||
<FileCheck className="h-4 w-4" />
|
||||
<span className="text-xs">검증</span>
|
||||
</Button>
|
||||
|
||||
{/* 테스트 실행 */}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleExecute}
|
||||
disabled={isExecuting}
|
||||
className="gap-1 bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
<span className="text-xs">{isExecuting ? "실행 중..." : "테스트 실행"}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 플로우 불러오기 다이얼로그
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Loader2, FileJson, Calendar, Trash2 } from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { getNodeFlows, deleteNodeFlow } from "@/lib/api/nodeFlows";
|
||||
|
||||
interface Flow {
|
||||
flowId: number;
|
||||
flowName: string;
|
||||
flowDescription: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface LoadFlowDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onLoad: (flowId: number) => void;
|
||||
}
|
||||
|
||||
export function LoadFlowDialog({ open, onOpenChange, onLoad }: LoadFlowDialogProps) {
|
||||
const [flows, setFlows] = useState<Flow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedFlowId, setSelectedFlowId] = useState<number | null>(null);
|
||||
const [deleting, setDeleting] = useState<number | null>(null);
|
||||
|
||||
// 플로우 목록 조회
|
||||
const fetchFlows = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const flows = await getNodeFlows();
|
||||
setFlows(flows);
|
||||
} catch (error) {
|
||||
console.error("플로우 목록 조회 오류:", error);
|
||||
alert(error instanceof Error ? error.message : "플로우 목록을 불러올 수 없습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 플로우 삭제
|
||||
const handleDelete = async (flowId: number, flowName: string) => {
|
||||
if (!confirm(`"${flowName}" 플로우를 삭제하시겠습니까?\n\n이 작업은 되돌릴 수 없습니다.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleting(flowId);
|
||||
try {
|
||||
await deleteNodeFlow(flowId);
|
||||
alert("✅ 플로우가 삭제되었습니다.");
|
||||
fetchFlows(); // 목록 새로고침
|
||||
} catch (error) {
|
||||
console.error("플로우 삭제 오류:", error);
|
||||
alert(error instanceof Error ? error.message : "플로우를 삭제할 수 없습니다.");
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 플로우 불러오기
|
||||
const handleLoad = () => {
|
||||
if (selectedFlowId === null) {
|
||||
alert("불러올 플로우를 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
onLoad(selectedFlowId);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
// 다이얼로그 열릴 때 목록 조회
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchFlows();
|
||||
setSelectedFlowId(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// 날짜 포맷팅
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat("ko-KR", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>플로우 불러오기</DialogTitle>
|
||||
<DialogDescription>저장된 플로우를 선택하여 불러옵니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : flows.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<FileJson className="mx-auto mb-4 h-12 w-12 text-gray-300" />
|
||||
<p className="text-sm text-gray-500">저장된 플로우가 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-[400px]">
|
||||
<div className="space-y-2 pr-4">
|
||||
{flows.map((flow) => (
|
||||
<div
|
||||
key={flow.flowId}
|
||||
className={`cursor-pointer rounded-lg border-2 p-4 transition-all hover:border-blue-300 hover:bg-blue-50 ${
|
||||
selectedFlowId === flow.flowId ? "border-blue-500 bg-blue-50" : "border-gray-200 bg-white"
|
||||
}`}
|
||||
onClick={() => setSelectedFlowId(flow.flowId)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-gray-900">{flow.flowName}</h3>
|
||||
<span className="text-xs text-gray-400">#{flow.flowId}</span>
|
||||
</div>
|
||||
{flow.flowDescription && <p className="mt-1 text-sm text-gray-600">{flow.flowDescription}</p>}
|
||||
<div className="mt-2 flex items-center gap-4 text-xs text-gray-500">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>수정: {formatDate(flow.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(flow.flowId, flow.flowName);
|
||||
}}
|
||||
disabled={deleting === flow.flowId}
|
||||
className="text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
>
|
||||
{deleting === flow.flowId ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleLoad} disabled={selectedFlowId === null || loading}>
|
||||
불러오기
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 주석 노드 - 플로우 설명용
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { NodeProps } from "reactflow";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import type { CommentNodeData } from "@/types/node-editor";
|
||||
|
||||
export const CommentNode = memo(({ data, selected }: NodeProps<CommentNodeData>) => {
|
||||
return (
|
||||
<div
|
||||
className={`max-w-[350px] min-w-[200px] rounded-lg border-2 border-dashed bg-yellow-50 shadow-sm transition-all ${
|
||||
selected ? "border-yellow-500 shadow-md" : "border-yellow-300"
|
||||
}`}
|
||||
>
|
||||
<div className="p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4 text-yellow-600" />
|
||||
<span className="text-xs font-semibold text-yellow-800">메모</span>
|
||||
</div>
|
||||
<div className="text-sm whitespace-pre-wrap text-gray-700">{data.content || "메모를 입력하세요..."}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
CommentNode.displayName = "CommentNode";
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 조건 분기 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Zap, Check, X } from "lucide-react";
|
||||
import type { ConditionNodeData } from "@/types/node-editor";
|
||||
|
||||
const OPERATOR_LABELS: Record<string, string> = {
|
||||
EQUALS: "=",
|
||||
NOT_EQUALS: "≠",
|
||||
GREATER_THAN: ">",
|
||||
LESS_THAN: "<",
|
||||
GREATER_THAN_OR_EQUAL: "≥",
|
||||
LESS_THAN_OR_EQUAL: "≤",
|
||||
LIKE: "포함",
|
||||
NOT_LIKE: "미포함",
|
||||
IN: "IN",
|
||||
NOT_IN: "NOT IN",
|
||||
IS_NULL: "NULL",
|
||||
IS_NOT_NULL: "NOT NULL",
|
||||
};
|
||||
|
||||
export const ConditionNode = memo(({ data, selected }: NodeProps<ConditionNodeData>) => {
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[280px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-yellow-500 shadow-lg" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* 입력 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !border-2 !border-yellow-500 !bg-white" />
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-yellow-500 px-3 py-2 text-white">
|
||||
<Zap className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">조건 검사</div>
|
||||
<div className="text-xs opacity-80">{data.displayName || "조건 분기"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
{data.conditions && data.conditions.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-gray-700">조건식: ({data.conditions.length}개)</div>
|
||||
<div className="max-h-[150px] space-y-1.5 overflow-y-auto">
|
||||
{data.conditions.slice(0, 4).map((condition, idx) => (
|
||||
<div key={idx} className="rounded bg-yellow-50 px-2 py-1.5 text-xs">
|
||||
{idx > 0 && (
|
||||
<div className="mb-1 text-center text-xs font-semibold text-yellow-600">{data.logic}</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-mono text-gray-700">{condition.field}</span>
|
||||
<span className="rounded bg-yellow-200 px-1 py-0.5 text-yellow-800">
|
||||
{OPERATOR_LABELS[condition.operator] || condition.operator}
|
||||
</span>
|
||||
{condition.value !== null && condition.value !== undefined && (
|
||||
<span className="text-gray-600">
|
||||
{typeof condition.value === "string" ? `"${condition.value}"` : String(condition.value)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{data.conditions.length > 4 && (
|
||||
<div className="text-xs text-gray-400">... 외 {data.conditions.length - 4}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-xs text-gray-400">조건 없음</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 분기 출력 핸들 */}
|
||||
<div className="border-t">
|
||||
<div className="grid grid-cols-2">
|
||||
{/* TRUE 출력 */}
|
||||
<div className="relative border-r p-2">
|
||||
<div className="flex items-center justify-center gap-1 text-xs">
|
||||
<Check className="h-3 w-3 text-green-600" />
|
||||
<span className="font-medium text-green-600">TRUE</span>
|
||||
</div>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="true"
|
||||
className="!-right-1.5 !h-3 !w-3 !border-2 !border-green-500 !bg-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* FALSE 출력 */}
|
||||
<div className="relative p-2">
|
||||
<div className="flex items-center justify-center gap-1 text-xs">
|
||||
<X className="h-3 w-3 text-red-600" />
|
||||
<span className="font-medium text-red-600">FALSE</span>
|
||||
</div>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="false"
|
||||
className="!-right-1.5 !h-3 !w-3 !border-2 !border-red-500 !bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ConditionNode.displayName = "ConditionNode";
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 데이터 변환 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Wand2, ArrowRight } from "lucide-react";
|
||||
import type { DataTransformNodeData } from "@/types/node-editor";
|
||||
|
||||
export const DataTransformNode = memo(({ data, selected }: NodeProps<DataTransformNodeData>) => {
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-orange-500 shadow-lg" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-indigo-600 px-3 py-2 text-white">
|
||||
<Wand2 className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || "데이터 변환"}</div>
|
||||
<div className="text-xs opacity-80">{data.transformations?.length || 0}개 변환</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
{data.transformations && data.transformations.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{data.transformations.slice(0, 3).map((transform, idx) => {
|
||||
const sourceLabel = transform.sourceFieldLabel || transform.sourceField || "소스";
|
||||
const targetField = transform.targetField || transform.sourceField;
|
||||
const targetLabel = transform.targetFieldLabel || targetField;
|
||||
const isInPlace = !transform.targetField || transform.targetField === transform.sourceField;
|
||||
|
||||
return (
|
||||
<div key={idx} className="rounded bg-indigo-50 p-2">
|
||||
<div className="mb-1 flex items-center gap-2 text-xs">
|
||||
<span className="font-medium text-indigo-700">{transform.type}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">
|
||||
{sourceLabel}
|
||||
<span className="mx-1 text-gray-400">→</span>
|
||||
{isInPlace ? (
|
||||
<span className="font-medium text-indigo-600">(자기자신)</span>
|
||||
) : (
|
||||
<span>{targetLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 타입별 추가 정보 */}
|
||||
{transform.type === "EXPLODE" && transform.delimiter && (
|
||||
<div className="mt-1 text-xs text-gray-500">구분자: {transform.delimiter}</div>
|
||||
)}
|
||||
{transform.type === "CONCAT" && transform.separator && (
|
||||
<div className="mt-1 text-xs text-gray-500">구분자: {transform.separator}</div>
|
||||
)}
|
||||
{transform.type === "REPLACE" && (
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
"{transform.searchValue}" → "{transform.replaceValue}"
|
||||
</div>
|
||||
)}
|
||||
{transform.expression && (
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
<code className="rounded bg-white px-1 py-0.5">{transform.expression}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{data.transformations.length > 3 && (
|
||||
<div className="text-xs text-gray-400">... 외 {data.transformations.length - 3}개</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4 text-center text-xs text-gray-400">변환 규칙 없음</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !bg-indigo-500" />
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-indigo-500" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
DataTransformNode.displayName = "DataTransformNode";
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* DELETE 액션 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Trash2, AlertTriangle } from "lucide-react";
|
||||
import type { DeleteActionNodeData } from "@/types/node-editor";
|
||||
|
||||
export const DeleteActionNode = memo(({ data, selected }: NodeProps<DeleteActionNodeData>) => {
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-red-500 shadow-lg" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* 입력 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !border-2 !border-red-500 !bg-white" />
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-red-500 px-3 py-2 text-white">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">DELETE</div>
|
||||
<div className="text-xs opacity-80">{data.displayName || data.targetTable}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
<div className="mb-2 text-xs font-medium text-gray-500">타겟: {data.targetTable}</div>
|
||||
|
||||
{/* WHERE 조건 */}
|
||||
{data.whereConditions && data.whereConditions.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-gray-700">WHERE 조건:</div>
|
||||
<div className="max-h-[120px] space-y-1 overflow-y-auto">
|
||||
{data.whereConditions.map((condition, idx) => (
|
||||
<div key={idx} className="rounded bg-red-50 px-2 py-1 text-xs">
|
||||
<span className="font-mono text-gray-700">{condition.field}</span>
|
||||
<span className="mx-1 text-red-600">{condition.operator}</span>
|
||||
<span className="text-gray-600">{condition.sourceField || condition.staticValue || "?"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded bg-yellow-50 p-2 text-xs text-yellow-700">⚠️ 조건 없음 - 모든 데이터 삭제 주의!</div>
|
||||
)}
|
||||
|
||||
{/* 경고 메시지 */}
|
||||
<div className="mt-3 flex items-start gap-2 rounded border border-red-200 bg-red-50 p-2">
|
||||
<AlertTriangle className="h-3 w-3 flex-shrink-0 text-red-600" />
|
||||
<div className="text-xs text-red-700">
|
||||
<div className="font-medium">주의</div>
|
||||
<div className="mt-0.5">삭제된 데이터는 복구할 수 없습니다</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 옵션 */}
|
||||
{data.options?.requireConfirmation && (
|
||||
<div className="mt-2">
|
||||
<span className="rounded bg-red-100 px-1.5 py-0.5 text-xs text-red-700">실행 전 확인 필요</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 출력 핸들 */}
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !border-2 !border-red-500 !bg-white" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
DeleteActionNode.displayName = "DeleteActionNode";
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 외부 DB 소스 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Plug } from "lucide-react";
|
||||
import type { ExternalDBSourceNodeData } from "@/types/node-editor";
|
||||
|
||||
const DB_TYPE_COLORS: Record<string, string> = {
|
||||
PostgreSQL: "#336791",
|
||||
MySQL: "#4479A1",
|
||||
Oracle: "#F80000",
|
||||
MSSQL: "#CC2927",
|
||||
MariaDB: "#003545",
|
||||
};
|
||||
|
||||
const DB_TYPE_ICONS: Record<string, string> = {
|
||||
PostgreSQL: "🐘",
|
||||
MySQL: "🐬",
|
||||
Oracle: "🔴",
|
||||
MSSQL: "🟦",
|
||||
MariaDB: "🦭",
|
||||
};
|
||||
|
||||
export const ExternalDBSourceNode = memo(({ data, selected }: NodeProps<ExternalDBSourceNodeData>) => {
|
||||
const dbColor = (data.dbType && DB_TYPE_COLORS[data.dbType]) || "#F59E0B";
|
||||
const dbIcon = (data.dbType && DB_TYPE_ICONS[data.dbType]) || "🔌";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-orange-500 shadow-lg" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg px-3 py-2 text-white" style={{ backgroundColor: dbColor }}>
|
||||
<Plug className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || data.connectionName}</div>
|
||||
<div className="text-xs opacity-80">{data.tableName}</div>
|
||||
</div>
|
||||
<span className="text-lg">{dbIcon}</span>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
<div className="mb-2 flex items-center gap-1 text-xs">
|
||||
<div className="rounded bg-orange-100 px-2 py-0.5 font-medium text-orange-700">{data.dbType || "DB"}</div>
|
||||
<div className="flex-1 text-gray-500">외부 DB</div>
|
||||
</div>
|
||||
|
||||
{/* 필드 목록 */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-gray-700">출력 필드:</div>
|
||||
<div className="max-h-[150px] overflow-y-auto">
|
||||
{data.fields && data.fields.length > 0 ? (
|
||||
data.fields.slice(0, 5).map((field) => (
|
||||
<div key={field.name} className="flex items-center gap-2 text-xs text-gray-600">
|
||||
<div className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: dbColor }} />
|
||||
<span className="font-mono">{field.name}</span>
|
||||
<span className="text-gray-400">({field.type})</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-xs text-gray-400">필드 없음</div>
|
||||
)}
|
||||
{data.fields && data.fields.length > 5 && (
|
||||
<div className="text-xs text-gray-400">... 외 {data.fields.length - 5}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 출력 핸들 */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!h-3 !w-3 !border-2 !bg-white"
|
||||
style={{ borderColor: dbColor }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ExternalDBSourceNode.displayName = "ExternalDBSourceNode";
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 필드 매핑 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { ArrowLeftRight } from "lucide-react";
|
||||
import type { FieldMappingNodeData } from "@/types/node-editor";
|
||||
|
||||
export const FieldMappingNode = memo(({ data, selected }: NodeProps<FieldMappingNodeData>) => {
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-purple-500 shadow-lg" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* 입력 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !border-2 !border-purple-500 !bg-white" />
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-purple-500 px-3 py-2 text-white">
|
||||
<ArrowLeftRight className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">필드 매핑</div>
|
||||
<div className="text-xs opacity-80">{data.displayName || "데이터 매핑"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
{data.mappings && data.mappings.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-gray-700">매핑 규칙: ({data.mappings.length}개)</div>
|
||||
<div className="max-h-[150px] space-y-1 overflow-y-auto">
|
||||
{data.mappings.slice(0, 5).map((mapping) => (
|
||||
<div key={mapping.id} className="rounded bg-gray-50 px-2 py-1 text-xs">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-gray-600">{mapping.sourceField || "정적값"}</span>
|
||||
<span className="text-purple-500">→</span>
|
||||
<span className="font-mono text-gray-700">{mapping.targetField}</span>
|
||||
</div>
|
||||
{mapping.transform && <div className="mt-0.5 text-xs text-gray-400">변환: {mapping.transform}</div>}
|
||||
{mapping.staticValue !== undefined && (
|
||||
<div className="mt-0.5 text-xs text-gray-400">값: {String(mapping.staticValue)}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{data.mappings.length > 5 && (
|
||||
<div className="text-xs text-gray-400">... 외 {data.mappings.length - 5}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-xs text-gray-400">매핑 규칙 없음</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 출력 핸들 */}
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !border-2 !border-purple-500 !bg-white" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
FieldMappingNode.displayName = "FieldMappingNode";
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* INSERT 액션 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Plus } from "lucide-react";
|
||||
import type { InsertActionNodeData } from "@/types/node-editor";
|
||||
|
||||
export const InsertActionNode = memo(({ data, selected }: NodeProps<InsertActionNodeData>) => {
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-green-500 shadow-lg" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* 입력 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !border-2 !border-green-500 !bg-white" />
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-green-500 px-3 py-2 text-white">
|
||||
<Plus className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">INSERT</div>
|
||||
<div className="text-xs opacity-80">{data.displayName || data.targetTable}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
<div className="mb-2 text-xs font-medium text-gray-500">
|
||||
타겟: {data.displayName || data.targetTable}
|
||||
{data.targetTable && data.displayName && data.displayName !== data.targetTable && (
|
||||
<span className="ml-1 font-mono text-gray-400">({data.targetTable})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 필드 매핑 */}
|
||||
{data.fieldMappings && data.fieldMappings.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-gray-700">삽입 필드:</div>
|
||||
<div className="max-h-[120px] space-y-1 overflow-y-auto">
|
||||
{data.fieldMappings.slice(0, 4).map((mapping, idx) => (
|
||||
<div key={idx} className="rounded bg-gray-50 px-2 py-1 text-xs">
|
||||
<span className="text-gray-600">
|
||||
{mapping.sourceFieldLabel || mapping.sourceField || mapping.staticValue || "?"}
|
||||
</span>
|
||||
<span className="mx-1 text-gray-400">→</span>
|
||||
<span className="font-mono text-gray-700">{mapping.targetFieldLabel || mapping.targetField}</span>
|
||||
</div>
|
||||
))}
|
||||
{data.fieldMappings.length > 4 && (
|
||||
<div className="text-xs text-gray-400">... 외 {data.fieldMappings.length - 4}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 옵션 */}
|
||||
{data.options && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{data.options.ignoreDuplicates && (
|
||||
<span className="rounded bg-green-100 px-1.5 py-0.5 text-xs text-green-700">중복 무시</span>
|
||||
)}
|
||||
{data.options.batchSize && (
|
||||
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700">
|
||||
배치 {data.options.batchSize}건
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 출력 핸들 */}
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !border-2 !border-green-500 !bg-white" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
InsertActionNode.displayName = "InsertActionNode";
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 로그 노드 - 디버깅 및 모니터링용
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { FileText, AlertCircle, Info, AlertTriangle } from "lucide-react";
|
||||
import type { LogNodeData } from "@/types/node-editor";
|
||||
|
||||
const LOG_LEVEL_CONFIG = {
|
||||
debug: { icon: Info, color: "text-blue-600", bg: "bg-blue-50", border: "border-blue-200" },
|
||||
info: { icon: Info, color: "text-green-600", bg: "bg-green-50", border: "border-green-200" },
|
||||
warn: { icon: AlertTriangle, color: "text-yellow-600", bg: "bg-yellow-50", border: "border-yellow-200" },
|
||||
error: { icon: AlertCircle, color: "text-red-600", bg: "bg-red-50", border: "border-red-200" },
|
||||
};
|
||||
|
||||
export const LogNode = memo(({ data, selected }: NodeProps<LogNodeData>) => {
|
||||
const config = LOG_LEVEL_CONFIG[data.level] || LOG_LEVEL_CONFIG.info;
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[200px] rounded-lg border-2 bg-white shadow-sm transition-all ${
|
||||
selected ? `${config.border} shadow-md` : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className={`flex items-center gap-2 rounded-t-lg ${config.bg} px-3 py-2`}>
|
||||
<FileText className={`h-4 w-4 ${config.color}`} />
|
||||
<div className="flex-1">
|
||||
<div className={`text-sm font-semibold ${config.color}`}>로그</div>
|
||||
<div className="text-xs text-gray-600">{data.level.toUpperCase()}</div>
|
||||
</div>
|
||||
<Icon className={`h-4 w-4 ${config.color}`} />
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
{data.message ? (
|
||||
<div className="text-sm text-gray-700">{data.message}</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-400">로그 메시지 없음</div>
|
||||
)}
|
||||
|
||||
{data.includeData && (
|
||||
<div className="mt-2 rounded bg-gray-50 px-2 py-1 text-xs text-gray-600">✓ 데이터 포함</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !bg-gray-400" />
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-gray-400" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
LogNode.displayName = "LogNode";
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* REST API 소스 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Globe, Lock } from "lucide-react";
|
||||
import type { RestAPISourceNodeData } from "@/types/node-editor";
|
||||
|
||||
const METHOD_COLORS: Record<string, string> = {
|
||||
GET: "bg-green-100 text-green-700",
|
||||
POST: "bg-blue-100 text-blue-700",
|
||||
PUT: "bg-yellow-100 text-yellow-700",
|
||||
DELETE: "bg-red-100 text-red-700",
|
||||
PATCH: "bg-purple-100 text-purple-700",
|
||||
};
|
||||
|
||||
export const RestAPISourceNode = memo(({ data, selected }: NodeProps<RestAPISourceNodeData>) => {
|
||||
const methodColor = METHOD_COLORS[data.method] || "bg-gray-100 text-gray-700";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-orange-500 shadow-lg" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-teal-600 px-3 py-2 text-white">
|
||||
<Globe className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || "REST API"}</div>
|
||||
<div className="text-xs opacity-80">{data.url || "URL 미설정"}</div>
|
||||
</div>
|
||||
{data.authentication && <Lock className="h-4 w-4 opacity-70" />}
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
{/* HTTP 메서드 */}
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className={`rounded px-2 py-1 text-xs font-semibold ${methodColor}`}>{data.method}</span>
|
||||
{data.timeout && <span className="text-xs text-gray-500">{data.timeout}ms</span>}
|
||||
</div>
|
||||
|
||||
{/* 헤더 */}
|
||||
{data.headers && Object.keys(data.headers).length > 0 && (
|
||||
<div className="mb-2">
|
||||
<div className="text-xs font-medium text-gray-700">헤더:</div>
|
||||
<div className="mt-1 space-y-1">
|
||||
{Object.entries(data.headers)
|
||||
.slice(0, 2)
|
||||
.map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-2 text-xs text-gray-600">
|
||||
<span className="font-mono">{key}:</span>
|
||||
<span className="truncate text-gray-500">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
{Object.keys(data.headers).length > 2 && (
|
||||
<div className="text-xs text-gray-400">... 외 {Object.keys(data.headers).length - 2}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 응답 매핑 */}
|
||||
{data.responseMapping && (
|
||||
<div className="rounded bg-teal-50 px-2 py-1 text-xs text-teal-700">
|
||||
응답 경로: <code className="font-mono">{data.responseMapping}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 핸들 */}
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-teal-500" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
RestAPISourceNode.displayName = "RestAPISourceNode";
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 테이블 소스 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Database } from "lucide-react";
|
||||
import type { TableSourceNodeData } from "@/types/node-editor";
|
||||
|
||||
export const TableSourceNode = memo(({ data, selected }: NodeProps<TableSourceNodeData>) => {
|
||||
// 디버깅: 필드 데이터 확인
|
||||
if (data.fields && data.fields.length > 0) {
|
||||
console.log("🔍 TableSource 필드 데이터:", data.fields);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-blue-500 shadow-lg" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-blue-500 px-3 py-2 text-white">
|
||||
<Database className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || data.tableName || "테이블 소스"}</div>
|
||||
{data.tableName && data.displayName !== data.tableName && (
|
||||
<div className="text-xs opacity-80">{data.tableName}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
<div className="mb-2 text-xs font-medium text-gray-500">📍 내부 데이터베이스</div>
|
||||
|
||||
{/* 필드 목록 */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-gray-700">출력 필드:</div>
|
||||
<div className="max-h-[150px] overflow-y-auto">
|
||||
{data.fields && data.fields.length > 0 ? (
|
||||
data.fields.slice(0, 5).map((field) => (
|
||||
<div key={field.name} className="flex items-center gap-2 text-xs text-gray-600">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-blue-400" />
|
||||
<span className="font-medium">{field.label || field.displayName || field.name}</span>
|
||||
{(field.label || field.displayName) && field.label !== field.name && (
|
||||
<span className="font-mono text-gray-400">({field.name})</span>
|
||||
)}
|
||||
<span className="text-gray-400">{field.type}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-xs text-gray-400">필드 없음</div>
|
||||
)}
|
||||
{data.fields && data.fields.length > 5 && (
|
||||
<div className="text-xs text-gray-400">... 외 {data.fields.length - 5}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 출력 핸들 */}
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !border-2 !border-blue-500 !bg-white" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
TableSourceNode.displayName = "TableSourceNode";
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* UPDATE 액션 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Edit } from "lucide-react";
|
||||
import type { UpdateActionNodeData } from "@/types/node-editor";
|
||||
|
||||
export const UpdateActionNode = memo(({ data, selected }: NodeProps<UpdateActionNodeData>) => {
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-blue-500 shadow-lg" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* 입력 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !border-2 !border-blue-500 !bg-white" />
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-blue-500 px-3 py-2 text-white">
|
||||
<Edit className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">UPDATE</div>
|
||||
<div className="text-xs opacity-80">{data.displayName || data.targetTable}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
<div className="mb-2 text-xs font-medium text-gray-500">
|
||||
타겟: {data.displayName || data.targetTable}
|
||||
{data.targetTable && data.displayName && data.displayName !== data.targetTable && (
|
||||
<span className="ml-1 font-mono text-gray-400">({data.targetTable})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* WHERE 조건 */}
|
||||
{data.whereConditions && data.whereConditions.length > 0 && (
|
||||
<div className="mb-3 space-y-1">
|
||||
<div className="text-xs font-medium text-gray-700">WHERE 조건:</div>
|
||||
<div className="max-h-[80px] space-y-1 overflow-y-auto">
|
||||
{data.whereConditions.slice(0, 2).map((condition, idx) => (
|
||||
<div key={idx} className="rounded bg-blue-50 px-2 py-1 text-xs">
|
||||
<span className="font-mono text-gray-700">{condition.fieldLabel || condition.field}</span>
|
||||
<span className="mx-1 text-blue-600">{condition.operator}</span>
|
||||
<span className="text-gray-600">
|
||||
{condition.sourceFieldLabel || condition.sourceField || condition.staticValue || "?"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{data.whereConditions.length > 2 && (
|
||||
<div className="text-xs text-gray-400">... 외 {data.whereConditions.length - 2}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 필드 매핑 */}
|
||||
{data.fieldMappings && data.fieldMappings.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-gray-700">업데이트 필드:</div>
|
||||
<div className="max-h-[100px] space-y-1 overflow-y-auto">
|
||||
{data.fieldMappings.slice(0, 3).map((mapping, idx) => (
|
||||
<div key={idx} className="rounded bg-gray-50 px-2 py-1 text-xs">
|
||||
<span className="text-gray-600">
|
||||
{mapping.sourceFieldLabel || mapping.sourceField || mapping.staticValue || "?"}
|
||||
</span>
|
||||
<span className="mx-1 text-gray-400">→</span>
|
||||
<span className="font-mono text-gray-700">{mapping.targetFieldLabel || mapping.targetField}</span>
|
||||
</div>
|
||||
))}
|
||||
{data.fieldMappings.length > 3 && (
|
||||
<div className="text-xs text-gray-400">... 외 {data.fieldMappings.length - 3}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 옵션 */}
|
||||
{data.options && data.options.batchSize && (
|
||||
<div className="mt-2">
|
||||
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700">
|
||||
배치 {data.options.batchSize}건
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 출력 핸들 */}
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !border-2 !border-blue-500 !bg-white" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
UpdateActionNode.displayName = "UpdateActionNode";
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* UPSERT 액션 노드
|
||||
* INSERT와 UPDATE를 결합한 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Database, RefreshCw } from "lucide-react";
|
||||
import type { UpsertActionNodeData } from "@/types/node-editor";
|
||||
|
||||
export const UpsertActionNode = memo(({ data, selected }: NodeProps<UpsertActionNodeData>) => {
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-orange-500 shadow-lg" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-purple-600 px-3 py-2 text-white">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || "UPSERT 액션"}</div>
|
||||
<div className="text-xs opacity-80">{data.targetTable}</div>
|
||||
</div>
|
||||
<Database className="h-4 w-4 opacity-70" />
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
<div className="mb-2 text-xs font-medium text-gray-500">
|
||||
타겟: {data.displayName || data.targetTable}
|
||||
{data.targetTable && data.displayName && data.displayName !== data.targetTable && (
|
||||
<span className="ml-1 font-mono text-gray-400">({data.targetTable})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 충돌 키 */}
|
||||
{data.conflictKeys && data.conflictKeys.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<div className="text-xs font-medium text-gray-700">충돌 키:</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{data.conflictKeys.map((key, idx) => (
|
||||
<span key={idx} className="rounded bg-purple-100 px-2 py-0.5 text-xs text-purple-700">
|
||||
{data.conflictKeyLabels?.[idx] || key}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 필드 매핑 */}
|
||||
{data.fieldMappings && data.fieldMappings.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<div className="text-xs font-medium text-gray-700">필드 매핑:</div>
|
||||
<div className="mt-1 space-y-1">
|
||||
{data.fieldMappings.slice(0, 3).map((mapping, idx) => (
|
||||
<div key={idx} className="rounded bg-gray-50 px-2 py-1 text-xs">
|
||||
<span className="text-gray-600">
|
||||
{mapping.sourceFieldLabel || mapping.sourceField || mapping.staticValue || "?"}
|
||||
</span>
|
||||
<span className="mx-1 text-gray-400">→</span>
|
||||
<span className="font-mono text-gray-700">{mapping.targetFieldLabel || mapping.targetField}</span>
|
||||
</div>
|
||||
))}
|
||||
{data.fieldMappings.length > 3 && (
|
||||
<div className="text-xs text-gray-400">... 외 {data.fieldMappings.length - 3}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 옵션 */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{data.options?.updateOnConflict && (
|
||||
<span className="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700">충돌 시 업데이트</span>
|
||||
)}
|
||||
{data.options?.batchSize && (
|
||||
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
|
||||
배치: {data.options.batchSize}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !bg-purple-500" />
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-purple-500" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
UpsertActionNode.displayName = "UpsertActionNode";
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 노드 속성 편집 패널
|
||||
*/
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { TableSourceProperties } from "./properties/TableSourceProperties";
|
||||
import { InsertActionProperties } from "./properties/InsertActionProperties";
|
||||
import { FieldMappingProperties } from "./properties/FieldMappingProperties";
|
||||
import { ConditionProperties } from "./properties/ConditionProperties";
|
||||
import { UpdateActionProperties } from "./properties/UpdateActionProperties";
|
||||
import { DeleteActionProperties } from "./properties/DeleteActionProperties";
|
||||
import { ExternalDBSourceProperties } from "./properties/ExternalDBSourceProperties";
|
||||
import { UpsertActionProperties } from "./properties/UpsertActionProperties";
|
||||
import { DataTransformProperties } from "./properties/DataTransformProperties";
|
||||
import { RestAPISourceProperties } from "./properties/RestAPISourceProperties";
|
||||
import { CommentProperties } from "./properties/CommentProperties";
|
||||
import { LogProperties } from "./properties/LogProperties";
|
||||
import type { NodeType } from "@/types/node-editor";
|
||||
|
||||
export function PropertiesPanel() {
|
||||
const { nodes, selectedNodes, setShowPropertiesPanel } = useFlowEditorStore();
|
||||
|
||||
// 선택된 노드가 하나일 경우 해당 노드 데이터 가져오기
|
||||
const selectedNode = selectedNodes.length === 1 ? nodes.find((n) => n.id === selectedNodes[0]) : null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">속성</h3>
|
||||
{selectedNode && (
|
||||
<p className="mt-0.5 text-xs text-gray-500">{getNodeTypeLabel(selectedNode.type as NodeType)}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowPropertiesPanel(false)} className="h-6 w-6 p-0">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 내용 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{selectedNodes.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center p-4">
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
<div className="mb-2 text-2xl">📝</div>
|
||||
<p>노드를 선택하여</p>
|
||||
<p>속성을 편집하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
) : selectedNodes.length === 1 && selectedNode ? (
|
||||
<NodePropertiesRenderer node={selectedNode} />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center p-4">
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
<div className="mb-2 text-2xl">📋</div>
|
||||
<p>{selectedNodes.length}개의 노드가</p>
|
||||
<p>선택되었습니다</p>
|
||||
<p className="mt-2 text-xs">한 번에 하나의 노드만 편집할 수 있습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 노드 타입별 속성 렌더러
|
||||
*/
|
||||
function NodePropertiesRenderer({ node }: { node: any }) {
|
||||
switch (node.type) {
|
||||
case "tableSource":
|
||||
return <TableSourceProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "insertAction":
|
||||
return <InsertActionProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "fieldMapping":
|
||||
return <FieldMappingProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "condition":
|
||||
return <ConditionProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "updateAction":
|
||||
return <UpdateActionProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "deleteAction":
|
||||
return <DeleteActionProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "externalDBSource":
|
||||
return <ExternalDBSourceProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "upsertAction":
|
||||
return <UpsertActionProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "dataTransform":
|
||||
return <DataTransformProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "restAPISource":
|
||||
return <RestAPISourceProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "comment":
|
||||
return <CommentProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "log":
|
||||
return <LogProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="rounded border border-yellow-200 bg-yellow-50 p-4 text-sm">
|
||||
<p className="font-medium text-yellow-800">🚧 속성 편집 준비 중</p>
|
||||
<p className="mt-2 text-xs text-yellow-700">
|
||||
{getNodeTypeLabel(node.type as NodeType)} 노드의 속성 편집 UI는 곧 구현될 예정입니다.
|
||||
</p>
|
||||
<div className="mt-3 rounded bg-white p-2 text-xs">
|
||||
<p className="font-medium text-gray-700">노드 ID:</p>
|
||||
<p className="font-mono text-gray-600">{node.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 노드 타입 라벨 가져오기
|
||||
*/
|
||||
function getNodeTypeLabel(type: NodeType): string {
|
||||
const labels: Record<NodeType, string> = {
|
||||
tableSource: "테이블 소스",
|
||||
externalDBSource: "외부 DB 소스",
|
||||
restAPISource: "REST API 소스",
|
||||
condition: "조건 분기",
|
||||
fieldMapping: "필드 매핑",
|
||||
dataTransform: "데이터 변환",
|
||||
insertAction: "INSERT 액션",
|
||||
updateAction: "UPDATE 액션",
|
||||
deleteAction: "DELETE 액션",
|
||||
upsertAction: "UPSERT 액션",
|
||||
comment: "주석",
|
||||
log: "로그",
|
||||
};
|
||||
return labels[type] || type;
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { CommentNodeData } from "@/types/node-editor";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
|
||||
interface CommentPropertiesProps {
|
||||
nodeId: string;
|
||||
data: CommentNodeData;
|
||||
}
|
||||
|
||||
export function CommentProperties({ nodeId, data }: CommentPropertiesProps) {
|
||||
const { updateNode } = useFlowEditorStore();
|
||||
|
||||
const [content, setContent] = useState(data.content || "");
|
||||
|
||||
useEffect(() => {
|
||||
setContent(data.content || "");
|
||||
}, [data]);
|
||||
|
||||
const handleApply = () => {
|
||||
updateNode(nodeId, {
|
||||
content,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="flex items-center gap-2 rounded-md bg-yellow-50 p-2">
|
||||
<MessageSquare className="h-4 w-4 text-yellow-600" />
|
||||
<span className="font-semibold text-yellow-600">주석</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="content" className="text-xs">
|
||||
메모 내용
|
||||
</Label>
|
||||
<Textarea
|
||||
id="content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="플로우 설명이나 메모를 입력하세요..."
|
||||
className="mt-1 text-sm"
|
||||
rows={8}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">이 노드는 플로우 실행에 영향을 주지 않습니다.</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleApply} className="w-full">
|
||||
적용
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 조건 분기 노드 속성 편집
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import type { ConditionNodeData } from "@/types/node-editor";
|
||||
|
||||
interface ConditionPropertiesProps {
|
||||
nodeId: string;
|
||||
data: ConditionNodeData;
|
||||
}
|
||||
|
||||
const OPERATORS = [
|
||||
{ value: "EQUALS", label: "같음 (=)" },
|
||||
{ value: "NOT_EQUALS", label: "같지 않음 (≠)" },
|
||||
{ value: "GREATER_THAN", label: "보다 큼 (>)" },
|
||||
{ value: "LESS_THAN", label: "보다 작음 (<)" },
|
||||
{ value: "GREATER_THAN_OR_EQUAL", label: "크거나 같음 (≥)" },
|
||||
{ value: "LESS_THAN_OR_EQUAL", label: "작거나 같음 (≤)" },
|
||||
{ value: "LIKE", label: "포함 (LIKE)" },
|
||||
{ value: "NOT_LIKE", label: "미포함 (NOT LIKE)" },
|
||||
{ value: "IN", label: "IN" },
|
||||
{ value: "NOT_IN", label: "NOT IN" },
|
||||
{ value: "IS_NULL", label: "NULL" },
|
||||
{ value: "IS_NOT_NULL", label: "NOT NULL" },
|
||||
] as const;
|
||||
|
||||
export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) {
|
||||
const { updateNode } = useFlowEditorStore();
|
||||
|
||||
const [displayName, setDisplayName] = useState(data.displayName || "조건 분기");
|
||||
const [conditions, setConditions] = useState(data.conditions || []);
|
||||
const [logic, setLogic] = useState<"AND" | "OR">(data.logic || "AND");
|
||||
|
||||
// 데이터 변경 시 로컬 상태 업데이트
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || "조건 분기");
|
||||
setConditions(data.conditions || []);
|
||||
setLogic(data.logic || "AND");
|
||||
}, [data]);
|
||||
|
||||
const handleAddCondition = () => {
|
||||
setConditions([
|
||||
...conditions,
|
||||
{
|
||||
field: "",
|
||||
operator: "EQUALS",
|
||||
value: "",
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRemoveCondition = (index: number) => {
|
||||
setConditions(conditions.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleConditionChange = (index: number, field: string, value: any) => {
|
||||
const newConditions = [...conditions];
|
||||
newConditions[index] = { ...newConditions[index], [field]: value };
|
||||
setConditions(newConditions);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
conditions,
|
||||
logic,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="displayName" className="text-xs">
|
||||
표시 이름
|
||||
</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="노드 표시 이름"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="logic" className="text-xs">
|
||||
조건 로직
|
||||
</Label>
|
||||
<Select value={logic} onValueChange={(value: "AND" | "OR") => setLogic(value)}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="AND">AND (모두 충족)</SelectItem>
|
||||
<SelectItem value="OR">OR (하나라도 충족)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 조건식 */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">조건식</h3>
|
||||
<Button size="sm" variant="outline" onClick={handleAddCondition} className="h-7">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{conditions.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{conditions.map((condition, index) => (
|
||||
<div key={index} className="rounded border bg-yellow-50 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-yellow-700">조건 #{index + 1}</span>
|
||||
{index > 0 && (
|
||||
<span className="rounded bg-yellow-200 px-1.5 py-0.5 text-xs font-semibold text-yellow-800">
|
||||
{logic}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveCondition(index)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">필드명</Label>
|
||||
<Input
|
||||
value={condition.field}
|
||||
onChange={(e) => handleConditionChange(index, "field", e.target.value)}
|
||||
placeholder="조건을 검사할 필드"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">연산자</Label>
|
||||
<Select
|
||||
value={condition.operator}
|
||||
onValueChange={(value) => handleConditionChange(index, "operator", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OPERATORS.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{condition.operator !== "IS_NULL" && condition.operator !== "IS_NOT_NULL" && (
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">비교 값</Label>
|
||||
<Input
|
||||
value={condition.value as string}
|
||||
onChange={(e) => handleConditionChange(index, "value", e.target.value)}
|
||||
placeholder="비교할 값"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded border border-dashed p-4 text-center text-xs text-gray-400">
|
||||
조건식이 없습니다. "추가" 버튼을 클릭하세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 저장 버튼 */}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSave} className="flex-1" size="sm">
|
||||
적용
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 안내 */}
|
||||
<div className="space-y-2">
|
||||
<div className="rounded bg-yellow-50 p-3 text-xs text-yellow-700">
|
||||
💡 <strong>AND</strong>: 모든 조건이 참이어야 TRUE 출력
|
||||
</div>
|
||||
<div className="rounded bg-yellow-50 p-3 text-xs text-yellow-700">
|
||||
💡 <strong>OR</strong>: 하나라도 참이면 TRUE 출력
|
||||
</div>
|
||||
<div className="rounded bg-yellow-50 p-3 text-xs text-yellow-700">
|
||||
⚡ TRUE 출력은 오른쪽 위, FALSE 출력은 오른쪽 아래입니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,466 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 데이터 변환 노드 속성 편집 (개선 버전)
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Plus, Trash2, Wand2, ArrowRight } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import type { DataTransformNodeData } from "@/types/node-editor";
|
||||
|
||||
interface DataTransformPropertiesProps {
|
||||
nodeId: string;
|
||||
data: DataTransformNodeData;
|
||||
}
|
||||
|
||||
const TRANSFORM_TYPES = [
|
||||
{ value: "UPPERCASE", label: "대문자 변환", category: "기본" },
|
||||
{ value: "LOWERCASE", label: "소문자 변환", category: "기본" },
|
||||
{ value: "TRIM", label: "공백 제거", category: "기본" },
|
||||
{ value: "CONCAT", label: "문자열 결합", category: "기본" },
|
||||
{ value: "SPLIT", label: "문자열 분리", category: "기본" },
|
||||
{ value: "REPLACE", label: "문자열 치환", category: "기본" },
|
||||
{ value: "EXPLODE", label: "행 확장 (1→N)", category: "고급" },
|
||||
{ value: "CAST", label: "타입 변환", category: "고급" },
|
||||
{ value: "FORMAT", label: "형식화", category: "고급" },
|
||||
{ value: "CALCULATE", label: "계산식", category: "고급" },
|
||||
{ value: "JSON_EXTRACT", label: "JSON 추출", category: "고급" },
|
||||
{ value: "CUSTOM", label: "사용자 정의", category: "고급" },
|
||||
] as const;
|
||||
|
||||
export function DataTransformProperties({ nodeId, data }: DataTransformPropertiesProps) {
|
||||
const { updateNode, nodes, edges } = useFlowEditorStore();
|
||||
|
||||
const [displayName, setDisplayName] = useState(data.displayName || "데이터 변환");
|
||||
const [transformations, setTransformations] = useState(data.transformations || []);
|
||||
|
||||
// 소스 필드 목록 (연결된 입력 노드에서 가져오기)
|
||||
const [sourceFields, setSourceFields] = useState<Array<{ name: string; label?: string }>>([]);
|
||||
|
||||
// 데이터 변경 시 로컬 상태 업데이트
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || "데이터 변환");
|
||||
setTransformations(data.transformations || []);
|
||||
}, [data]);
|
||||
|
||||
// 연결된 소스 노드에서 필드 가져오기
|
||||
useEffect(() => {
|
||||
const inputEdges = edges.filter((edge) => edge.target === nodeId);
|
||||
const sourceNodeIds = inputEdges.map((edge) => edge.source);
|
||||
const sourceNodes = nodes.filter((node) => sourceNodeIds.includes(node.id));
|
||||
|
||||
const fields: Array<{ name: string; label?: string }> = [];
|
||||
sourceNodes.forEach((node) => {
|
||||
if (node.type === "tableSource" && node.data.fields) {
|
||||
node.data.fields.forEach((field: any) => {
|
||||
fields.push({
|
||||
name: field.name,
|
||||
label: field.label || field.displayName,
|
||||
});
|
||||
});
|
||||
} else if (node.type === "externalDBSource" && node.data.fields) {
|
||||
node.data.fields.forEach((field: any) => {
|
||||
fields.push({
|
||||
name: field.name,
|
||||
label: field.label || field.displayName,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
setSourceFields(fields);
|
||||
}, [nodeId, nodes, edges]);
|
||||
|
||||
const handleAddTransformation = () => {
|
||||
setTransformations([
|
||||
...transformations,
|
||||
{
|
||||
type: "UPPERCASE" as const,
|
||||
sourceField: "",
|
||||
targetField: "",
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRemoveTransformation = (index: number) => {
|
||||
const newTransformations = transformations.filter((_, i) => i !== index);
|
||||
setTransformations(newTransformations);
|
||||
|
||||
// 즉시 반영
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
transformations: newTransformations,
|
||||
});
|
||||
};
|
||||
|
||||
const handleTransformationChange = (index: number, field: string, value: any) => {
|
||||
const newTransformations = [...transformations];
|
||||
|
||||
// 필드 변경 시 라벨도 함께 저장
|
||||
if (field === "sourceField") {
|
||||
const sourceField = sourceFields.find((f) => f.name === value);
|
||||
newTransformations[index] = {
|
||||
...newTransformations[index],
|
||||
sourceField: value,
|
||||
sourceFieldLabel: sourceField?.label,
|
||||
};
|
||||
} else if (field === "targetField") {
|
||||
// 타겟 필드는 새로 생성하는 필드이므로 라벨은 사용자가 직접 입력
|
||||
newTransformations[index] = {
|
||||
...newTransformations[index],
|
||||
targetField: value,
|
||||
};
|
||||
} else {
|
||||
newTransformations[index] = { ...newTransformations[index], [field]: value };
|
||||
}
|
||||
|
||||
setTransformations(newTransformations);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
transformations,
|
||||
});
|
||||
};
|
||||
|
||||
const renderTransformationFields = (transform: any, index: number) => {
|
||||
const commonFields = (
|
||||
<>
|
||||
{/* 소스 필드 */}
|
||||
{transform.type !== "CONCAT" && (
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">소스 필드</Label>
|
||||
<Select
|
||||
value={transform.sourceField || ""}
|
||||
onValueChange={(value) => handleTransformationChange(index, "sourceField", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="소스 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceFields.length === 0 ? (
|
||||
<div className="p-2 text-center text-xs text-gray-400">연결된 소스 노드가 없습니다</div>
|
||||
) : (
|
||||
sourceFields.map((field) => (
|
||||
<SelectItem key={field.name} value={field.name} className="text-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{field.label || field.name}</span>
|
||||
{field.label && field.label !== field.name && (
|
||||
<span className="text-muted-foreground font-mono text-xs">{field.name}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 타겟 필드 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">타겟 필드명 (선택)</Label>
|
||||
<Input
|
||||
value={transform.targetField || ""}
|
||||
onChange={(e) => handleTransformationChange(index, "targetField", e.target.value)}
|
||||
placeholder="비어있으면 소스 필드에 적용"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
{transform.targetField ? (
|
||||
transform.targetField === transform.sourceField ? (
|
||||
<span className="text-indigo-600">✓ 소스 필드를 덮어씁니다</span>
|
||||
) : (
|
||||
<span className="text-green-600">✓ 새 필드를 생성합니다</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-indigo-600">비어있음: 소스 필드를 덮어씁니다</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
// 타입별 추가 필드
|
||||
switch (transform.type) {
|
||||
case "EXPLODE":
|
||||
return (
|
||||
<>
|
||||
{commonFields}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">구분자</Label>
|
||||
<Input
|
||||
value={transform.delimiter || ","}
|
||||
onChange={(e) => handleTransformationChange(index, "delimiter", e.target.value)}
|
||||
placeholder="예: , 또는 ; 또는 |"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400">이 문자로 분리하여 여러 행으로 확장합니다</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case "CONCAT":
|
||||
return (
|
||||
<>
|
||||
{/* CONCAT은 다중 소스 필드를 지원 - 간소화 버전 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">첫 번째 필드</Label>
|
||||
<Select
|
||||
value={transform.sourceField || ""}
|
||||
onValueChange={(value) => handleTransformationChange(index, "sourceField", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="첫 번째 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceFields.map((field) => (
|
||||
<SelectItem key={field.name} value={field.name} className="text-xs">
|
||||
{field.label || field.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">구분자</Label>
|
||||
<Input
|
||||
value={transform.separator || " "}
|
||||
onChange={(e) => handleTransformationChange(index, "separator", e.target.value)}
|
||||
placeholder="예: 공백 또는 , 또는 -"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
{commonFields}
|
||||
</>
|
||||
);
|
||||
|
||||
case "SPLIT":
|
||||
return (
|
||||
<>
|
||||
{commonFields}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">구분자</Label>
|
||||
<Input
|
||||
value={transform.delimiter || ","}
|
||||
onChange={(e) => handleTransformationChange(index, "delimiter", e.target.value)}
|
||||
placeholder="예: , 또는 ; 또는 |"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">인덱스 (0부터 시작)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={transform.splitIndex !== undefined ? transform.splitIndex : 0}
|
||||
onChange={(e) => handleTransformationChange(index, "splitIndex", parseInt(e.target.value))}
|
||||
placeholder="0"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400">분리된 값 중 몇 번째를 가져올지 지정 (0=첫번째)</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case "REPLACE":
|
||||
return (
|
||||
<>
|
||||
{commonFields}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">찾을 문자열</Label>
|
||||
<Input
|
||||
value={transform.searchValue || ""}
|
||||
onChange={(e) => handleTransformationChange(index, "searchValue", e.target.value)}
|
||||
placeholder="예: old"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">바꿀 문자열</Label>
|
||||
<Input
|
||||
value={transform.replaceValue || ""}
|
||||
onChange={(e) => handleTransformationChange(index, "replaceValue", e.target.value)}
|
||||
placeholder="예: new"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case "CAST":
|
||||
return (
|
||||
<>
|
||||
{commonFields}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">변환할 타입</Label>
|
||||
<Select
|
||||
value={transform.castType || "string"}
|
||||
onValueChange={(value) => handleTransformationChange(index, "castType", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="string" className="text-xs">
|
||||
문자열 (String)
|
||||
</SelectItem>
|
||||
<SelectItem value="number" className="text-xs">
|
||||
숫자 (Number)
|
||||
</SelectItem>
|
||||
<SelectItem value="boolean" className="text-xs">
|
||||
불린 (Boolean)
|
||||
</SelectItem>
|
||||
<SelectItem value="date" className="text-xs">
|
||||
날짜 (Date)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case "CALCULATE":
|
||||
case "FORMAT":
|
||||
case "JSON_EXTRACT":
|
||||
case "CUSTOM":
|
||||
return (
|
||||
<>
|
||||
{commonFields}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">표현식</Label>
|
||||
<Input
|
||||
value={transform.expression || ""}
|
||||
onChange={(e) => handleTransformationChange(index, "expression", e.target.value)}
|
||||
placeholder="예: field1 + field2"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
{transform.type === "CALCULATE" && "계산 수식을 입력하세요 (예: field1 + field2)"}
|
||||
{transform.type === "FORMAT" && "형식 문자열을 입력하세요 (예: {0}-{1})"}
|
||||
{transform.type === "JSON_EXTRACT" && "JSON 경로를 입력하세요 (예: $.data.name)"}
|
||||
{transform.type === "CUSTOM" && "JavaScript 표현식을 입력하세요"}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
default:
|
||||
// UPPERCASE, LOWERCASE, TRIM 등
|
||||
return commonFields;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-md bg-indigo-50 p-2">
|
||||
<Wand2 className="h-4 w-4 text-indigo-600" />
|
||||
<span className="font-semibold text-indigo-600">데이터 변환</span>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
||||
<div>
|
||||
<Label htmlFor="displayName" className="text-xs">
|
||||
표시 이름
|
||||
</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="노드 표시 이름"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 변환 규칙 */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">변환 규칙</h3>
|
||||
<p className="text-xs text-gray-500">데이터를 변환할 규칙을 추가하세요</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={handleAddTransformation} className="h-7 px-2 text-xs">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
규칙 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{transformations.length === 0 ? (
|
||||
<div className="rounded border border-dashed bg-gray-50 p-4 text-center text-xs text-gray-500">
|
||||
변환 규칙을 추가하세요
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{transformations.map((transform, index) => (
|
||||
<div key={index} className="rounded border bg-indigo-50 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-indigo-700">변환 #{index + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveTransformation(index)}
|
||||
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* 변환 타입 선택 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">변환 타입</Label>
|
||||
<Select
|
||||
value={transform.type}
|
||||
onValueChange={(value) => handleTransformationChange(index, "type", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<div className="px-2 py-1 text-xs font-semibold text-gray-500">기본 변환</div>
|
||||
{TRANSFORM_TYPES.filter((t) => t.category === "기본").map((type) => (
|
||||
<SelectItem key={type.value} value={type.value} className="text-xs">
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
<div className="px-2 py-1 text-xs font-semibold text-gray-500">고급 변환</div>
|
||||
{TRANSFORM_TYPES.filter((t) => t.category === "고급").map((type) => (
|
||||
<SelectItem key={type.value} value={type.value} className="text-xs">
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 타입별 필드 렌더링 */}
|
||||
{renderTransformationFields(transform, index)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 적용 버튼 */}
|
||||
<div className="sticky bottom-0 border-t bg-white pt-3">
|
||||
<Button onClick={handleSave} className="w-full" size="sm">
|
||||
적용
|
||||
</Button>
|
||||
<p className="mt-2 text-center text-xs text-gray-500">✅ 변경 사항이 즉시 노드에 반영됩니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* DELETE 액션 노드 속성 편집
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Plus, Trash2, AlertTriangle } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import type { DeleteActionNodeData } from "@/types/node-editor";
|
||||
|
||||
interface DeleteActionPropertiesProps {
|
||||
nodeId: string;
|
||||
data: DeleteActionNodeData;
|
||||
}
|
||||
|
||||
const OPERATORS = [
|
||||
{ value: "EQUALS", label: "=" },
|
||||
{ value: "NOT_EQUALS", label: "≠" },
|
||||
{ value: "GREATER_THAN", label: ">" },
|
||||
{ value: "LESS_THAN", label: "<" },
|
||||
{ value: "IN", label: "IN" },
|
||||
{ value: "NOT_IN", label: "NOT IN" },
|
||||
] as const;
|
||||
|
||||
export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesProps) {
|
||||
const { updateNode } = useFlowEditorStore();
|
||||
|
||||
const [displayName, setDisplayName] = useState(data.displayName || `${data.targetTable} 삭제`);
|
||||
const [targetTable, setTargetTable] = useState(data.targetTable);
|
||||
const [whereConditions, setWhereConditions] = useState(data.whereConditions || []);
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || `${data.targetTable} 삭제`);
|
||||
setTargetTable(data.targetTable);
|
||||
setWhereConditions(data.whereConditions || []);
|
||||
}, [data]);
|
||||
|
||||
const handleAddCondition = () => {
|
||||
setWhereConditions([
|
||||
...whereConditions,
|
||||
{
|
||||
field: "",
|
||||
operator: "EQUALS",
|
||||
value: "",
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRemoveCondition = (index: number) => {
|
||||
setWhereConditions(whereConditions.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleConditionChange = (index: number, field: string, value: any) => {
|
||||
const newConditions = [...whereConditions];
|
||||
newConditions[index] = { ...newConditions[index], [field]: value };
|
||||
setWhereConditions(newConditions);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
targetTable,
|
||||
whereConditions,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
{/* 경고 */}
|
||||
<div className="rounded-lg border-2 border-red-200 bg-red-50 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 flex-shrink-0 text-red-600" />
|
||||
<div className="text-sm">
|
||||
<p className="font-semibold text-red-800">위험한 작업입니다!</p>
|
||||
<p className="mt-1 text-xs text-red-700">
|
||||
DELETE 작업은 되돌릴 수 없습니다. WHERE 조건을 반드시 설정하세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="displayName" className="text-xs">
|
||||
표시 이름
|
||||
</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="targetTable" className="text-xs">
|
||||
타겟 테이블
|
||||
</Label>
|
||||
<Input
|
||||
id="targetTable"
|
||||
value={targetTable}
|
||||
onChange={(e) => setTargetTable(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* WHERE 조건 */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">WHERE 조건 (필수)</h3>
|
||||
<Button size="sm" variant="outline" onClick={handleAddCondition} className="h-7">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{whereConditions.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{whereConditions.map((condition, index) => (
|
||||
<div key={index} className="rounded border-2 border-red-200 bg-red-50 p-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-red-700">조건 #{index + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveCondition(index)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">필드</Label>
|
||||
<Input
|
||||
value={condition.field}
|
||||
onChange={(e) => handleConditionChange(index, "field", e.target.value)}
|
||||
placeholder="조건 필드명"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">연산자</Label>
|
||||
<Select
|
||||
value={condition.operator}
|
||||
onValueChange={(value) => handleConditionChange(index, "operator", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OPERATORS.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">값</Label>
|
||||
<Input
|
||||
value={condition.value as string}
|
||||
onChange={(e) => handleConditionChange(index, "value", e.target.value)}
|
||||
placeholder="비교 값"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded border-2 border-dashed border-red-300 bg-red-50 p-4 text-center text-xs text-red-600">
|
||||
⚠️ WHERE 조건이 없습니다! 모든 데이터가 삭제됩니다!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSave} variant="destructive" className="w-full" size="sm">
|
||||
적용
|
||||
</Button>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="rounded bg-red-50 p-3 text-xs text-red-700">
|
||||
🚨 WHERE 조건 없이 삭제하면 테이블의 모든 데이터가 영구 삭제됩니다!
|
||||
</div>
|
||||
<div className="rounded bg-red-50 p-3 text-xs text-red-700">💡 실행 전 WHERE 조건을 꼭 확인하세요.</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 외부 DB 소스 노드 속성 편집
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Database } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import type { ExternalDBSourceNodeData } from "@/types/node-editor";
|
||||
|
||||
interface ExternalDBSourcePropertiesProps {
|
||||
nodeId: string;
|
||||
data: ExternalDBSourceNodeData;
|
||||
}
|
||||
|
||||
const DB_TYPE_INFO: Record<string, { label: string; color: string; icon: string }> = {
|
||||
postgresql: { label: "PostgreSQL", color: "#336791", icon: "🐘" },
|
||||
mysql: { label: "MySQL", color: "#4479A1", icon: "🐬" },
|
||||
oracle: { label: "Oracle", color: "#F80000", icon: "🔴" },
|
||||
mssql: { label: "MS SQL Server", color: "#CC2927", icon: "🏢" },
|
||||
mariadb: { label: "MariaDB", color: "#003545", icon: "🌊" },
|
||||
};
|
||||
|
||||
export function ExternalDBSourceProperties({ nodeId, data }: ExternalDBSourcePropertiesProps) {
|
||||
const { updateNode } = useFlowEditorStore();
|
||||
|
||||
const [displayName, setDisplayName] = useState(data.displayName || data.connectionName);
|
||||
const [connectionName, setConnectionName] = useState(data.connectionName);
|
||||
const [tableName, setTableName] = useState(data.tableName);
|
||||
const [schema, setSchema] = useState(data.schema || "");
|
||||
|
||||
const dbInfo =
|
||||
data.dbType && DB_TYPE_INFO[data.dbType]
|
||||
? DB_TYPE_INFO[data.dbType]
|
||||
: {
|
||||
label: data.dbType ? data.dbType.toUpperCase() : "알 수 없음",
|
||||
color: "#666",
|
||||
icon: "💾",
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || data.connectionName);
|
||||
setConnectionName(data.connectionName);
|
||||
setTableName(data.tableName);
|
||||
setSchema(data.schema || "");
|
||||
}, [data]);
|
||||
|
||||
const handleSave = () => {
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
connectionName,
|
||||
tableName,
|
||||
schema,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
{/* DB 타입 정보 */}
|
||||
<div
|
||||
className="rounded-lg border-2 p-4"
|
||||
style={{
|
||||
borderColor: dbInfo.color,
|
||||
backgroundColor: `${dbInfo.color}10`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="flex h-12 w-12 items-center justify-center rounded-lg"
|
||||
style={{ backgroundColor: dbInfo.color }}
|
||||
>
|
||||
<span className="text-2xl">{dbInfo.icon}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold" style={{ color: dbInfo.color }}>
|
||||
{dbInfo.label}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600">외부 데이터베이스</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">연결 정보</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="displayName" className="text-xs">
|
||||
표시 이름
|
||||
</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="노드 표시 이름"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="connectionName" className="text-xs">
|
||||
연결명
|
||||
</Label>
|
||||
<Input
|
||||
id="connectionName"
|
||||
value={connectionName}
|
||||
onChange={(e) => setConnectionName(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="외부 DB 연결명"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">외부 DB 연결 관리에서 설정한 연결명입니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">테이블 정보</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="tableName" className="text-xs">
|
||||
테이블명
|
||||
</Label>
|
||||
<Input
|
||||
id="tableName"
|
||||
value={tableName}
|
||||
onChange={(e) => setTableName(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="데이터를 가져올 테이블"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="schema" className="text-xs">
|
||||
스키마 (선택)
|
||||
</Label>
|
||||
<Input
|
||||
id="schema"
|
||||
value={schema}
|
||||
onChange={(e) => setSchema(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="스키마명"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 출력 필드 */}
|
||||
{data.outputFields && data.outputFields.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">출력 필드</h3>
|
||||
<div className="space-y-1">
|
||||
{data.outputFields.map((field, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between rounded border bg-gray-50 px-3 py-2 text-xs"
|
||||
>
|
||||
<span className="font-medium">{field.label || field.name}</span>
|
||||
<span className="font-mono text-gray-500">{field.type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={handleSave} className="w-full" size="sm">
|
||||
적용
|
||||
</Button>
|
||||
|
||||
<div className="rounded p-3 text-xs" style={{ backgroundColor: `${dbInfo.color}15`, color: dbInfo.color }}>
|
||||
💡 외부 DB 연결은 "외부 DB 연결 관리" 메뉴에서 미리 설정해야 합니다.
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 필드 매핑 노드 속성 편집
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Plus, Trash2, ArrowRight } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import type { FieldMappingNodeData } from "@/types/node-editor";
|
||||
|
||||
interface FieldMappingPropertiesProps {
|
||||
nodeId: string;
|
||||
data: FieldMappingNodeData;
|
||||
}
|
||||
|
||||
export function FieldMappingProperties({ nodeId, data }: FieldMappingPropertiesProps) {
|
||||
const { updateNode } = useFlowEditorStore();
|
||||
|
||||
const [displayName, setDisplayName] = useState(data.displayName || "데이터 매핑");
|
||||
const [mappings, setMappings] = useState(data.mappings || []);
|
||||
|
||||
// 데이터 변경 시 로컬 상태 업데이트
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || "데이터 매핑");
|
||||
setMappings(data.mappings || []);
|
||||
}, [data]);
|
||||
|
||||
const handleAddMapping = () => {
|
||||
setMappings([
|
||||
...mappings,
|
||||
{
|
||||
id: `mapping_${Date.now()}`,
|
||||
sourceField: "",
|
||||
targetField: "",
|
||||
transform: undefined,
|
||||
staticValue: undefined,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRemoveMapping = (id: string) => {
|
||||
setMappings(mappings.filter((m) => m.id !== id));
|
||||
};
|
||||
|
||||
const handleMappingChange = (id: string, field: string, value: any) => {
|
||||
const newMappings = mappings.map((m) => (m.id === id ? { ...m, [field]: value } : m));
|
||||
setMappings(newMappings);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
mappings,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="displayName" className="text-xs">
|
||||
표시 이름
|
||||
</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="노드 표시 이름"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 매핑 규칙 */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">매핑 규칙</h3>
|
||||
<Button size="sm" variant="outline" onClick={handleAddMapping} className="h-7">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{mappings.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{mappings.map((mapping, index) => (
|
||||
<div key={mapping.id} className="rounded border bg-purple-50 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-purple-700">규칙 #{index + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveMapping(mapping.id)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* 소스 → 타겟 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs text-gray-600">소스 필드</Label>
|
||||
<Input
|
||||
value={mapping.sourceField || ""}
|
||||
onChange={(e) => handleMappingChange(mapping.id, "sourceField", e.target.value)}
|
||||
placeholder="입력 필드"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-5">
|
||||
<ArrowRight className="h-4 w-4 text-purple-500" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs text-gray-600">타겟 필드</Label>
|
||||
<Input
|
||||
value={mapping.targetField}
|
||||
onChange={(e) => handleMappingChange(mapping.id, "targetField", e.target.value)}
|
||||
placeholder="출력 필드"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 변환 함수 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">변환 함수 (선택)</Label>
|
||||
<Input
|
||||
value={mapping.transform || ""}
|
||||
onChange={(e) => handleMappingChange(mapping.id, "transform", e.target.value)}
|
||||
placeholder="예: UPPER(), TRIM(), CONCAT()"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 정적 값 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">정적 값 (선택)</Label>
|
||||
<Input
|
||||
value={mapping.staticValue || ""}
|
||||
onChange={(e) => handleMappingChange(mapping.id, "staticValue", e.target.value)}
|
||||
placeholder="고정 값 (소스 필드 대신 사용)"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded border border-dashed p-4 text-center text-xs text-gray-400">
|
||||
매핑 규칙이 없습니다. "추가" 버튼을 클릭하세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 저장 버튼 */}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSave} className="flex-1" size="sm">
|
||||
적용
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 안내 */}
|
||||
<div className="space-y-2">
|
||||
<div className="rounded bg-purple-50 p-3 text-xs text-purple-700">
|
||||
💡 <strong>소스 필드</strong>: 입력 데이터의 필드명
|
||||
</div>
|
||||
<div className="rounded bg-purple-50 p-3 text-xs text-purple-700">
|
||||
💡 <strong>타겟 필드</strong>: 출력 데이터의 필드명
|
||||
</div>
|
||||
<div className="rounded bg-purple-50 p-3 text-xs text-purple-700">
|
||||
💡 <strong>변환 함수</strong>: 데이터 변환 로직 (SQL 함수 형식)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,613 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* INSERT 액션 노드 속성 편집 (개선 버전)
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import type { InsertActionNodeData } from "@/types/node-editor";
|
||||
|
||||
interface InsertActionPropertiesProps {
|
||||
nodeId: string;
|
||||
data: InsertActionNodeData;
|
||||
}
|
||||
|
||||
interface TableOption {
|
||||
tableName: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ColumnInfo {
|
||||
columnName: string;
|
||||
columnLabel?: string;
|
||||
dataType: string;
|
||||
isNullable: boolean;
|
||||
}
|
||||
|
||||
export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesProps) {
|
||||
const { updateNode, nodes, edges } = useFlowEditorStore();
|
||||
|
||||
const [displayName, setDisplayName] = useState(data.displayName || data.targetTable);
|
||||
const [targetTable, setTargetTable] = useState(data.targetTable);
|
||||
const [fieldMappings, setFieldMappings] = useState(data.fieldMappings || []);
|
||||
const [batchSize, setBatchSize] = useState(data.options?.batchSize?.toString() || "");
|
||||
const [ignoreErrors, setIgnoreErrors] = useState(data.options?.ignoreErrors || false);
|
||||
const [ignoreDuplicates, setIgnoreDuplicates] = useState(data.options?.ignoreDuplicates || false);
|
||||
|
||||
// 테이블 관련 상태
|
||||
const [tables, setTables] = useState<TableOption[]>([]);
|
||||
const [tablesLoading, setTablesLoading] = useState(false);
|
||||
const [tablesOpen, setTablesOpen] = useState(false);
|
||||
|
||||
// 컬럼 관련 상태
|
||||
const [targetColumns, setTargetColumns] = useState<ColumnInfo[]>([]);
|
||||
const [columnsLoading, setColumnsLoading] = useState(false);
|
||||
|
||||
// 소스 필드 목록 (연결된 입력 노드에서 가져오기)
|
||||
const [sourceFields, setSourceFields] = useState<Array<{ name: string; label?: string }>>([]);
|
||||
|
||||
// 데이터 변경 시 로컬 상태 업데이트
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || data.targetTable);
|
||||
setTargetTable(data.targetTable);
|
||||
setFieldMappings(data.fieldMappings || []);
|
||||
setBatchSize(data.options?.batchSize?.toString() || "");
|
||||
setIgnoreErrors(data.options?.ignoreErrors || false);
|
||||
setIgnoreDuplicates(data.options?.ignoreDuplicates || false);
|
||||
}, [data]);
|
||||
|
||||
// 테이블 목록 로딩
|
||||
useEffect(() => {
|
||||
loadTables();
|
||||
}, []);
|
||||
|
||||
// 타겟 테이블 변경 시 컬럼 로딩
|
||||
useEffect(() => {
|
||||
if (targetTable) {
|
||||
loadColumns(targetTable);
|
||||
}
|
||||
}, [targetTable]);
|
||||
|
||||
// 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색)
|
||||
useEffect(() => {
|
||||
const getAllSourceFields = (
|
||||
targetNodeId: string,
|
||||
visitedNodes = new Set<string>(),
|
||||
): Array<{ name: string; label?: string }> => {
|
||||
if (visitedNodes.has(targetNodeId)) {
|
||||
return [];
|
||||
}
|
||||
visitedNodes.add(targetNodeId);
|
||||
|
||||
const inputEdges = edges.filter((edge) => edge.target === targetNodeId);
|
||||
const sourceNodeIds = inputEdges.map((edge) => edge.source);
|
||||
const sourceNodes = nodes.filter((node) => sourceNodeIds.includes(node.id));
|
||||
|
||||
const fields: Array<{ name: string; label?: string }> = [];
|
||||
|
||||
sourceNodes.forEach((node) => {
|
||||
console.log(`🔍 노드 ${node.id} 타입: ${node.type}`);
|
||||
console.log(`🔍 노드 ${node.id} 데이터:`, node.data);
|
||||
|
||||
// 데이터 변환 노드인 경우: 변환된 필드 + 상위 노드의 원본 필드
|
||||
if (node.type === "dataTransform") {
|
||||
console.log(`✅ 데이터 변환 노드 발견`);
|
||||
|
||||
// 상위 노드의 원본 필드 먼저 수집
|
||||
const upperFields = getAllSourceFields(node.id, visitedNodes);
|
||||
console.log(` 📤 상위 노드에서 ${upperFields.length}개 필드 가져옴`);
|
||||
|
||||
// 변환된 필드 추가 (in-place 변환 고려)
|
||||
if (node.data.transformations && Array.isArray(node.data.transformations)) {
|
||||
console.log(` 📊 ${node.data.transformations.length}개 변환 발견`);
|
||||
const inPlaceFields = new Set<string>(); // in-place 변환된 필드 추적
|
||||
|
||||
node.data.transformations.forEach((transform: any) => {
|
||||
const targetField = transform.targetField || transform.sourceField;
|
||||
const isInPlace = !transform.targetField || transform.targetField === transform.sourceField;
|
||||
|
||||
console.log(` 🔹 변환: ${transform.sourceField} → ${targetField} ${isInPlace ? "(in-place)" : ""}`);
|
||||
|
||||
if (isInPlace) {
|
||||
// in-place: 원본 필드를 덮어쓰므로, 원본 필드는 이미 upperFields에 있음
|
||||
inPlaceFields.add(transform.sourceField);
|
||||
} else if (targetField) {
|
||||
// 새 필드 생성
|
||||
fields.push({
|
||||
name: targetField,
|
||||
label: transform.targetFieldLabel || targetField,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 상위 필드 중 in-place 변환되지 않은 것만 추가
|
||||
upperFields.forEach((field) => {
|
||||
if (!inPlaceFields.has(field.name)) {
|
||||
fields.push(field);
|
||||
} else {
|
||||
// in-place 변환된 필드도 추가 (변환 후 값)
|
||||
fields.push(field);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 변환이 없으면 상위 필드만 추가
|
||||
fields.push(...upperFields);
|
||||
}
|
||||
}
|
||||
// 일반 소스 노드인 경우
|
||||
else {
|
||||
const nodeFields = node.data.fields || node.data.outputFields;
|
||||
|
||||
if (nodeFields && Array.isArray(nodeFields)) {
|
||||
console.log(`✅ 노드 ${node.id}에서 ${nodeFields.length}개 필드 발견`);
|
||||
nodeFields.forEach((field: any) => {
|
||||
const fieldName = field.name || field.fieldName || field.column_name;
|
||||
const fieldLabel = field.label || field.displayName || field.label_ko;
|
||||
if (fieldName) {
|
||||
fields.push({
|
||||
name: fieldName,
|
||||
label: fieldLabel,
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.log(`❌ 노드 ${node.id}에 fields 없음`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return fields;
|
||||
};
|
||||
|
||||
console.log("🔍 INSERT 노드 ID:", nodeId);
|
||||
const allFields = getAllSourceFields(nodeId);
|
||||
|
||||
// 중복 제거
|
||||
const uniqueFields = Array.from(new Map(allFields.map((field) => [field.name, field])).values());
|
||||
|
||||
setSourceFields(uniqueFields);
|
||||
console.log("✅ 최종 소스 필드 목록:", uniqueFields);
|
||||
}, [nodeId, nodes, edges]);
|
||||
|
||||
/**
|
||||
* 테이블 목록 로드
|
||||
*/
|
||||
const loadTables = async () => {
|
||||
try {
|
||||
setTablesLoading(true);
|
||||
const tableList = await tableTypeApi.getTables();
|
||||
|
||||
const options: TableOption[] = tableList.map((table) => {
|
||||
const label = (table as any).tableLabel || table.displayName || table.tableName || "알 수 없는 테이블";
|
||||
|
||||
return {
|
||||
tableName: table.tableName,
|
||||
displayName: table.displayName || table.tableName,
|
||||
description: table.description || "",
|
||||
label,
|
||||
};
|
||||
});
|
||||
|
||||
setTables(options);
|
||||
console.log(`✅ 테이블 ${options.length}개 로딩 완료`);
|
||||
} catch (error) {
|
||||
console.error("❌ 테이블 목록 로딩 실패:", error);
|
||||
setTables([]);
|
||||
} finally {
|
||||
setTablesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 타겟 테이블의 컬럼 목록 로드
|
||||
*/
|
||||
const loadColumns = async (tableName: string) => {
|
||||
try {
|
||||
setColumnsLoading(true);
|
||||
console.log(`🔍 컬럼 조회 중: ${tableName}`);
|
||||
|
||||
const columns = await tableTypeApi.getColumns(tableName);
|
||||
|
||||
const columnInfo: ColumnInfo[] = columns.map((col: any) => ({
|
||||
columnName: col.column_name || col.columnName,
|
||||
columnLabel: col.label_ko || col.columnLabel,
|
||||
dataType: col.data_type || col.dataType || "unknown",
|
||||
isNullable: col.is_nullable === "YES" || col.isNullable === true,
|
||||
}));
|
||||
|
||||
setTargetColumns(columnInfo);
|
||||
console.log(`✅ 컬럼 ${columnInfo.length}개 로딩 완료`);
|
||||
} catch (error) {
|
||||
console.error("❌ 컬럼 목록 로딩 실패:", error);
|
||||
setTargetColumns([]);
|
||||
} finally {
|
||||
setColumnsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 테이블 선택 핸들러
|
||||
*/
|
||||
const handleTableSelect = (selectedTableName: string) => {
|
||||
const selectedTable = tables.find((t) => t.tableName === selectedTableName);
|
||||
if (selectedTable) {
|
||||
setTargetTable(selectedTable.tableName);
|
||||
if (!displayName || displayName === targetTable) {
|
||||
setDisplayName(selectedTable.label);
|
||||
}
|
||||
|
||||
// 즉시 노드 업데이트
|
||||
updateNode(nodeId, {
|
||||
displayName: selectedTable.label,
|
||||
targetTable: selectedTable.tableName,
|
||||
fieldMappings,
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
ignoreErrors,
|
||||
ignoreDuplicates,
|
||||
},
|
||||
});
|
||||
|
||||
setTablesOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddMapping = () => {
|
||||
setFieldMappings([
|
||||
...fieldMappings,
|
||||
{
|
||||
sourceField: null,
|
||||
targetField: "",
|
||||
staticValue: undefined,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRemoveMapping = (index: number) => {
|
||||
const newMappings = fieldMappings.filter((_, i) => i !== index);
|
||||
setFieldMappings(newMappings);
|
||||
|
||||
// 즉시 반영
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
targetTable,
|
||||
fieldMappings: newMappings,
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
ignoreErrors,
|
||||
ignoreDuplicates,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleMappingChange = (index: number, field: string, value: any) => {
|
||||
const newMappings = [...fieldMappings];
|
||||
|
||||
// 필드 변경 시 라벨도 함께 저장
|
||||
if (field === "sourceField") {
|
||||
const sourceField = sourceFields.find((f) => f.name === value);
|
||||
newMappings[index] = {
|
||||
...newMappings[index],
|
||||
sourceField: value,
|
||||
sourceFieldLabel: sourceField?.label,
|
||||
};
|
||||
} else if (field === "targetField") {
|
||||
const targetColumn = targetColumns.find((c) => c.columnName === value);
|
||||
newMappings[index] = {
|
||||
...newMappings[index],
|
||||
targetField: value,
|
||||
targetFieldLabel: targetColumn?.columnLabel,
|
||||
};
|
||||
} else {
|
||||
newMappings[index] = { ...newMappings[index], [field]: value };
|
||||
}
|
||||
|
||||
setFieldMappings(newMappings);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
targetTable,
|
||||
fieldMappings,
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
ignoreErrors,
|
||||
ignoreDuplicates,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const selectedTableLabel = tables.find((t) => t.tableName === targetTable)?.label || targetTable;
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="displayName" className="text-xs">
|
||||
표시 이름
|
||||
</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="노드 표시 이름"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 타겟 테이블 Combobox */}
|
||||
<div>
|
||||
<Label className="text-xs">타겟 테이블</Label>
|
||||
<Popover open={tablesOpen} onOpenChange={setTablesOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tablesOpen}
|
||||
className="mt-1 w-full justify-between"
|
||||
disabled={tablesLoading}
|
||||
>
|
||||
{tablesLoading ? (
|
||||
<span className="text-muted-foreground">로딩 중...</span>
|
||||
) : targetTable ? (
|
||||
<span className="truncate">{selectedTableLabel}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">테이블을 선택하세요</span>
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[320px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="h-9" />
|
||||
<CommandList>
|
||||
<CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<ScrollArea className="h-[300px]">
|
||||
{tables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={`${table.label} ${table.tableName} ${table.description}`}
|
||||
onSelect={() => handleTableSelect(table.tableName)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
targetTable === table.tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.label}</span>
|
||||
{table.label !== table.tableName && (
|
||||
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
||||
)}
|
||||
{table.description && (
|
||||
<span className="text-muted-foreground text-xs">{table.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{targetTable && selectedTableLabel !== targetTable && (
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
실제 테이블명: <code className="rounded bg-gray-100 px-1 py-0.5">{targetTable}</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필드 매핑 */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">필드 매핑</h3>
|
||||
<Button size="sm" variant="outline" onClick={handleAddMapping} className="h-7">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{columnsLoading && (
|
||||
<div className="rounded border p-3 text-center text-xs text-gray-500">컬럼 목록을 불러오는 중...</div>
|
||||
)}
|
||||
|
||||
{!targetTable && !columnsLoading && (
|
||||
<div className="rounded border border-dashed bg-yellow-50 p-3 text-center text-xs text-yellow-700">
|
||||
⚠️ 먼저 타겟 테이블을 선택하세요
|
||||
</div>
|
||||
)}
|
||||
|
||||
{targetTable && !columnsLoading && targetColumns.length === 0 && (
|
||||
<div className="rounded border border-dashed bg-red-50 p-3 text-center text-xs text-red-700">
|
||||
❌ 컬럼 정보를 불러올 수 없습니다
|
||||
</div>
|
||||
)}
|
||||
|
||||
{targetTable && targetColumns.length > 0 && (
|
||||
<>
|
||||
{fieldMappings.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{fieldMappings.map((mapping, index) => (
|
||||
<div key={index} className="rounded border bg-gray-50 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-gray-700">매핑 #{index + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveMapping(index)}
|
||||
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* 소스 필드 드롭다운 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">소스 필드</Label>
|
||||
<Select
|
||||
value={mapping.sourceField || ""}
|
||||
onValueChange={(value) => handleMappingChange(index, "sourceField", value || null)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="소스 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceFields.length === 0 ? (
|
||||
<div className="p-2 text-center text-xs text-gray-400">연결된 소스 노드가 없습니다</div>
|
||||
) : (
|
||||
sourceFields.map((field) => (
|
||||
<SelectItem key={field.name} value={field.name} className="text-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{field.label || field.name}</span>
|
||||
{field.label && field.label !== field.name && (
|
||||
<span className="text-muted-foreground font-mono text-xs">{field.name}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center py-1">
|
||||
<ArrowRight className="h-4 w-4 text-green-600" />
|
||||
</div>
|
||||
|
||||
{/* 타겟 필드 드롭다운 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">타겟 필드</Label>
|
||||
<Select
|
||||
value={mapping.targetField}
|
||||
onValueChange={(value) => handleMappingChange(index, "targetField", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="타겟 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-mono">{col.columnLabel || col.columnName}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{col.dataType}
|
||||
{!col.isNullable && <span className="text-red-500">*</span>}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 정적 값 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">정적 값 (선택)</Label>
|
||||
<Input
|
||||
value={mapping.staticValue || ""}
|
||||
onChange={(e) => handleMappingChange(index, "staticValue", e.target.value || undefined)}
|
||||
placeholder="소스 필드 대신 고정 값 사용"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400">소스 필드가 비어있을 때만 사용됩니다</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded border border-dashed p-4 text-center text-xs text-gray-400">
|
||||
필드 매핑이 없습니다. "추가" 버튼을 클릭하세요.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 옵션 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">옵션</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="batchSize" className="text-xs">
|
||||
배치 크기
|
||||
</Label>
|
||||
<Input
|
||||
id="batchSize"
|
||||
type="number"
|
||||
value={batchSize}
|
||||
onChange={(e) => setBatchSize(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="한 번에 처리할 레코드 수"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="ignoreDuplicates"
|
||||
checked={ignoreDuplicates}
|
||||
onCheckedChange={(checked) => setIgnoreDuplicates(checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="ignoreDuplicates" className="text-xs font-normal">
|
||||
중복 데이터 무시
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="ignoreErrors"
|
||||
checked={ignoreErrors}
|
||||
onCheckedChange={(checked) => setIgnoreErrors(checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="ignoreErrors" className="text-xs font-normal">
|
||||
오류 발생 시 계속 진행
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 저장 버튼 */}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSave} className="flex-1" size="sm">
|
||||
적용
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 안내 */}
|
||||
<div className="rounded bg-green-50 p-3 text-xs text-green-700">
|
||||
✅ 테이블과 필드는 실제 데이터베이스에서 조회됩니다.
|
||||
<br />
|
||||
💡 소스 필드가 없으면 정적 값이 사용됩니다.
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { LogNodeData } from "@/types/node-editor";
|
||||
import { FileText, Info, AlertTriangle, AlertCircle } from "lucide-react";
|
||||
|
||||
interface LogPropertiesProps {
|
||||
nodeId: string;
|
||||
data: LogNodeData;
|
||||
}
|
||||
|
||||
const LOG_LEVELS = [
|
||||
{ value: "debug", label: "Debug", icon: Info, color: "text-blue-600" },
|
||||
{ value: "info", label: "Info", icon: Info, color: "text-green-600" },
|
||||
{ value: "warn", label: "Warning", icon: AlertTriangle, color: "text-yellow-600" },
|
||||
{ value: "error", label: "Error", icon: AlertCircle, color: "text-red-600" },
|
||||
];
|
||||
|
||||
export function LogProperties({ nodeId, data }: LogPropertiesProps) {
|
||||
const { updateNode } = useFlowEditorStore();
|
||||
|
||||
const [level, setLevel] = useState(data.level || "info");
|
||||
const [message, setMessage] = useState(data.message || "");
|
||||
const [includeData, setIncludeData] = useState(data.includeData ?? false);
|
||||
|
||||
useEffect(() => {
|
||||
setLevel(data.level || "info");
|
||||
setMessage(data.message || "");
|
||||
setIncludeData(data.includeData ?? false);
|
||||
}, [data]);
|
||||
|
||||
const handleApply = () => {
|
||||
updateNode(nodeId, {
|
||||
level: level as any,
|
||||
message,
|
||||
includeData,
|
||||
});
|
||||
};
|
||||
|
||||
const selectedLevel = LOG_LEVELS.find((l) => l.value === level);
|
||||
const LevelIcon = selectedLevel?.icon || Info;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="flex items-center gap-2 rounded-md bg-gray-50 p-2">
|
||||
<FileText className="h-4 w-4 text-gray-600" />
|
||||
<span className="font-semibold text-gray-600">로그</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">로그 레벨</Label>
|
||||
<Select value={level} onValueChange={setLevel}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LOG_LEVELS.map((lvl) => {
|
||||
const Icon = lvl.icon;
|
||||
return (
|
||||
<SelectItem key={lvl.value} value={lvl.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={`h-4 w-4 ${lvl.color}`} />
|
||||
<span>{lvl.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="message" className="text-xs">
|
||||
로그 메시지
|
||||
</Label>
|
||||
<Input
|
||||
id="message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="로그 메시지를 입력하세요"
|
||||
className="mt-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-xs">데이터 포함</Label>
|
||||
<p className="text-xs text-gray-500">플로우 데이터를 로그에 포함합니다</p>
|
||||
</div>
|
||||
<Switch checked={includeData} onCheckedChange={setIncludeData} />
|
||||
</div>
|
||||
|
||||
<div className={`rounded-md border p-3 ${selectedLevel?.color || "text-gray-600"}`}>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<LevelIcon className="h-4 w-4" />
|
||||
<span className="text-xs font-semibold uppercase">{level}</span>
|
||||
</div>
|
||||
<div className="text-sm">{message || "메시지가 없습니다"}</div>
|
||||
{includeData && <div className="mt-1 text-xs opacity-70">+ 플로우 데이터</div>}
|
||||
</div>
|
||||
|
||||
<Button onClick={handleApply} className="w-full">
|
||||
적용
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { RestAPISourceNodeData } from "@/types/node-editor";
|
||||
import { Globe, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
interface RestAPISourcePropertiesProps {
|
||||
nodeId: string;
|
||||
data: RestAPISourceNodeData;
|
||||
}
|
||||
|
||||
const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH"];
|
||||
const AUTH_TYPES = [
|
||||
{ value: "none", label: "인증 없음" },
|
||||
{ value: "bearer", label: "Bearer Token" },
|
||||
{ value: "basic", label: "Basic Auth" },
|
||||
{ value: "apikey", label: "API Key" },
|
||||
];
|
||||
|
||||
export function RestAPISourceProperties({ nodeId, data }: RestAPISourcePropertiesProps) {
|
||||
const { updateNode } = useFlowEditorStore();
|
||||
|
||||
const [displayName, setDisplayName] = useState(data.displayName || "");
|
||||
const [url, setUrl] = useState(data.url || "");
|
||||
const [method, setMethod] = useState(data.method || "GET");
|
||||
const [headers, setHeaders] = useState(data.headers || {});
|
||||
const [newHeaderKey, setNewHeaderKey] = useState("");
|
||||
const [newHeaderValue, setNewHeaderValue] = useState("");
|
||||
const [body, setBody] = useState(JSON.stringify(data.body || {}, null, 2));
|
||||
const [authType, setAuthType] = useState(data.authentication?.type || "none");
|
||||
const [authToken, setAuthToken] = useState(data.authentication?.token || "");
|
||||
const [timeout, setTimeout] = useState(data.timeout?.toString() || "30000");
|
||||
const [responseMapping, setResponseMapping] = useState(data.responseMapping || "");
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || "");
|
||||
setUrl(data.url || "");
|
||||
setMethod(data.method || "GET");
|
||||
setHeaders(data.headers || {});
|
||||
setBody(JSON.stringify(data.body || {}, null, 2));
|
||||
setAuthType(data.authentication?.type || "none");
|
||||
setAuthToken(data.authentication?.token || "");
|
||||
setTimeout(data.timeout?.toString() || "30000");
|
||||
setResponseMapping(data.responseMapping || "");
|
||||
}, [data]);
|
||||
|
||||
const handleApply = () => {
|
||||
let parsedBody = {};
|
||||
try {
|
||||
parsedBody = body.trim() ? JSON.parse(body) : {};
|
||||
} catch (e) {
|
||||
alert("Body JSON 형식이 올바르지 않습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
url,
|
||||
method: method as any,
|
||||
headers,
|
||||
body: parsedBody,
|
||||
authentication: {
|
||||
type: authType as any,
|
||||
token: authToken || undefined,
|
||||
},
|
||||
timeout: parseInt(timeout) || 30000,
|
||||
responseMapping,
|
||||
});
|
||||
};
|
||||
|
||||
const addHeader = () => {
|
||||
if (newHeaderKey.trim() && newHeaderValue.trim()) {
|
||||
setHeaders({ ...headers, [newHeaderKey.trim()]: newHeaderValue.trim() });
|
||||
setNewHeaderKey("");
|
||||
setNewHeaderValue("");
|
||||
}
|
||||
};
|
||||
|
||||
const removeHeader = (key: string) => {
|
||||
const newHeaders = { ...headers };
|
||||
delete newHeaders[key];
|
||||
setHeaders(newHeaders);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="flex items-center gap-2 rounded-md bg-teal-50 p-2">
|
||||
<Globe className="h-4 w-4 text-teal-600" />
|
||||
<span className="font-semibold text-teal-600">REST API 소스</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="displayName" className="text-xs">
|
||||
표시 이름
|
||||
</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="노드에 표시될 이름"
|
||||
className="mt-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="url" className="text-xs">
|
||||
API URL
|
||||
</Label>
|
||||
<Input
|
||||
id="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://api.example.com/data"
|
||||
className="mt-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">HTTP 메서드</Label>
|
||||
<Select value={method} onValueChange={setMethod}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{HTTP_METHODS.map((m) => (
|
||||
<SelectItem key={m} value={m}>
|
||||
{m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">헤더</Label>
|
||||
<div className="mt-1 space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newHeaderKey}
|
||||
onChange={(e) => setNewHeaderKey(e.target.value)}
|
||||
placeholder="Key"
|
||||
className="text-sm"
|
||||
/>
|
||||
<Input
|
||||
value={newHeaderValue}
|
||||
onChange={(e) => setNewHeaderValue(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && addHeader()}
|
||||
placeholder="Value"
|
||||
className="text-sm"
|
||||
/>
|
||||
<Button size="sm" onClick={addHeader}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-[100px] space-y-1 overflow-y-auto">
|
||||
{Object.entries(headers).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center justify-between rounded bg-teal-50 px-2 py-1">
|
||||
<span className="text-xs">
|
||||
<span className="font-medium">{key}:</span> {value}
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeHeader(key)}>
|
||||
<Trash2 className="h-3 w-3 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(method === "POST" || method === "PUT" || method === "PATCH") && (
|
||||
<div>
|
||||
<Label htmlFor="body" className="text-xs">
|
||||
Body (JSON)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="body"
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder='{"key": "value"}'
|
||||
className="mt-1 font-mono text-sm"
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">인증</Label>
|
||||
<Select value={authType} onValueChange={setAuthType}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AUTH_TYPES.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{authType !== "none" && (
|
||||
<Input
|
||||
value={authToken}
|
||||
onChange={(e) => setAuthToken(e.target.value)}
|
||||
placeholder="토큰/키 입력"
|
||||
className="mt-2 text-sm"
|
||||
type="password"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="timeout" className="text-xs">
|
||||
타임아웃 (ms)
|
||||
</Label>
|
||||
<Input
|
||||
id="timeout"
|
||||
type="number"
|
||||
value={timeout}
|
||||
onChange={(e) => setTimeout(e.target.value)}
|
||||
className="mt-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="responseMapping" className="text-xs">
|
||||
응답 매핑 (JSON 경로)
|
||||
</Label>
|
||||
<Input
|
||||
id="responseMapping"
|
||||
value={responseMapping}
|
||||
onChange={(e) => setResponseMapping(e.target.value)}
|
||||
placeholder="예: data.items"
|
||||
className="mt-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleApply} className="w-full">
|
||||
적용
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 테이블 소스 노드 속성 편집
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import type { TableSourceNodeData } from "@/types/node-editor";
|
||||
|
||||
interface TableSourcePropertiesProps {
|
||||
nodeId: string;
|
||||
data: TableSourceNodeData;
|
||||
}
|
||||
|
||||
interface TableOption {
|
||||
tableName: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
label: string; // 표시용 (라벨 또는 테이블명)
|
||||
}
|
||||
|
||||
export function TableSourceProperties({ nodeId, data }: TableSourcePropertiesProps) {
|
||||
const { updateNode } = useFlowEditorStore();
|
||||
|
||||
const [displayName, setDisplayName] = useState(data.displayName || data.tableName);
|
||||
const [tableName, setTableName] = useState(data.tableName);
|
||||
|
||||
// 테이블 선택 관련 상태
|
||||
const [tables, setTables] = useState<TableOption[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// 데이터 변경 시 로컬 상태 업데이트
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || data.tableName);
|
||||
setTableName(data.tableName);
|
||||
}, [data.displayName, data.tableName]);
|
||||
|
||||
// 테이블 목록 로딩
|
||||
useEffect(() => {
|
||||
loadTables();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 테이블 목록 로드
|
||||
*/
|
||||
const loadTables = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
console.log("🔍 테이블 목록 로딩 중...");
|
||||
|
||||
const tableList = await tableTypeApi.getTables();
|
||||
|
||||
// 테이블 목록 변환 (라벨 또는 displayName 우선 표시)
|
||||
const options: TableOption[] = tableList.map((table) => {
|
||||
// tableLabel이 있으면 우선 사용, 없으면 displayName, 그것도 없으면 tableName
|
||||
const label = (table as any).tableLabel || table.displayName || table.tableName || "알 수 없는 테이블";
|
||||
|
||||
return {
|
||||
tableName: table.tableName,
|
||||
displayName: table.displayName || table.tableName,
|
||||
description: table.description || "",
|
||||
label,
|
||||
};
|
||||
});
|
||||
|
||||
setTables(options);
|
||||
console.log(`✅ 테이블 ${options.length}개 로딩 완료`);
|
||||
} catch (error) {
|
||||
console.error("❌ 테이블 목록 로딩 실패:", error);
|
||||
setTables([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 테이블 선택 핸들러 (즉시 노드 업데이트 + 컬럼 로드)
|
||||
*/
|
||||
const handleTableSelect = async (selectedTableName: string) => {
|
||||
const selectedTable = tables.find((t) => t.tableName === selectedTableName);
|
||||
if (selectedTable) {
|
||||
const newTableName = selectedTable.tableName;
|
||||
const newDisplayName = selectedTable.label;
|
||||
|
||||
setTableName(newTableName);
|
||||
setDisplayName(newDisplayName);
|
||||
setOpen(false);
|
||||
|
||||
// 컬럼 정보 로드
|
||||
console.log(`🔍 테이블 "${newTableName}" 컬럼 로드 중...`);
|
||||
try {
|
||||
const columns = await tableTypeApi.getColumns(newTableName);
|
||||
console.log("🔍 API에서 받은 컬럼 데이터:", columns);
|
||||
|
||||
const fields = columns.map((col: any) => ({
|
||||
name: col.column_name || col.columnName,
|
||||
type: col.data_type || col.dataType || "unknown",
|
||||
nullable: col.is_nullable === "YES" || col.isNullable === true,
|
||||
// displayName이 라벨입니다!
|
||||
label: col.displayName || col.label_ko || col.columnLabel || col.column_label,
|
||||
}));
|
||||
|
||||
console.log(`✅ ${fields.length}개 컬럼 로드 완료:`, fields);
|
||||
|
||||
// 필드 정보와 함께 노드 업데이트
|
||||
updateNode(nodeId, {
|
||||
displayName: newDisplayName,
|
||||
tableName: newTableName,
|
||||
fields,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ 컬럼 로드 실패:", error);
|
||||
|
||||
// 실패해도 테이블 정보는 업데이트
|
||||
updateNode(nodeId, {
|
||||
displayName: newDisplayName,
|
||||
tableName: newTableName,
|
||||
fields: [],
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`✅ 테이블 선택: ${newTableName} (${newDisplayName})`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 표시 이름 변경 핸들러
|
||||
*/
|
||||
const handleDisplayNameChange = (newDisplayName: string) => {
|
||||
setDisplayName(newDisplayName);
|
||||
updateNode(nodeId, {
|
||||
displayName: newDisplayName,
|
||||
tableName,
|
||||
});
|
||||
};
|
||||
|
||||
// 현재 선택된 테이블의 라벨 찾기
|
||||
const selectedTableLabel = tables.find((t) => t.tableName === tableName)?.label || tableName;
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="displayName" className="text-xs">
|
||||
표시 이름
|
||||
</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => handleDisplayNameChange(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="노드 표시 이름"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 테이블 선택 Combobox */}
|
||||
<div>
|
||||
<Label className="text-xs">테이블 선택</Label>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="mt-1 w-full justify-between"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="text-muted-foreground">로딩 중...</span>
|
||||
) : tableName ? (
|
||||
<span className="truncate">{selectedTableLabel}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">테이블을 선택하세요</span>
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[320px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="h-9" />
|
||||
<CommandList>
|
||||
<CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<ScrollArea className="h-[300px]">
|
||||
{tables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={`${table.label} ${table.tableName} ${table.description}`}
|
||||
onSelect={() => handleTableSelect(table.tableName)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
tableName === table.tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.label}</span>
|
||||
{table.label !== table.tableName && (
|
||||
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
||||
)}
|
||||
{table.description && (
|
||||
<span className="text-muted-foreground text-xs">{table.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{tableName && selectedTableLabel !== tableName && (
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
실제 테이블명: <code className="rounded bg-gray-100 px-1 py-0.5">{tableName}</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필드 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">출력 필드</h3>
|
||||
{data.fields && data.fields.length > 0 ? (
|
||||
<div className="space-y-1 rounded border p-2">
|
||||
{data.fields.map((field) => (
|
||||
<div key={field.name} className="flex items-center justify-between text-xs">
|
||||
<span className="font-mono text-gray-700">{field.name}</span>
|
||||
<span className="text-gray-400">{field.type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded border p-4 text-center text-xs text-gray-400">필드 정보가 없습니다</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 안내 */}
|
||||
<div className="rounded bg-green-50 p-3 text-xs text-green-700">✅ 변경 사항이 즉시 노드에 반영됩니다.</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,757 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* UPDATE 액션 노드 속성 편집 (개선 버전)
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import type { UpdateActionNodeData } from "@/types/node-editor";
|
||||
|
||||
interface UpdateActionPropertiesProps {
|
||||
nodeId: string;
|
||||
data: UpdateActionNodeData;
|
||||
}
|
||||
|
||||
interface TableOption {
|
||||
tableName: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ColumnInfo {
|
||||
columnName: string;
|
||||
columnLabel?: string;
|
||||
dataType: string;
|
||||
isNullable: boolean;
|
||||
}
|
||||
|
||||
const OPERATORS = [
|
||||
{ value: "EQUALS", label: "=" },
|
||||
{ value: "NOT_EQUALS", label: "≠" },
|
||||
{ value: "GREATER_THAN", label: ">" },
|
||||
{ value: "LESS_THAN", label: "<" },
|
||||
{ value: "GREATER_THAN_OR_EQUAL", label: "≥" },
|
||||
{ value: "LESS_THAN_OR_EQUAL", label: "≤" },
|
||||
{ value: "LIKE", label: "LIKE" },
|
||||
{ value: "NOT_LIKE", label: "NOT LIKE" },
|
||||
{ value: "IN", label: "IN" },
|
||||
{ value: "NOT_IN", label: "NOT IN" },
|
||||
{ value: "IS_NULL", label: "IS NULL" },
|
||||
{ value: "IS_NOT_NULL", label: "IS NOT NULL" },
|
||||
] as const;
|
||||
|
||||
export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesProps) {
|
||||
const { updateNode, nodes, edges } = useFlowEditorStore();
|
||||
|
||||
const [displayName, setDisplayName] = useState(data.displayName || data.targetTable);
|
||||
const [targetTable, setTargetTable] = useState(data.targetTable);
|
||||
const [fieldMappings, setFieldMappings] = useState(data.fieldMappings || []);
|
||||
const [whereConditions, setWhereConditions] = useState(data.whereConditions || []);
|
||||
const [batchSize, setBatchSize] = useState(data.options?.batchSize?.toString() || "");
|
||||
const [ignoreErrors, setIgnoreErrors] = useState(data.options?.ignoreErrors || false);
|
||||
|
||||
// 테이블 관련 상태
|
||||
const [tables, setTables] = useState<TableOption[]>([]);
|
||||
const [tablesLoading, setTablesLoading] = useState(false);
|
||||
const [tablesOpen, setTablesOpen] = useState(false);
|
||||
|
||||
// 컬럼 관련 상태
|
||||
const [targetColumns, setTargetColumns] = useState<ColumnInfo[]>([]);
|
||||
const [columnsLoading, setColumnsLoading] = useState(false);
|
||||
|
||||
// 소스 필드 목록 (연결된 입력 노드에서 가져오기)
|
||||
const [sourceFields, setSourceFields] = useState<Array<{ name: string; label?: string }>>([]);
|
||||
|
||||
// 데이터 변경 시 로컬 상태 업데이트
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || data.targetTable);
|
||||
setTargetTable(data.targetTable);
|
||||
setFieldMappings(data.fieldMappings || []);
|
||||
setWhereConditions(data.whereConditions || []);
|
||||
setBatchSize(data.options?.batchSize?.toString() || "");
|
||||
setIgnoreErrors(data.options?.ignoreErrors || false);
|
||||
}, [data]);
|
||||
|
||||
// 테이블 목록 로딩
|
||||
useEffect(() => {
|
||||
loadTables();
|
||||
}, []);
|
||||
|
||||
// 타겟 테이블 변경 시 컬럼 로딩
|
||||
useEffect(() => {
|
||||
if (targetTable) {
|
||||
loadColumns(targetTable);
|
||||
}
|
||||
}, [targetTable]);
|
||||
|
||||
// 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색)
|
||||
useEffect(() => {
|
||||
const getAllSourceFields = (
|
||||
targetNodeId: string,
|
||||
visitedNodes = new Set<string>(),
|
||||
): Array<{ name: string; label?: string }> => {
|
||||
if (visitedNodes.has(targetNodeId)) {
|
||||
return [];
|
||||
}
|
||||
visitedNodes.add(targetNodeId);
|
||||
|
||||
const inputEdges = edges.filter((edge) => edge.target === targetNodeId);
|
||||
const sourceNodeIds = inputEdges.map((edge) => edge.source);
|
||||
const sourceNodes = nodes.filter((node) => sourceNodeIds.includes(node.id));
|
||||
|
||||
const fields: Array<{ name: string; label?: string }> = [];
|
||||
|
||||
sourceNodes.forEach((node) => {
|
||||
// 데이터 변환 노드인 경우: 변환된 필드 + 상위 노드의 원본 필드
|
||||
if (node.type === "dataTransform") {
|
||||
// 상위 노드의 원본 필드 먼저 수집
|
||||
const upperFields = getAllSourceFields(node.id, visitedNodes);
|
||||
|
||||
// 변환된 필드 추가 (in-place 변환 고려)
|
||||
if (node.data.transformations && Array.isArray(node.data.transformations)) {
|
||||
const inPlaceFields = new Set<string>();
|
||||
|
||||
node.data.transformations.forEach((transform: any) => {
|
||||
const targetField = transform.targetField || transform.sourceField;
|
||||
const isInPlace = !transform.targetField || transform.targetField === transform.sourceField;
|
||||
|
||||
if (isInPlace) {
|
||||
inPlaceFields.add(transform.sourceField);
|
||||
} else if (targetField) {
|
||||
fields.push({
|
||||
name: targetField,
|
||||
label: transform.targetFieldLabel || targetField,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 상위 필드 추가 (모두 포함, in-place는 변환 후 값)
|
||||
upperFields.forEach((field) => {
|
||||
fields.push(field);
|
||||
});
|
||||
} else {
|
||||
fields.push(...upperFields);
|
||||
}
|
||||
}
|
||||
// 일반 소스 노드인 경우
|
||||
else if (node.type === "tableSource" && node.data.fields) {
|
||||
node.data.fields.forEach((field: any) => {
|
||||
fields.push({
|
||||
name: field.name,
|
||||
label: field.label || field.displayName,
|
||||
});
|
||||
});
|
||||
} else if (node.type === "externalDBSource" && node.data.fields) {
|
||||
node.data.fields.forEach((field: any) => {
|
||||
fields.push({
|
||||
name: field.name,
|
||||
label: field.label || field.displayName,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return fields;
|
||||
};
|
||||
|
||||
const allFields = getAllSourceFields(nodeId);
|
||||
|
||||
// 중복 제거
|
||||
const uniqueFields = Array.from(new Map(allFields.map((field) => [field.name, field])).values());
|
||||
|
||||
setSourceFields(uniqueFields);
|
||||
}, [nodeId, nodes, edges]);
|
||||
|
||||
const loadTables = async () => {
|
||||
try {
|
||||
setTablesLoading(true);
|
||||
const tableList = await tableTypeApi.getTables();
|
||||
console.log("🔍 UPDATE 노드 - 테이블 목록:", tableList);
|
||||
|
||||
const options: TableOption[] = tableList.map((table) => {
|
||||
const label = (table as any).tableLabel || table.displayName || table.tableName || "알 수 없는 테이블";
|
||||
|
||||
return {
|
||||
tableName: table.tableName,
|
||||
displayName: table.displayName || table.tableName,
|
||||
description: table.description || "",
|
||||
label,
|
||||
};
|
||||
});
|
||||
|
||||
console.log("✅ UPDATE 노드 - 테이블 옵션:", options);
|
||||
setTables(options);
|
||||
} catch (error) {
|
||||
console.error("❌ UPDATE 노드 - 테이블 목록 로딩 실패:", error);
|
||||
} finally {
|
||||
setTablesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadColumns = async (tableName: string) => {
|
||||
try {
|
||||
setColumnsLoading(true);
|
||||
console.log(`🔍 UPDATE 노드 - 컬럼 조회 중: ${tableName}`);
|
||||
|
||||
const columns = await tableTypeApi.getColumns(tableName);
|
||||
|
||||
const columnInfo: ColumnInfo[] = columns.map((col: any) => ({
|
||||
columnName: col.column_name || col.columnName,
|
||||
columnLabel: col.label_ko || col.columnLabel,
|
||||
dataType: col.data_type || col.dataType || "unknown",
|
||||
isNullable: col.is_nullable === "YES" || col.isNullable === true,
|
||||
}));
|
||||
|
||||
setTargetColumns(columnInfo);
|
||||
console.log(`✅ UPDATE 노드 - 컬럼 ${columnInfo.length}개 로딩 완료`);
|
||||
} catch (error) {
|
||||
console.error("❌ UPDATE 노드 - 컬럼 목록 로딩 실패:", error);
|
||||
setTargetColumns([]);
|
||||
} finally {
|
||||
setColumnsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTableSelect = async (newTableName: string) => {
|
||||
const selectedTable = tables.find((t) => t.tableName === newTableName);
|
||||
const newDisplayName = selectedTable?.label || selectedTable?.displayName || newTableName;
|
||||
|
||||
setTargetTable(newTableName);
|
||||
setDisplayName(newDisplayName);
|
||||
|
||||
await loadColumns(newTableName);
|
||||
|
||||
// 즉시 반영
|
||||
updateNode(nodeId, {
|
||||
displayName: newDisplayName,
|
||||
targetTable: newTableName,
|
||||
fieldMappings,
|
||||
whereConditions,
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
ignoreErrors,
|
||||
},
|
||||
});
|
||||
|
||||
setTablesOpen(false);
|
||||
};
|
||||
|
||||
const handleAddMapping = () => {
|
||||
setFieldMappings([
|
||||
...fieldMappings,
|
||||
{
|
||||
sourceField: null,
|
||||
targetField: "",
|
||||
staticValue: undefined,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRemoveMapping = (index: number) => {
|
||||
const newMappings = fieldMappings.filter((_, i) => i !== index);
|
||||
setFieldMappings(newMappings);
|
||||
|
||||
// 즉시 반영
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
targetTable,
|
||||
fieldMappings: newMappings,
|
||||
whereConditions,
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
ignoreErrors,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleMappingChange = (index: number, field: string, value: any) => {
|
||||
const newMappings = [...fieldMappings];
|
||||
|
||||
// 필드 변경 시 라벨도 함께 저장
|
||||
if (field === "sourceField") {
|
||||
const sourceField = sourceFields.find((f) => f.name === value);
|
||||
newMappings[index] = {
|
||||
...newMappings[index],
|
||||
sourceField: value,
|
||||
sourceFieldLabel: sourceField?.label,
|
||||
};
|
||||
} else if (field === "targetField") {
|
||||
const targetColumn = targetColumns.find((c) => c.columnName === value);
|
||||
newMappings[index] = {
|
||||
...newMappings[index],
|
||||
targetField: value,
|
||||
targetFieldLabel: targetColumn?.columnLabel,
|
||||
};
|
||||
} else {
|
||||
newMappings[index] = { ...newMappings[index], [field]: value };
|
||||
}
|
||||
|
||||
setFieldMappings(newMappings);
|
||||
};
|
||||
|
||||
const handleAddCondition = () => {
|
||||
setWhereConditions([
|
||||
...whereConditions,
|
||||
{
|
||||
field: "",
|
||||
operator: "EQUALS",
|
||||
staticValue: "",
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRemoveCondition = (index: number) => {
|
||||
const newConditions = whereConditions.filter((_, i) => i !== index);
|
||||
setWhereConditions(newConditions);
|
||||
|
||||
// 즉시 반영
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
targetTable,
|
||||
fieldMappings,
|
||||
whereConditions: newConditions,
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
ignoreErrors,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleConditionChange = (index: number, field: string, value: any) => {
|
||||
const newConditions = [...whereConditions];
|
||||
|
||||
// 필드 변경 시 라벨도 함께 저장
|
||||
if (field === "field") {
|
||||
const targetColumn = targetColumns.find((c) => c.columnName === value);
|
||||
newConditions[index] = {
|
||||
...newConditions[index],
|
||||
field: value,
|
||||
fieldLabel: targetColumn?.columnLabel,
|
||||
};
|
||||
} else if (field === "sourceField") {
|
||||
const sourceField = sourceFields.find((f) => f.name === value);
|
||||
newConditions[index] = {
|
||||
...newConditions[index],
|
||||
sourceField: value,
|
||||
sourceFieldLabel: sourceField?.label,
|
||||
};
|
||||
} else {
|
||||
newConditions[index] = { ...newConditions[index], [field]: value };
|
||||
}
|
||||
|
||||
setWhereConditions(newConditions);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
targetTable,
|
||||
fieldMappings,
|
||||
whereConditions,
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
ignoreErrors,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const selectedTableLabel = tables.find((t) => t.tableName === targetTable)?.label || targetTable;
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="displayName" className="text-xs">
|
||||
표시 이름
|
||||
</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="노드 표시 이름"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 타겟 테이블 Combobox */}
|
||||
<div>
|
||||
<Label className="text-xs">타겟 테이블</Label>
|
||||
<Popover open={tablesOpen} onOpenChange={setTablesOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tablesOpen}
|
||||
className="mt-1 w-full justify-between"
|
||||
disabled={tablesLoading}
|
||||
>
|
||||
{tablesLoading ? (
|
||||
<span className="text-muted-foreground">로딩 중...</span>
|
||||
) : targetTable ? (
|
||||
<span className="truncate">{selectedTableLabel}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">테이블을 선택하세요</span>
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[320px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." />
|
||||
<CommandEmpty>테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
{tables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={`${table.label} ${table.displayName} ${table.tableName}`}
|
||||
onSelect={() => handleTableSelect(table.tableName)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
targetTable === table.tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.label || table.displayName}</span>
|
||||
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* WHERE 조건 */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">WHERE 조건</h3>
|
||||
<Button size="sm" variant="outline" onClick={handleAddCondition} className="h-7 px-2 text-xs">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
조건 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!targetTable && !columnsLoading && (
|
||||
<div className="rounded border border-dashed bg-yellow-50 p-3 text-center text-xs text-yellow-700">
|
||||
⚠️ 먼저 타겟 테이블을 선택하세요
|
||||
</div>
|
||||
)}
|
||||
|
||||
{targetTable && whereConditions.length === 0 && (
|
||||
<div className="rounded border border-dashed bg-gray-50 p-3 text-center text-xs text-gray-500">
|
||||
WHERE 조건을 추가하세요
|
||||
</div>
|
||||
)}
|
||||
|
||||
{targetTable && whereConditions.length > 0 && targetColumns.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{whereConditions.map((condition, index) => (
|
||||
<div key={index} className="rounded border bg-blue-50 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-gray-700">조건 #{index + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveCondition(index)}
|
||||
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* 타겟 필드 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">타겟 필드</Label>
|
||||
<Select
|
||||
value={condition.field}
|
||||
onValueChange={(value) => handleConditionChange(index, "field", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-mono">{col.columnLabel || col.columnName}</span>
|
||||
<span className="text-muted-foreground">{col.dataType}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 연산자 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">연산자</Label>
|
||||
<Select
|
||||
value={condition.operator}
|
||||
onValueChange={(value) => handleConditionChange(index, "operator", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OPERATORS.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value} className="text-xs">
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* IS_NULL, IS_NOT_NULL이 아닐 때만 소스 필드와 정적 값 표시 */}
|
||||
{condition.operator !== "IS_NULL" && condition.operator !== "IS_NOT_NULL" && (
|
||||
<>
|
||||
{/* 소스 필드 또는 값 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">소스 필드 (선택)</Label>
|
||||
<Select
|
||||
value={condition.sourceField || "_NONE_"}
|
||||
onValueChange={(value) =>
|
||||
handleConditionChange(index, "sourceField", value === "_NONE_" ? undefined : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="소스 필드 선택 (선택)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_NONE_" className="text-xs text-gray-400">
|
||||
없음 (정적 값 사용)
|
||||
</SelectItem>
|
||||
{sourceFields.map((field) => (
|
||||
<SelectItem key={field.name} value={field.name} className="text-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{field.label || field.name}</span>
|
||||
{field.label && field.label !== field.name && (
|
||||
<span className="text-muted-foreground font-mono text-xs">{field.name}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 정적 값 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">정적 값</Label>
|
||||
<Input
|
||||
value={condition.staticValue || ""}
|
||||
onChange={(e) => handleConditionChange(index, "staticValue", e.target.value || undefined)}
|
||||
placeholder="비교할 고정 값"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400">소스 필드가 비어있을 때만 사용됩니다</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* IS_NULL, IS_NOT_NULL일 때 안내 메시지 */}
|
||||
{(condition.operator === "IS_NULL" || condition.operator === "IS_NOT_NULL") && (
|
||||
<div className="rounded bg-blue-50 p-3 text-xs text-blue-700">
|
||||
ℹ️ {condition.operator === "IS_NULL" ? "IS NULL" : "IS NOT NULL"} 조건은 값 비교가 필요하지
|
||||
않습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 필드 매핑 */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">업데이트 필드</h3>
|
||||
<Button size="sm" variant="outline" onClick={handleAddMapping} className="h-7 px-2 text-xs">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
매핑 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!targetTable && !columnsLoading && (
|
||||
<div className="rounded border border-dashed bg-yellow-50 p-3 text-center text-xs text-yellow-700">
|
||||
⚠️ 먼저 타겟 테이블을 선택하세요
|
||||
</div>
|
||||
)}
|
||||
|
||||
{targetTable && !columnsLoading && targetColumns.length === 0 && (
|
||||
<div className="rounded border border-dashed bg-red-50 p-3 text-center text-xs text-red-700">
|
||||
❌ 컬럼 정보를 불러올 수 없습니다
|
||||
</div>
|
||||
)}
|
||||
|
||||
{targetTable && targetColumns.length > 0 && (
|
||||
<>
|
||||
{fieldMappings.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{fieldMappings.map((mapping, index) => (
|
||||
<div key={index} className="rounded border bg-gray-50 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-gray-700">매핑 #{index + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveMapping(index)}
|
||||
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* 소스 필드 드롭다운 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">소스 필드</Label>
|
||||
<Select
|
||||
value={mapping.sourceField || ""}
|
||||
onValueChange={(value) => handleMappingChange(index, "sourceField", value || null)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="소스 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceFields.length === 0 ? (
|
||||
<div className="p-2 text-center text-xs text-gray-400">연결된 소스 노드가 없습니다</div>
|
||||
) : (
|
||||
sourceFields.map((field) => (
|
||||
<SelectItem key={field.name} value={field.name} className="text-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{field.label || field.name}</span>
|
||||
{field.label && field.label !== field.name && (
|
||||
<span className="text-muted-foreground font-mono text-xs">{field.name}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center py-1">
|
||||
<ArrowRight className="h-4 w-4 text-blue-600" />
|
||||
</div>
|
||||
|
||||
{/* 타겟 필드 드롭다운 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">타겟 필드</Label>
|
||||
<Select
|
||||
value={mapping.targetField}
|
||||
onValueChange={(value) => handleMappingChange(index, "targetField", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="타겟 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-mono">{col.columnLabel || col.columnName}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{col.dataType}
|
||||
{!col.isNullable && <span className="text-red-500">*</span>}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 정적 값 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">정적 값 (선택)</Label>
|
||||
<Input
|
||||
value={mapping.staticValue || ""}
|
||||
onChange={(e) => handleMappingChange(index, "staticValue", e.target.value || undefined)}
|
||||
placeholder="소스 필드 대신 고정 값 사용"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400">소스 필드가 비어있을 때만 사용됩니다</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded border border-dashed bg-gray-50 p-3 text-center text-xs text-gray-500">
|
||||
업데이트할 필드를 추가하세요
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 옵션 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">옵션</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="batchSize" className="text-xs">
|
||||
배치 크기
|
||||
</Label>
|
||||
<Input
|
||||
id="batchSize"
|
||||
type="number"
|
||||
value={batchSize}
|
||||
onChange={(e) => setBatchSize(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="예: 100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="ignoreErrors"
|
||||
checked={ignoreErrors}
|
||||
onCheckedChange={(checked) => setIgnoreErrors(checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="ignoreErrors" className="cursor-pointer text-xs font-normal">
|
||||
오류 무시
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 적용 버튼 */}
|
||||
<div className="sticky bottom-0 border-t bg-white pt-3">
|
||||
<Button onClick={handleSave} className="w-full" size="sm">
|
||||
적용
|
||||
</Button>
|
||||
<p className="mt-2 text-center text-xs text-gray-500">✅ 변경 사항이 즉시 노드에 반영됩니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,638 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* UPSERT 액션 노드 속성 편집 (개선 버전)
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import type { UpsertActionNodeData } from "@/types/node-editor";
|
||||
|
||||
interface UpsertActionPropertiesProps {
|
||||
nodeId: string;
|
||||
data: UpsertActionNodeData;
|
||||
}
|
||||
|
||||
interface TableOption {
|
||||
tableName: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ColumnInfo {
|
||||
columnName: string;
|
||||
columnLabel?: string;
|
||||
dataType: string;
|
||||
isNullable: boolean;
|
||||
}
|
||||
|
||||
export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesProps) {
|
||||
const { updateNode, nodes, edges } = useFlowEditorStore();
|
||||
|
||||
const [displayName, setDisplayName] = useState(data.displayName || data.targetTable);
|
||||
const [targetTable, setTargetTable] = useState(data.targetTable);
|
||||
const [conflictKeys, setConflictKeys] = useState<string[]>(data.conflictKeys || []);
|
||||
const [conflictKeyLabels, setConflictKeyLabels] = useState<string[]>(data.conflictKeyLabels || []);
|
||||
const [fieldMappings, setFieldMappings] = useState(data.fieldMappings || []);
|
||||
const [batchSize, setBatchSize] = useState(data.options?.batchSize?.toString() || "");
|
||||
const [updateOnConflict, setUpdateOnConflict] = useState(data.options?.updateOnConflict ?? true);
|
||||
|
||||
// 테이블 관련 상태
|
||||
const [tables, setTables] = useState<TableOption[]>([]);
|
||||
const [tablesLoading, setTablesLoading] = useState(false);
|
||||
const [tablesOpen, setTablesOpen] = useState(false);
|
||||
|
||||
// 컬럼 관련 상태
|
||||
const [targetColumns, setTargetColumns] = useState<ColumnInfo[]>([]);
|
||||
const [columnsLoading, setColumnsLoading] = useState(false);
|
||||
|
||||
// 소스 필드 목록 (연결된 입력 노드에서 가져오기)
|
||||
const [sourceFields, setSourceFields] = useState<Array<{ name: string; label?: string }>>([]);
|
||||
|
||||
// 데이터 변경 시 로컬 상태 업데이트
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || data.targetTable);
|
||||
setTargetTable(data.targetTable);
|
||||
setConflictKeys(data.conflictKeys || []);
|
||||
setConflictKeyLabels(data.conflictKeyLabels || []);
|
||||
setFieldMappings(data.fieldMappings || []);
|
||||
setBatchSize(data.options?.batchSize?.toString() || "");
|
||||
setUpdateOnConflict(data.options?.updateOnConflict ?? true);
|
||||
}, [data]);
|
||||
|
||||
// 테이블 목록 로딩
|
||||
useEffect(() => {
|
||||
loadTables();
|
||||
}, []);
|
||||
|
||||
// 타겟 테이블 변경 시 컬럼 로딩
|
||||
useEffect(() => {
|
||||
if (targetTable) {
|
||||
loadColumns(targetTable);
|
||||
}
|
||||
}, [targetTable]);
|
||||
|
||||
// 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색)
|
||||
useEffect(() => {
|
||||
const getAllSourceFields = (
|
||||
targetNodeId: string,
|
||||
visitedNodes = new Set<string>(),
|
||||
): Array<{ name: string; label?: string }> => {
|
||||
if (visitedNodes.has(targetNodeId)) {
|
||||
return [];
|
||||
}
|
||||
visitedNodes.add(targetNodeId);
|
||||
|
||||
const inputEdges = edges.filter((edge) => edge.target === targetNodeId);
|
||||
const sourceNodeIds = inputEdges.map((edge) => edge.source);
|
||||
const sourceNodes = nodes.filter((node) => sourceNodeIds.includes(node.id));
|
||||
|
||||
const fields: Array<{ name: string; label?: string }> = [];
|
||||
|
||||
sourceNodes.forEach((node) => {
|
||||
// 데이터 변환 노드인 경우: 변환된 필드 + 상위 노드의 원본 필드
|
||||
if (node.type === "dataTransform") {
|
||||
// 상위 노드의 원본 필드 먼저 수집
|
||||
const upperFields = getAllSourceFields(node.id, visitedNodes);
|
||||
|
||||
// 변환된 필드 추가 (in-place 변환 고려)
|
||||
if (node.data.transformations && Array.isArray(node.data.transformations)) {
|
||||
const inPlaceFields = new Set<string>();
|
||||
|
||||
node.data.transformations.forEach((transform: any) => {
|
||||
const targetField = transform.targetField || transform.sourceField;
|
||||
const isInPlace = !transform.targetField || transform.targetField === transform.sourceField;
|
||||
|
||||
if (isInPlace) {
|
||||
inPlaceFields.add(transform.sourceField);
|
||||
} else if (targetField) {
|
||||
fields.push({
|
||||
name: targetField,
|
||||
label: transform.targetFieldLabel || targetField,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 상위 필드 추가 (모두 포함, in-place는 변환 후 값)
|
||||
upperFields.forEach((field) => {
|
||||
fields.push(field);
|
||||
});
|
||||
} else {
|
||||
fields.push(...upperFields);
|
||||
}
|
||||
}
|
||||
// 일반 소스 노드인 경우
|
||||
else if (node.type === "tableSource" && node.data.fields) {
|
||||
node.data.fields.forEach((field: any) => {
|
||||
fields.push({
|
||||
name: field.name,
|
||||
label: field.label || field.displayName,
|
||||
});
|
||||
});
|
||||
} else if (node.type === "externalDBSource" && node.data.fields) {
|
||||
node.data.fields.forEach((field: any) => {
|
||||
fields.push({
|
||||
name: field.name,
|
||||
label: field.label || field.displayName,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return fields;
|
||||
};
|
||||
|
||||
const allFields = getAllSourceFields(nodeId);
|
||||
|
||||
// 중복 제거
|
||||
const uniqueFields = Array.from(new Map(allFields.map((field) => [field.name, field])).values());
|
||||
|
||||
setSourceFields(uniqueFields);
|
||||
}, [nodeId, nodes, edges]);
|
||||
|
||||
const loadTables = async () => {
|
||||
try {
|
||||
setTablesLoading(true);
|
||||
const tableList = await tableTypeApi.getTables();
|
||||
|
||||
const options: TableOption[] = tableList.map((table) => {
|
||||
const label = (table as any).tableLabel || table.displayName || table.tableName || "알 수 없는 테이블";
|
||||
|
||||
return {
|
||||
tableName: table.tableName,
|
||||
displayName: table.displayName || table.tableName,
|
||||
description: table.description || "",
|
||||
label,
|
||||
};
|
||||
});
|
||||
|
||||
setTables(options);
|
||||
} catch (error) {
|
||||
console.error("❌ UPSERT 노드 - 테이블 목록 로딩 실패:", error);
|
||||
} finally {
|
||||
setTablesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadColumns = async (tableName: string) => {
|
||||
try {
|
||||
setColumnsLoading(true);
|
||||
const columns = await tableTypeApi.getColumns(tableName);
|
||||
|
||||
const columnInfo: ColumnInfo[] = columns.map((col: any) => ({
|
||||
columnName: col.column_name || col.columnName,
|
||||
columnLabel: col.label_ko || col.columnLabel,
|
||||
dataType: col.data_type || col.dataType || "unknown",
|
||||
isNullable: col.is_nullable === "YES" || col.isNullable === true,
|
||||
}));
|
||||
|
||||
setTargetColumns(columnInfo);
|
||||
} catch (error) {
|
||||
console.error("❌ UPSERT 노드 - 컬럼 목록 로딩 실패:", error);
|
||||
setTargetColumns([]);
|
||||
} finally {
|
||||
setColumnsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTableSelect = async (newTableName: string) => {
|
||||
const selectedTable = tables.find((t) => t.tableName === newTableName);
|
||||
const newDisplayName = selectedTable?.label || selectedTable?.displayName || newTableName;
|
||||
|
||||
setTargetTable(newTableName);
|
||||
setDisplayName(newDisplayName);
|
||||
|
||||
await loadColumns(newTableName);
|
||||
|
||||
// 즉시 반영
|
||||
updateNode(nodeId, {
|
||||
displayName: newDisplayName,
|
||||
targetTable: newTableName,
|
||||
conflictKeys,
|
||||
conflictKeyLabels,
|
||||
fieldMappings,
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
updateOnConflict,
|
||||
},
|
||||
});
|
||||
|
||||
setTablesOpen(false);
|
||||
};
|
||||
|
||||
const handleAddConflictKey = (columnName: string) => {
|
||||
if (!conflictKeys.includes(columnName)) {
|
||||
const column = targetColumns.find((c) => c.columnName === columnName);
|
||||
const newConflictKeys = [...conflictKeys, columnName];
|
||||
const newConflictKeyLabels = [...conflictKeyLabels, column?.columnLabel || columnName];
|
||||
|
||||
setConflictKeys(newConflictKeys);
|
||||
setConflictKeyLabels(newConflictKeyLabels);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveConflictKey = (index: number) => {
|
||||
const newKeys = conflictKeys.filter((_, i) => i !== index);
|
||||
const newLabels = conflictKeyLabels.filter((_, i) => i !== index);
|
||||
|
||||
setConflictKeys(newKeys);
|
||||
setConflictKeyLabels(newLabels);
|
||||
|
||||
// 즉시 반영
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
targetTable,
|
||||
conflictKeys: newKeys,
|
||||
conflictKeyLabels: newLabels,
|
||||
fieldMappings,
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
updateOnConflict,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddMapping = () => {
|
||||
setFieldMappings([
|
||||
...fieldMappings,
|
||||
{
|
||||
sourceField: null,
|
||||
targetField: "",
|
||||
staticValue: undefined,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRemoveMapping = (index: number) => {
|
||||
const newMappings = fieldMappings.filter((_, i) => i !== index);
|
||||
setFieldMappings(newMappings);
|
||||
|
||||
// 즉시 반영
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
targetTable,
|
||||
conflictKeys,
|
||||
conflictKeyLabels,
|
||||
fieldMappings: newMappings,
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
updateOnConflict,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleMappingChange = (index: number, field: string, value: any) => {
|
||||
const newMappings = [...fieldMappings];
|
||||
|
||||
// 필드 변경 시 라벨도 함께 저장
|
||||
if (field === "sourceField") {
|
||||
const sourceField = sourceFields.find((f) => f.name === value);
|
||||
newMappings[index] = {
|
||||
...newMappings[index],
|
||||
sourceField: value,
|
||||
sourceFieldLabel: sourceField?.label,
|
||||
};
|
||||
} else if (field === "targetField") {
|
||||
const targetColumn = targetColumns.find((c) => c.columnName === value);
|
||||
newMappings[index] = {
|
||||
...newMappings[index],
|
||||
targetField: value,
|
||||
targetFieldLabel: targetColumn?.columnLabel,
|
||||
};
|
||||
} else {
|
||||
newMappings[index] = { ...newMappings[index], [field]: value };
|
||||
}
|
||||
|
||||
setFieldMappings(newMappings);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
targetTable,
|
||||
conflictKeys,
|
||||
conflictKeyLabels,
|
||||
fieldMappings,
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
updateOnConflict,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const selectedTableLabel = tables.find((t) => t.tableName === targetTable)?.label || targetTable;
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="displayName" className="text-xs">
|
||||
표시 이름
|
||||
</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="노드 표시 이름"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 타겟 테이블 Combobox */}
|
||||
<div>
|
||||
<Label className="text-xs">타겟 테이블</Label>
|
||||
<Popover open={tablesOpen} onOpenChange={setTablesOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tablesOpen}
|
||||
className="mt-1 w-full justify-between"
|
||||
disabled={tablesLoading}
|
||||
>
|
||||
{tablesLoading ? (
|
||||
<span className="text-muted-foreground">로딩 중...</span>
|
||||
) : targetTable ? (
|
||||
<span className="truncate">{selectedTableLabel}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">테이블을 선택하세요</span>
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[320px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="h-9" />
|
||||
<CommandEmpty>테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
{tables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={`${table.label} ${table.displayName} ${table.tableName}`}
|
||||
onSelect={() => handleTableSelect(table.tableName)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
targetTable === table.tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.label || table.displayName}</span>
|
||||
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 충돌 키 (ON CONFLICT) */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">충돌 키 (ON CONFLICT)</h3>
|
||||
<p className="text-xs text-gray-500">중복 체크에 사용할 키를 선택하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!targetTable && (
|
||||
<div className="rounded border border-dashed bg-yellow-50 p-3 text-center text-xs text-yellow-700">
|
||||
⚠️ 먼저 타겟 테이블을 선택하세요
|
||||
</div>
|
||||
)}
|
||||
|
||||
{targetTable && targetColumns.length > 0 && (
|
||||
<>
|
||||
{/* 선택된 충돌 키 */}
|
||||
{conflictKeys.length > 0 ? (
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
{conflictKeys.map((key, idx) => (
|
||||
<div key={idx} className="flex items-center gap-1 rounded bg-purple-100 px-2 py-1 text-xs">
|
||||
<span className="font-medium text-purple-700">{conflictKeyLabels[idx] || key}</span>
|
||||
<button
|
||||
onClick={() => handleRemoveConflictKey(idx)}
|
||||
className="ml-1 text-purple-600 hover:text-purple-800"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-3 rounded border border-dashed bg-gray-50 p-3 text-center text-xs text-gray-500">
|
||||
충돌 키를 추가하세요
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 충돌 키 추가 드롭다운 */}
|
||||
<Select onValueChange={handleAddConflictKey}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="충돌 키 추가..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetColumns
|
||||
.filter((col) => !conflictKeys.includes(col.columnName))
|
||||
.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-mono">{col.columnLabel || col.columnName}</span>
|
||||
<span className="text-muted-foreground">{col.dataType}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 필드 매핑 */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">필드 매핑</h3>
|
||||
<Button size="sm" variant="outline" onClick={handleAddMapping} className="h-7 px-2 text-xs">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
매핑 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!targetTable && !columnsLoading && (
|
||||
<div className="rounded border border-dashed bg-yellow-50 p-3 text-center text-xs text-yellow-700">
|
||||
⚠️ 먼저 타겟 테이블을 선택하세요
|
||||
</div>
|
||||
)}
|
||||
|
||||
{targetTable && !columnsLoading && targetColumns.length === 0 && (
|
||||
<div className="rounded border border-dashed bg-red-50 p-3 text-center text-xs text-red-700">
|
||||
❌ 컬럼 정보를 불러올 수 없습니다
|
||||
</div>
|
||||
)}
|
||||
|
||||
{targetTable && targetColumns.length > 0 && (
|
||||
<>
|
||||
{fieldMappings.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{fieldMappings.map((mapping, index) => (
|
||||
<div key={index} className="rounded border bg-gray-50 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-gray-700">매핑 #{index + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveMapping(index)}
|
||||
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* 소스 필드 드롭다운 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">소스 필드</Label>
|
||||
<Select
|
||||
value={mapping.sourceField || ""}
|
||||
onValueChange={(value) => handleMappingChange(index, "sourceField", value || null)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="소스 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceFields.length === 0 ? (
|
||||
<div className="p-2 text-center text-xs text-gray-400">연결된 소스 노드가 없습니다</div>
|
||||
) : (
|
||||
sourceFields.map((field) => (
|
||||
<SelectItem key={field.name} value={field.name} className="text-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{field.label || field.name}</span>
|
||||
{field.label && field.label !== field.name && (
|
||||
<span className="text-muted-foreground font-mono text-xs">{field.name}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center py-1">
|
||||
<ArrowRight className="h-4 w-4 text-purple-600" />
|
||||
</div>
|
||||
|
||||
{/* 타겟 필드 드롭다운 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">타겟 필드</Label>
|
||||
<Select
|
||||
value={mapping.targetField}
|
||||
onValueChange={(value) => handleMappingChange(index, "targetField", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="타겟 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-mono">{col.columnLabel || col.columnName}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{col.dataType}
|
||||
{!col.isNullable && <span className="text-red-500">*</span>}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 정적 값 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">정적 값 (선택)</Label>
|
||||
<Input
|
||||
value={mapping.staticValue || ""}
|
||||
onChange={(e) => handleMappingChange(index, "staticValue", e.target.value || undefined)}
|
||||
placeholder="소스 필드 대신 고정 값 사용"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400">소스 필드가 비어있을 때만 사용됩니다</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded border border-dashed bg-gray-50 p-3 text-center text-xs text-gray-500">
|
||||
UPSERT할 필드를 추가하세요
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 옵션 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">옵션</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="batchSize" className="text-xs">
|
||||
배치 크기
|
||||
</Label>
|
||||
<Input
|
||||
id="batchSize"
|
||||
type="number"
|
||||
value={batchSize}
|
||||
onChange={(e) => setBatchSize(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="예: 100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="updateOnConflict"
|
||||
checked={updateOnConflict}
|
||||
onCheckedChange={(checked) => setUpdateOnConflict(checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="updateOnConflict" className="cursor-pointer text-xs font-normal">
|
||||
충돌 시 업데이트 (ON CONFLICT DO UPDATE)
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 적용 버튼 */}
|
||||
<div className="sticky bottom-0 border-t bg-white pt-3">
|
||||
<Button onClick={handleSave} className="w-full" size="sm">
|
||||
적용
|
||||
</Button>
|
||||
<p className="mt-2 text-center text-xs text-gray-500">✅ 변경 사항이 즉시 노드에 반영됩니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 노드 팔레트 사이드바
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { NODE_CATEGORIES, getNodesByCategory } from "./nodePaletteConfig";
|
||||
import type { NodePaletteItem } from "@/types/node-editor";
|
||||
|
||||
export function NodePalette() {
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(["source", "transform", "action"]));
|
||||
|
||||
const toggleCategory = (categoryId: string) => {
|
||||
setExpandedCategories((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(categoryId)) {
|
||||
next.delete(categoryId);
|
||||
} else {
|
||||
next.add(categoryId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="border-b bg-gray-50 p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900">노드 라이브러리</h3>
|
||||
<p className="mt-1 text-xs text-gray-500">캔버스로 드래그하여 추가</p>
|
||||
</div>
|
||||
|
||||
{/* 노드 목록 */}
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{NODE_CATEGORIES.map((category) => {
|
||||
const isExpanded = expandedCategories.has(category.id);
|
||||
const nodes = getNodesByCategory(category.id);
|
||||
|
||||
return (
|
||||
<div key={category.id} className="mb-2">
|
||||
{/* 카테고리 헤더 */}
|
||||
<button
|
||||
onClick={() => toggleCategory(category.id)}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm font-medium text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
<span>{category.icon}</span>
|
||||
<span>{category.label}</span>
|
||||
<span className="ml-auto text-xs text-gray-400">{nodes.length}</span>
|
||||
</button>
|
||||
|
||||
{/* 노드 아이템들 */}
|
||||
{isExpanded && (
|
||||
<div className="mt-1 ml-2 space-y-1">
|
||||
{nodes.map((node) => (
|
||||
<NodePaletteItemComponent key={node.type} node={node} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 푸터 도움말 */}
|
||||
<div className="border-t bg-gray-50 p-3">
|
||||
<p className="text-xs text-gray-500">💡 노드를 드래그하여 캔버스에 배치하고 연결선으로 연결하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 노드 팔레트 아이템 컴포넌트
|
||||
*/
|
||||
function NodePaletteItemComponent({ node }: { node: NodePaletteItem }) {
|
||||
const onDragStart = (event: React.DragEvent) => {
|
||||
event.dataTransfer.setData("application/reactflow", node.type);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group cursor-move rounded-lg border border-gray-200 bg-white p-3 shadow-sm transition-all hover:border-gray-300 hover:shadow-md"
|
||||
draggable
|
||||
onDragStart={onDragStart}
|
||||
title={node.description}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{/* 아이콘 */}
|
||||
<div
|
||||
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded text-lg"
|
||||
style={{ backgroundColor: `${node.color}20` }}
|
||||
>
|
||||
{node.icon}
|
||||
</div>
|
||||
|
||||
{/* 라벨 및 설명 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-gray-900">{node.label}</div>
|
||||
<div className="mt-0.5 truncate text-xs text-gray-500">{node.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 색상 인디케이터 */}
|
||||
<div
|
||||
className="mt-2 h-1 w-full rounded-full opacity-0 transition-opacity group-hover:opacity-100"
|
||||
style={{ backgroundColor: node.color }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
/**
|
||||
* 노드 팔레트 설정
|
||||
*/
|
||||
|
||||
import type { NodePaletteItem } from "@/types/node-editor";
|
||||
|
||||
export const NODE_PALETTE: NodePaletteItem[] = [
|
||||
// ========================================================================
|
||||
// 데이터 소스
|
||||
// ========================================================================
|
||||
{
|
||||
type: "tableSource",
|
||||
label: "테이블",
|
||||
icon: "📊",
|
||||
description: "내부 데이터베이스 테이블에서 데이터를 읽어옵니다",
|
||||
category: "source",
|
||||
color: "#3B82F6", // 파란색
|
||||
},
|
||||
{
|
||||
type: "externalDBSource",
|
||||
label: "외부 DB",
|
||||
icon: "🔌",
|
||||
description: "외부 데이터베이스에서 데이터를 읽어옵니다",
|
||||
category: "source",
|
||||
color: "#F59E0B", // 주황색
|
||||
},
|
||||
{
|
||||
type: "restAPISource",
|
||||
label: "REST API",
|
||||
icon: "📁",
|
||||
description: "REST API를 호출하여 데이터를 가져옵니다",
|
||||
category: "source",
|
||||
color: "#10B981", // 초록색
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// 변환/조건
|
||||
// ========================================================================
|
||||
{
|
||||
type: "condition",
|
||||
label: "조건 분기",
|
||||
icon: "⚡",
|
||||
description: "조건에 따라 데이터 흐름을 분기합니다",
|
||||
category: "transform",
|
||||
color: "#EAB308", // 노란색
|
||||
},
|
||||
{
|
||||
type: "fieldMapping",
|
||||
label: "필드 매핑",
|
||||
icon: "🔀",
|
||||
description: "소스 필드를 타겟 필드로 매핑합니다",
|
||||
category: "transform",
|
||||
color: "#8B5CF6", // 보라색
|
||||
},
|
||||
{
|
||||
type: "dataTransform",
|
||||
label: "데이터 변환",
|
||||
icon: "🔧",
|
||||
description: "데이터를 변환하거나 가공합니다",
|
||||
category: "transform",
|
||||
color: "#06B6D4", // 청록색
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// 액션
|
||||
// ========================================================================
|
||||
{
|
||||
type: "insertAction",
|
||||
label: "INSERT",
|
||||
icon: "➕",
|
||||
description: "데이터를 삽입합니다",
|
||||
category: "action",
|
||||
color: "#22C55E", // 초록색
|
||||
},
|
||||
{
|
||||
type: "updateAction",
|
||||
label: "UPDATE",
|
||||
icon: "✏️",
|
||||
description: "데이터를 수정합니다",
|
||||
category: "action",
|
||||
color: "#3B82F6", // 파란색
|
||||
},
|
||||
{
|
||||
type: "deleteAction",
|
||||
label: "DELETE",
|
||||
icon: "❌",
|
||||
description: "데이터를 삭제합니다",
|
||||
category: "action",
|
||||
color: "#EF4444", // 빨간색
|
||||
},
|
||||
{
|
||||
type: "upsertAction",
|
||||
label: "UPSERT",
|
||||
icon: "🔄",
|
||||
description: "데이터를 삽입하거나 수정합니다",
|
||||
category: "action",
|
||||
color: "#8B5CF6", // 보라색
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// 유틸리티
|
||||
// ========================================================================
|
||||
{
|
||||
type: "comment",
|
||||
label: "주석",
|
||||
icon: "💬",
|
||||
description: "주석을 추가합니다",
|
||||
category: "utility",
|
||||
color: "#6B7280", // 회색
|
||||
},
|
||||
{
|
||||
type: "log",
|
||||
label: "로그",
|
||||
icon: "🔍",
|
||||
description: "로그를 출력합니다",
|
||||
category: "utility",
|
||||
color: "#6B7280", // 회색
|
||||
},
|
||||
];
|
||||
|
||||
export const NODE_CATEGORIES = [
|
||||
{
|
||||
id: "source",
|
||||
label: "데이터 소스",
|
||||
icon: "📂",
|
||||
},
|
||||
{
|
||||
id: "transform",
|
||||
label: "변환/조건",
|
||||
icon: "🔀",
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
label: "액션",
|
||||
icon: "⚡",
|
||||
},
|
||||
{
|
||||
id: "utility",
|
||||
label: "유틸리티",
|
||||
icon: "🛠️",
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 노드 타입별 팔레트 아이템 조회
|
||||
*/
|
||||
export function getNodePaletteItem(type: string): NodePaletteItem | undefined {
|
||||
return NODE_PALETTE.find((item) => item.type === type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리별 노드 목록 조회
|
||||
*/
|
||||
export function getNodesByCategory(category: string): NodePaletteItem[] {
|
||||
return NODE_PALETTE.filter((item) => item.category === category);
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import {
|
|||
import { dataflowJobQueue } from "@/lib/services/dataflowJobQueue";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { executeButtonWithFlow, handleFlowExecutionResult } from "@/lib/utils/nodeFlowButtonExecutor";
|
||||
|
||||
interface OptimizedButtonProps {
|
||||
component: ComponentData;
|
||||
|
|
@ -98,7 +99,44 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
|
|||
});
|
||||
|
||||
if (config?.enableDataflowControl && config?.dataflowConfig) {
|
||||
// 🔥 확장된 제어 검증 먼저 실행
|
||||
// 🆕 노드 플로우 방식 실행
|
||||
if (config.dataflowConfig.controlMode === "flow" && config.dataflowConfig.flowConfig) {
|
||||
console.log("🔄 노드 플로우 방식 실행:", config.dataflowConfig.flowConfig);
|
||||
|
||||
const flowResult = await executeButtonWithFlow(
|
||||
config.dataflowConfig.flowConfig,
|
||||
{
|
||||
buttonId: component.id,
|
||||
screenId: component.screenId,
|
||||
companyCode,
|
||||
userId: contextData.userId,
|
||||
formData,
|
||||
selectedRows: selectedRows || [],
|
||||
selectedRowsData: selectedRowsData || [],
|
||||
controlDataSource: config.dataflowConfig.controlDataSource,
|
||||
},
|
||||
// 원래 액션 (timing이 before나 after일 때 실행)
|
||||
async () => {
|
||||
if (!isControlOnlyAction) {
|
||||
await executeOriginalAction(config?.actionType || "save", contextData);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
handleFlowExecutionResult(flowResult, {
|
||||
buttonId: component.id,
|
||||
formData,
|
||||
onRefresh: onDataflowComplete,
|
||||
});
|
||||
|
||||
if (onActionComplete) {
|
||||
onActionComplete(flowResult);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 🔥 기존 관계 방식 실행
|
||||
const validationResult = await OptimizedButtonDataflowService.executeExtendedValidation(
|
||||
config.dataflowConfig,
|
||||
extendedContext as ExtendedControlContext,
|
||||
|
|
|
|||
|
|
@ -8,36 +8,20 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Settings,
|
||||
GitBranch,
|
||||
Clock,
|
||||
Zap,
|
||||
Info
|
||||
} from "lucide-react";
|
||||
import { ComponentData, ButtonDataflowConfig } from "@/types/screen";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { Settings, Clock, Zap, Info, Workflow } from "lucide-react";
|
||||
import { ComponentData } from "@/types/screen";
|
||||
import { getNodeFlows, NodeFlow } from "@/lib/api/nodeFlows";
|
||||
|
||||
interface ImprovedButtonControlConfigPanelProps {
|
||||
component: ComponentData;
|
||||
onUpdateProperty: (path: string, value: any) => void;
|
||||
}
|
||||
|
||||
interface RelationshipOption {
|
||||
id: string;
|
||||
name: string;
|
||||
sourceTable: string;
|
||||
targetTable: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 🔥 단순화된 버튼 제어 설정 패널
|
||||
*
|
||||
* 관계 실행만 지원:
|
||||
* - 관계 선택 및 실행 타이밍 설정
|
||||
* - 관계 내부에 데이터 저장/외부호출 로직 포함
|
||||
* 노드 플로우 실행만 지원:
|
||||
* - 플로우 선택 및 실행 타이밍 설정
|
||||
*/
|
||||
export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlConfigPanelProps> = ({
|
||||
component,
|
||||
|
|
@ -47,57 +31,45 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
|
|||
const dataflowConfig = config.dataflowConfig || {};
|
||||
|
||||
// 🔥 State 관리
|
||||
const [relationships, setRelationships] = useState<RelationshipOption[]>([]);
|
||||
const [flows, setFlows] = useState<NodeFlow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 🔥 관계 목록 로딩
|
||||
// 🔥 플로우 목록 로딩
|
||||
useEffect(() => {
|
||||
if (config.enableDataflowControl) {
|
||||
loadRelationships();
|
||||
loadFlows();
|
||||
}
|
||||
}, [config.enableDataflowControl]);
|
||||
|
||||
/**
|
||||
* 🔥 전체 관계 목록 로드 (관계도별 구분 없이)
|
||||
* 🔥 플로우 목록 로드
|
||||
*/
|
||||
const loadRelationships = async () => {
|
||||
const loadFlows = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
console.log("🔍 전체 관계 목록 로딩...");
|
||||
console.log("🔍 플로우 목록 로딩...");
|
||||
|
||||
const response = await apiClient.get("/test-button-dataflow/relationships/all");
|
||||
|
||||
if (response.data.success && Array.isArray(response.data.data)) {
|
||||
const relationshipList = response.data.data.map((rel: any) => ({
|
||||
id: rel.id,
|
||||
name: rel.name || `${rel.sourceTable} → ${rel.targetTable}`,
|
||||
sourceTable: rel.sourceTable,
|
||||
targetTable: rel.targetTable,
|
||||
category: rel.category || "데이터 흐름",
|
||||
}));
|
||||
|
||||
setRelationships(relationshipList);
|
||||
console.log(`✅ 관계 ${relationshipList.length}개 로딩 완료`);
|
||||
}
|
||||
const flowList = await getNodeFlows();
|
||||
setFlows(flowList);
|
||||
console.log(`✅ 플로우 ${flowList.length}개 로딩 완료`);
|
||||
} catch (error) {
|
||||
console.error("❌ 관계 목록 로딩 실패:", error);
|
||||
setRelationships([]);
|
||||
console.error("❌ 플로우 목록 로딩 실패:", error);
|
||||
setFlows([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 🔥 관계 선택 핸들러
|
||||
* 🔥 플로우 선택 핸들러
|
||||
*/
|
||||
const handleRelationshipSelect = (relationshipId: string) => {
|
||||
const selectedRelationship = relationships.find(r => r.id === relationshipId);
|
||||
if (selectedRelationship) {
|
||||
onUpdateProperty("webTypeConfig.dataflowConfig.relationshipConfig", {
|
||||
relationshipId: selectedRelationship.id,
|
||||
relationshipName: selectedRelationship.name,
|
||||
executionTiming: "after", // 기본값
|
||||
const handleFlowSelect = (flowId: string) => {
|
||||
const selectedFlow = flows.find((f) => f.flowId.toString() === flowId);
|
||||
if (selectedFlow) {
|
||||
onUpdateProperty("webTypeConfig.dataflowConfig.flowConfig", {
|
||||
flowId: selectedFlow.flowId,
|
||||
flowName: selectedFlow.flowName,
|
||||
executionTiming: "before", // 기본값
|
||||
contextData: {},
|
||||
});
|
||||
}
|
||||
|
|
@ -110,7 +82,7 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
|
|||
// 기존 설정 초기화
|
||||
onUpdateProperty("webTypeConfig.dataflowConfig", {
|
||||
controlMode: controlType,
|
||||
relationshipConfig: controlType === "relationship" ? undefined : null,
|
||||
flowConfig: controlType === "flow" ? undefined : null,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -138,46 +110,45 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
|
|||
<CardTitle>버튼 제어 설정</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs
|
||||
value={dataflowConfig.controlMode || "none"}
|
||||
onValueChange={handleControlTypeChange}
|
||||
>
|
||||
<Tabs value={dataflowConfig.controlMode || "none"} onValueChange={handleControlTypeChange}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="none">제어 없음</TabsTrigger>
|
||||
<TabsTrigger value="relationship">관계 실행</TabsTrigger>
|
||||
<TabsTrigger value="flow">노드 플로우</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="none" className="mt-4">
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Zap className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<div className="py-8 text-center text-gray-500">
|
||||
<Zap className="mx-auto mb-2 h-8 w-8 opacity-50" />
|
||||
<p>추가 제어 없이 기본 버튼 액션만 실행됩니다.</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="relationship" className="mt-4">
|
||||
<RelationshipSelector
|
||||
relationships={relationships}
|
||||
selectedRelationshipId={dataflowConfig.relationshipConfig?.relationshipId}
|
||||
onSelect={handleRelationshipSelect}
|
||||
<TabsContent value="flow" className="mt-4">
|
||||
<FlowSelector
|
||||
flows={flows}
|
||||
selectedFlowId={dataflowConfig.flowConfig?.flowId}
|
||||
onSelect={handleFlowSelect}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
{dataflowConfig.relationshipConfig && (
|
||||
{dataflowConfig.flowConfig && (
|
||||
<div className="mt-4 space-y-4">
|
||||
<Separator />
|
||||
<ExecutionTimingSelector
|
||||
value={dataflowConfig.relationshipConfig.executionTiming}
|
||||
value={dataflowConfig.flowConfig.executionTiming}
|
||||
onChange={(timing) =>
|
||||
onUpdateProperty("webTypeConfig.dataflowConfig.relationshipConfig.executionTiming", timing)
|
||||
onUpdateProperty("webTypeConfig.dataflowConfig.flowConfig.executionTiming", timing)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="rounded bg-blue-50 p-3">
|
||||
<div className="rounded bg-green-50 p-3">
|
||||
<div className="flex items-start space-x-2">
|
||||
<Info className="h-4 w-4 text-blue-600 mt-0.5" />
|
||||
<div className="text-xs text-blue-800">
|
||||
<p className="font-medium">관계 실행 정보:</p>
|
||||
<p className="mt-1">선택한 관계에 설정된 데이터 저장, 외부호출 등의 모든 액션이 자동으로 실행됩니다.</p>
|
||||
<Info className="mt-0.5 h-4 w-4 text-green-600" />
|
||||
<div className="text-xs text-green-800">
|
||||
<p className="font-medium">노드 플로우 실행 정보:</p>
|
||||
<p className="mt-1">선택한 플로우의 모든 노드가 순차적/병렬로 실행됩니다.</p>
|
||||
<p className="mt-1">• 독립 트랜잭션: 각 액션은 독립적으로 커밋/롤백</p>
|
||||
<p>• 연쇄 중단: 부모 노드 실패 시 자식 노드 스킵</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -193,38 +164,41 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
|
|||
};
|
||||
|
||||
/**
|
||||
* 🔥 관계 선택 컴포넌트
|
||||
* 🔥 플로우 선택 컴포넌트
|
||||
*/
|
||||
const RelationshipSelector: React.FC<{
|
||||
relationships: RelationshipOption[];
|
||||
selectedRelationshipId?: string;
|
||||
onSelect: (relationshipId: string) => void;
|
||||
const FlowSelector: React.FC<{
|
||||
flows: NodeFlow[];
|
||||
selectedFlowId?: number;
|
||||
onSelect: (flowId: string) => void;
|
||||
loading: boolean;
|
||||
}> = ({ relationships, selectedRelationshipId, onSelect, loading }) => {
|
||||
}> = ({ flows, selectedFlowId, onSelect, loading }) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<GitBranch className="h-4 w-4 text-blue-600" />
|
||||
<Label>실행할 관계 선택</Label>
|
||||
<Workflow className="h-4 w-4 text-green-600" />
|
||||
<Label>실행할 노드 플로우 선택</Label>
|
||||
</div>
|
||||
|
||||
<Select value={selectedRelationshipId || ""} onValueChange={onSelect}>
|
||||
<Select value={selectedFlowId?.toString() || ""} onValueChange={onSelect}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="관계를 선택하세요" />
|
||||
<SelectValue placeholder="플로우를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{loading ? (
|
||||
<div className="p-4 text-center text-sm text-gray-500">관계 목록을 불러오는 중...</div>
|
||||
) : relationships.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-gray-500">사용 가능한 관계가 없습니다</div>
|
||||
<div className="p-4 text-center text-sm text-gray-500">플로우 목록을 불러오는 중...</div>
|
||||
) : flows.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-gray-500">
|
||||
<p>사용 가능한 플로우가 없습니다</p>
|
||||
<p className="mt-2 text-xs">노드 편집기에서 플로우를 먼저 생성하세요</p>
|
||||
</div>
|
||||
) : (
|
||||
relationships.map((rel) => (
|
||||
<SelectItem key={rel.id} value={rel.id}>
|
||||
flows.map((flow) => (
|
||||
<SelectItem key={flow.flowId} value={flow.flowId.toString()}>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{rel.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{rel.sourceTable} → {rel.targetTable}
|
||||
</span>
|
||||
<span className="font-medium">{flow.flowName}</span>
|
||||
{flow.flowDescription && (
|
||||
<span className="text-muted-foreground text-xs">{flow.flowDescription}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
|
|
@ -235,7 +209,6 @@ const RelationshipSelector: React.FC<{
|
|||
);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 🔥 실행 타이밍 선택 컴포넌트
|
||||
*/
|
||||
|
|
@ -258,19 +231,19 @@ const ExecutionTimingSelector: React.FC<{
|
|||
<SelectItem value="before">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">Before (사전 실행)</span>
|
||||
<span className="text-xs text-muted-foreground">버튼 액션 실행 전에 제어를 실행합니다</span>
|
||||
<span className="text-muted-foreground text-xs">버튼 액션 실행 전에 플로우를 실행합니다</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="after">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">After (사후 실행)</span>
|
||||
<span className="text-xs text-muted-foreground">버튼 액션 실행 후에 제어를 실행합니다</span>
|
||||
<span className="text-muted-foreground text-xs">버튼 액션 실행 후에 플로우를 실행합니다</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="replace">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">Replace (대체 실행)</span>
|
||||
<span className="text-xs text-muted-foreground">버튼 액션 대신 제어만 실행합니다</span>
|
||||
<span className="text-muted-foreground text-xs">버튼 액션 대신 플로우만 실행합니다</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
|
|
@ -278,4 +251,3 @@ const ExecutionTimingSelector: React.FC<{
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
/**
|
||||
* 노드 플로우 API
|
||||
*/
|
||||
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export interface NodeFlow {
|
||||
flowId: number;
|
||||
flowName: string;
|
||||
flowDescription: string;
|
||||
flowData: string | any; // JSONB는 문자열 또는 객체로 반환될 수 있음
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 플로우 목록 조회
|
||||
*/
|
||||
export async function getNodeFlows(): Promise<NodeFlow[]> {
|
||||
const response = await apiClient.get<ApiResponse<NodeFlow[]>>("/dataflow/node-flows");
|
||||
if (response.data.success && response.data.data) {
|
||||
return response.data.data;
|
||||
}
|
||||
throw new Error(response.data.message || "플로우 목록을 불러올 수 없습니다.");
|
||||
}
|
||||
|
||||
/**
|
||||
* 플로우 상세 조회
|
||||
*/
|
||||
export async function getNodeFlow(flowId: number): Promise<NodeFlow> {
|
||||
const response = await apiClient.get<ApiResponse<NodeFlow>>(`/dataflow/node-flows/${flowId}`);
|
||||
if (response.data.success && response.data.data) {
|
||||
return response.data.data;
|
||||
}
|
||||
throw new Error(response.data.message || "플로우를 불러올 수 없습니다.");
|
||||
}
|
||||
|
||||
/**
|
||||
* 플로우 저장 (신규)
|
||||
*/
|
||||
export async function createNodeFlow(data: {
|
||||
flowName: string;
|
||||
flowDescription: string;
|
||||
flowData: string;
|
||||
}): Promise<{ flowId: number }> {
|
||||
const response = await apiClient.post<ApiResponse<{ flowId: number }>>("/dataflow/node-flows", data);
|
||||
if (response.data.success && response.data.data) {
|
||||
return response.data.data;
|
||||
}
|
||||
throw new Error(response.data.message || "플로우를 저장할 수 없습니다.");
|
||||
}
|
||||
|
||||
/**
|
||||
* 플로우 수정
|
||||
*/
|
||||
export async function updateNodeFlow(data: {
|
||||
flowId: number;
|
||||
flowName: string;
|
||||
flowDescription: string;
|
||||
flowData: string;
|
||||
}): Promise<{ flowId: number }> {
|
||||
const response = await apiClient.put<ApiResponse<{ flowId: number }>>("/dataflow/node-flows", data);
|
||||
if (response.data.success && response.data.data) {
|
||||
return response.data.data;
|
||||
}
|
||||
throw new Error(response.data.message || "플로우를 수정할 수 없습니다.");
|
||||
}
|
||||
|
||||
/**
|
||||
* 플로우 삭제
|
||||
*/
|
||||
export async function deleteNodeFlow(flowId: number): Promise<void> {
|
||||
const response = await apiClient.delete<ApiResponse<void>>(`/dataflow/node-flows/${flowId}`);
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "플로우를 삭제할 수 없습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 플로우 실행
|
||||
*/
|
||||
export async function executeNodeFlow(flowId: number, contextData: Record<string, any>): Promise<ExecutionResult> {
|
||||
const response = await apiClient.post<ApiResponse<ExecutionResult>>(
|
||||
`/dataflow/node-flows/${flowId}/execute`,
|
||||
contextData,
|
||||
);
|
||||
if (response.data.success && response.data.data) {
|
||||
return response.data.data;
|
||||
}
|
||||
throw new Error(response.data.message || "플로우를 실행할 수 없습니다.");
|
||||
}
|
||||
|
||||
/**
|
||||
* 플로우 실행 결과 인터페이스
|
||||
*/
|
||||
export interface ExecutionResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
executionTime: number;
|
||||
nodes: NodeExecutionSummary[];
|
||||
summary: {
|
||||
total: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NodeExecutionSummary {
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
nodeType: string;
|
||||
status: "success" | "failed" | "skipped" | "pending";
|
||||
duration?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,741 @@
|
|||
/**
|
||||
* 노드 에디터 상태 관리 스토어
|
||||
*/
|
||||
|
||||
import { create } from "zustand";
|
||||
import { Connection, Edge, EdgeChange, Node, NodeChange, addEdge, applyNodeChanges, applyEdgeChanges } from "reactflow";
|
||||
import type { FlowNode, FlowEdge, NodeType, ValidationResult } from "@/types/node-editor";
|
||||
import { createNodeFlow, updateNodeFlow } from "../api/nodeFlows";
|
||||
|
||||
interface FlowEditorState {
|
||||
// 노드 및 엣지
|
||||
nodes: FlowNode[];
|
||||
edges: FlowEdge[];
|
||||
|
||||
// 선택 상태
|
||||
selectedNodes: string[];
|
||||
selectedEdges: string[];
|
||||
|
||||
// 플로우 메타데이터
|
||||
flowId: number | null;
|
||||
flowName: string;
|
||||
flowDescription: string;
|
||||
|
||||
// UI 상태
|
||||
isExecuting: boolean;
|
||||
isSaving: boolean;
|
||||
showValidationPanel: boolean;
|
||||
showPropertiesPanel: boolean;
|
||||
|
||||
// 검증 결과
|
||||
validationResult: ValidationResult | null;
|
||||
|
||||
// ========================================================================
|
||||
// 노드 관리
|
||||
// ========================================================================
|
||||
|
||||
setNodes: (nodes: FlowNode[]) => void;
|
||||
onNodesChange: (changes: NodeChange[]) => void;
|
||||
addNode: (node: FlowNode) => void;
|
||||
updateNode: (id: string, data: Partial<FlowNode["data"]>) => void;
|
||||
removeNode: (id: string) => void;
|
||||
removeNodes: (ids: string[]) => void;
|
||||
|
||||
// ========================================================================
|
||||
// 엣지 관리
|
||||
// ========================================================================
|
||||
|
||||
setEdges: (edges: FlowEdge[]) => void;
|
||||
onEdgesChange: (changes: EdgeChange[]) => void;
|
||||
onConnect: (connection: Connection) => void;
|
||||
removeEdge: (id: string) => void;
|
||||
removeEdges: (ids: string[]) => void;
|
||||
|
||||
// ========================================================================
|
||||
// 선택 관리
|
||||
// ========================================================================
|
||||
|
||||
selectNode: (id: string, multi?: boolean) => void;
|
||||
selectNodes: (ids: string[]) => void;
|
||||
selectEdge: (id: string, multi?: boolean) => void;
|
||||
clearSelection: () => void;
|
||||
|
||||
// ========================================================================
|
||||
// 플로우 관리
|
||||
// ========================================================================
|
||||
|
||||
loadFlow: (id: number, name: string, description: string, nodes: FlowNode[], edges: FlowEdge[]) => void;
|
||||
clearFlow: () => void;
|
||||
setFlowName: (name: string) => void;
|
||||
setFlowDescription: (description: string) => void;
|
||||
saveFlow: () => Promise<{ success: boolean; flowId?: number; message?: string }>;
|
||||
exportFlow: () => string;
|
||||
|
||||
// ========================================================================
|
||||
// 검증
|
||||
// ========================================================================
|
||||
|
||||
validateFlow: () => ValidationResult;
|
||||
setValidationResult: (result: ValidationResult | null) => void;
|
||||
|
||||
// ========================================================================
|
||||
// UI 상태
|
||||
// ========================================================================
|
||||
|
||||
setIsExecuting: (value: boolean) => void;
|
||||
setIsSaving: (value: boolean) => void;
|
||||
setShowValidationPanel: (value: boolean) => void;
|
||||
setShowPropertiesPanel: (value: boolean) => void;
|
||||
|
||||
// ========================================================================
|
||||
// 유틸리티
|
||||
// ========================================================================
|
||||
|
||||
getNodeById: (id: string) => FlowNode | undefined;
|
||||
getEdgeById: (id: string) => FlowEdge | undefined;
|
||||
getConnectedNodes: (nodeId: string) => { incoming: FlowNode[]; outgoing: FlowNode[] };
|
||||
}
|
||||
|
||||
export const useFlowEditorStore = create<FlowEditorState>((set, get) => ({
|
||||
// 초기 상태
|
||||
nodes: [],
|
||||
edges: [],
|
||||
selectedNodes: [],
|
||||
selectedEdges: [],
|
||||
flowId: null,
|
||||
flowName: "새 제어 플로우",
|
||||
flowDescription: "",
|
||||
isExecuting: false,
|
||||
isSaving: false,
|
||||
showValidationPanel: false,
|
||||
showPropertiesPanel: true,
|
||||
validationResult: null,
|
||||
|
||||
// ========================================================================
|
||||
// 노드 관리
|
||||
// ========================================================================
|
||||
|
||||
setNodes: (nodes) => set({ nodes }),
|
||||
|
||||
onNodesChange: (changes) => {
|
||||
set({
|
||||
nodes: applyNodeChanges(changes, get().nodes) as FlowNode[],
|
||||
});
|
||||
},
|
||||
|
||||
addNode: (node) => {
|
||||
set((state) => ({
|
||||
nodes: [...state.nodes, node],
|
||||
}));
|
||||
},
|
||||
|
||||
updateNode: (id, data) => {
|
||||
set((state) => ({
|
||||
nodes: state.nodes.map((node) =>
|
||||
node.id === id
|
||||
? {
|
||||
...node,
|
||||
data: { ...node.data, ...data },
|
||||
}
|
||||
: node,
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
removeNode: (id) => {
|
||||
set((state) => ({
|
||||
nodes: state.nodes.filter((node) => node.id !== id),
|
||||
edges: state.edges.filter((edge) => edge.source !== id && edge.target !== id),
|
||||
}));
|
||||
},
|
||||
|
||||
removeNodes: (ids) => {
|
||||
set((state) => ({
|
||||
nodes: state.nodes.filter((node) => !ids.includes(node.id)),
|
||||
edges: state.edges.filter((edge) => !ids.includes(edge.source) && !ids.includes(edge.target)),
|
||||
}));
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// 엣지 관리
|
||||
// ========================================================================
|
||||
|
||||
setEdges: (edges) => set({ edges }),
|
||||
|
||||
onEdgesChange: (changes) => {
|
||||
set({
|
||||
edges: applyEdgeChanges(changes, get().edges) as FlowEdge[],
|
||||
});
|
||||
},
|
||||
|
||||
onConnect: (connection) => {
|
||||
// 연결 검증
|
||||
const validation = validateConnection(connection, get().nodes);
|
||||
if (!validation.valid) {
|
||||
console.warn("연결 검증 실패:", validation.error);
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
edges: addEdge(
|
||||
{
|
||||
...connection,
|
||||
type: "smoothstep",
|
||||
animated: false,
|
||||
data: {
|
||||
validation: { valid: true },
|
||||
},
|
||||
},
|
||||
state.edges,
|
||||
) as FlowEdge[],
|
||||
}));
|
||||
},
|
||||
|
||||
removeEdge: (id) => {
|
||||
set((state) => ({
|
||||
edges: state.edges.filter((edge) => edge.id !== id),
|
||||
}));
|
||||
},
|
||||
|
||||
removeEdges: (ids) => {
|
||||
set((state) => ({
|
||||
edges: state.edges.filter((edge) => !ids.includes(edge.id)),
|
||||
}));
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// 선택 관리
|
||||
// ========================================================================
|
||||
|
||||
selectNode: (id, multi = false) => {
|
||||
set((state) => ({
|
||||
selectedNodes: multi ? [...state.selectedNodes, id] : [id],
|
||||
}));
|
||||
},
|
||||
|
||||
selectNodes: (ids) => {
|
||||
set({
|
||||
selectedNodes: ids,
|
||||
showPropertiesPanel: ids.length > 0, // 노드가 선택되면 속성창 자동으로 열기
|
||||
});
|
||||
},
|
||||
|
||||
selectEdge: (id, multi = false) => {
|
||||
set((state) => ({
|
||||
selectedEdges: multi ? [...state.selectedEdges, id] : [id],
|
||||
}));
|
||||
},
|
||||
|
||||
clearSelection: () => {
|
||||
set({ selectedNodes: [], selectedEdges: [] });
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// 플로우 관리
|
||||
// ========================================================================
|
||||
|
||||
loadFlow: (id, name, description, nodes, edges) => {
|
||||
set({
|
||||
flowId: id,
|
||||
flowName: name,
|
||||
flowDescription: description,
|
||||
nodes,
|
||||
edges,
|
||||
selectedNodes: [],
|
||||
selectedEdges: [],
|
||||
});
|
||||
},
|
||||
|
||||
clearFlow: () => {
|
||||
set({
|
||||
flowId: null,
|
||||
flowName: "새 제어 플로우",
|
||||
flowDescription: "",
|
||||
nodes: [],
|
||||
edges: [],
|
||||
selectedNodes: [],
|
||||
selectedEdges: [],
|
||||
validationResult: null,
|
||||
});
|
||||
},
|
||||
|
||||
setFlowName: (name) => set({ flowName: name }),
|
||||
setFlowDescription: (description) => set({ flowDescription: description }),
|
||||
|
||||
saveFlow: async () => {
|
||||
const { flowId, flowName, flowDescription, nodes, edges } = get();
|
||||
|
||||
if (!flowName || flowName.trim() === "") {
|
||||
return { success: false, message: "플로우 이름을 입력해주세요." };
|
||||
}
|
||||
|
||||
// 검증
|
||||
const validation = get().validateFlow();
|
||||
if (!validation.valid) {
|
||||
return { success: false, message: `검증 실패: ${validation.errors[0]?.message || "오류가 있습니다."}` };
|
||||
}
|
||||
|
||||
set({ isSaving: true });
|
||||
|
||||
try {
|
||||
// 플로우 데이터 직렬화
|
||||
const flowData = {
|
||||
nodes: nodes.map((node) => ({
|
||||
id: node.id,
|
||||
type: node.type,
|
||||
position: node.position,
|
||||
data: node.data,
|
||||
})),
|
||||
edges: edges.map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
sourceHandle: edge.sourceHandle,
|
||||
targetHandle: edge.targetHandle,
|
||||
})),
|
||||
};
|
||||
|
||||
const result = flowId
|
||||
? await updateNodeFlow({
|
||||
flowId,
|
||||
flowName,
|
||||
flowDescription,
|
||||
flowData: JSON.stringify(flowData),
|
||||
})
|
||||
: await createNodeFlow({
|
||||
flowName,
|
||||
flowDescription,
|
||||
flowData: JSON.stringify(flowData),
|
||||
});
|
||||
|
||||
set({ flowId: result.flowId });
|
||||
return { success: true, flowId: result.flowId, message: "저장 완료!" };
|
||||
} catch (error) {
|
||||
console.error("플로우 저장 오류:", error);
|
||||
return { success: false, message: error instanceof Error ? error.message : "저장 중 오류 발생" };
|
||||
} finally {
|
||||
set({ isSaving: false });
|
||||
}
|
||||
},
|
||||
|
||||
exportFlow: () => {
|
||||
const { flowName, flowDescription, nodes, edges } = get();
|
||||
const flowData = {
|
||||
flowName,
|
||||
flowDescription,
|
||||
nodes,
|
||||
edges,
|
||||
version: "1.0",
|
||||
exportedAt: new Date().toISOString(),
|
||||
};
|
||||
return JSON.stringify(flowData, null, 2);
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// 검증
|
||||
// ========================================================================
|
||||
|
||||
validateFlow: () => {
|
||||
const { nodes, edges } = get();
|
||||
const result = performFlowValidation(nodes, edges);
|
||||
set({ validationResult: result });
|
||||
return result;
|
||||
},
|
||||
|
||||
setValidationResult: (result) => set({ validationResult: result }),
|
||||
|
||||
// ========================================================================
|
||||
// UI 상태
|
||||
// ========================================================================
|
||||
|
||||
setIsExecuting: (value) => set({ isExecuting: value }),
|
||||
setIsSaving: (value) => set({ isSaving: value }),
|
||||
setShowValidationPanel: (value) => set({ showValidationPanel: value }),
|
||||
setShowPropertiesPanel: (value) => set({ showPropertiesPanel: value }),
|
||||
|
||||
// ========================================================================
|
||||
// 유틸리티
|
||||
// ========================================================================
|
||||
|
||||
getNodeById: (id) => {
|
||||
return get().nodes.find((node) => node.id === id);
|
||||
},
|
||||
|
||||
getEdgeById: (id) => {
|
||||
return get().edges.find((edge) => edge.id === id);
|
||||
},
|
||||
|
||||
getConnectedNodes: (nodeId) => {
|
||||
const { nodes, edges } = get();
|
||||
|
||||
const incoming = edges
|
||||
.filter((edge) => edge.target === nodeId)
|
||||
.map((edge) => nodes.find((node) => node.id === edge.source))
|
||||
.filter((node): node is FlowNode => node !== undefined);
|
||||
|
||||
const outgoing = edges
|
||||
.filter((edge) => edge.source === nodeId)
|
||||
.map((edge) => nodes.find((node) => node.id === edge.target))
|
||||
.filter((node): node is FlowNode => node !== undefined);
|
||||
|
||||
return { incoming, outgoing };
|
||||
},
|
||||
}));
|
||||
|
||||
// ============================================================================
|
||||
// 헬퍼 함수들
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 연결 검증
|
||||
*/
|
||||
function validateConnection(connection: Connection, nodes: FlowNode[]): { valid: boolean; error?: string } {
|
||||
const sourceNode = nodes.find((n) => n.id === connection.source);
|
||||
const targetNode = nodes.find((n) => n.id === connection.target);
|
||||
|
||||
if (!sourceNode || !targetNode) {
|
||||
return { valid: false, error: "노드를 찾을 수 없습니다" };
|
||||
}
|
||||
|
||||
// 소스 노드가 출력을 가져야 함
|
||||
if (isSourceOnlyNode(sourceNode.type)) {
|
||||
// OK
|
||||
} else if (isActionNode(targetNode.type)) {
|
||||
// 액션 노드는 입력만 받을 수 있음
|
||||
} else {
|
||||
// 기타 경우는 허용
|
||||
}
|
||||
|
||||
// 자기 자신에게 연결 방지
|
||||
if (connection.source === connection.target) {
|
||||
return { valid: false, error: "자기 자신에게 연결할 수 없습니다" };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 플로우 검증
|
||||
*/
|
||||
function performFlowValidation(nodes: FlowNode[], edges: FlowEdge[]): ValidationResult {
|
||||
const errors: ValidationResult["errors"] = [];
|
||||
|
||||
// 1. 노드가 하나도 없으면 경고
|
||||
if (nodes.length === 0) {
|
||||
errors.push({
|
||||
message: "노드가 없습니다. 최소 하나의 노드를 추가하세요.",
|
||||
severity: "warning",
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 소스 노드 확인
|
||||
const sourceNodes = nodes.filter((n) => isSourceNode(n.type));
|
||||
if (sourceNodes.length === 0 && nodes.length > 0) {
|
||||
errors.push({
|
||||
message: "데이터 소스 노드가 필요합니다.",
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 액션 노드 확인
|
||||
const actionNodes = nodes.filter((n) => isActionNode(n.type));
|
||||
if (actionNodes.length === 0 && nodes.length > 0) {
|
||||
errors.push({
|
||||
message: "최소 하나의 액션 노드가 필요합니다.",
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
|
||||
// 4. 고아 노드 확인 (연결되지 않은 노드) - Comment와 Log는 제외
|
||||
nodes.forEach((node) => {
|
||||
// Comment와 Log는 독립적으로 존재 가능
|
||||
if (node.type === "comment" || node.type === "log") {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasIncoming = edges.some((e) => e.target === node.id);
|
||||
const hasOutgoing = edges.some((e) => e.source === node.id);
|
||||
|
||||
if (!hasIncoming && !hasOutgoing && !isSourceNode(node.type)) {
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: `노드 "${node.data.displayName || node.id}"가 연결되어 있지 않습니다.`,
|
||||
severity: "warning",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 5. 액션 노드가 입력을 받는지 확인
|
||||
actionNodes.forEach((node) => {
|
||||
const hasInput = edges.some((e) => e.target === node.id);
|
||||
if (!hasInput) {
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: `액션 노드 "${node.data.displayName || node.id}"에 입력 데이터가 없습니다.`,
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 6. 순환 참조 검증
|
||||
const cycles = detectCycles(nodes, edges);
|
||||
cycles.forEach((cycle) => {
|
||||
errors.push({
|
||||
message: `순환 참조가 감지되었습니다: ${cycle.join(" → ")}`,
|
||||
severity: "error",
|
||||
});
|
||||
});
|
||||
|
||||
// 7. 노드별 필수 속성 검증
|
||||
nodes.forEach((node) => {
|
||||
const nodeErrors = validateNodeProperties(node);
|
||||
errors.push(...nodeErrors);
|
||||
});
|
||||
|
||||
return {
|
||||
valid: errors.filter((e) => e.severity === "error").length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 소스 노드 여부 확인
|
||||
*/
|
||||
function isSourceNode(type: NodeType): boolean {
|
||||
return type === "tableSource" || type === "externalDBSource" || type === "restAPISource";
|
||||
}
|
||||
|
||||
/**
|
||||
* 소스 전용 노드 여부 확인
|
||||
*/
|
||||
function isSourceOnlyNode(type: NodeType): boolean {
|
||||
return isSourceNode(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 액션 노드 여부 확인
|
||||
*/
|
||||
function isActionNode(type: NodeType): boolean {
|
||||
return type === "insertAction" || type === "updateAction" || type === "deleteAction" || type === "upsertAction";
|
||||
}
|
||||
|
||||
/**
|
||||
* 순환 참조 검증 (DFS 사용)
|
||||
*/
|
||||
function detectCycles(nodes: FlowNode[], edges: FlowEdge[]): string[][] {
|
||||
const cycles: string[][] = [];
|
||||
const visited = new Set<string>();
|
||||
const recursionStack = new Set<string>();
|
||||
|
||||
// 인접 리스트 생성
|
||||
const adjacencyList = new Map<string, string[]>();
|
||||
nodes.forEach((node) => adjacencyList.set(node.id, []));
|
||||
edges.forEach((edge) => {
|
||||
const targets = adjacencyList.get(edge.source) || [];
|
||||
targets.push(edge.target);
|
||||
adjacencyList.set(edge.source, targets);
|
||||
});
|
||||
|
||||
function dfs(nodeId: string, path: string[]): boolean {
|
||||
visited.add(nodeId);
|
||||
recursionStack.add(nodeId);
|
||||
path.push(nodeId);
|
||||
|
||||
const neighbors = adjacencyList.get(nodeId) || [];
|
||||
for (const neighbor of neighbors) {
|
||||
if (!visited.has(neighbor)) {
|
||||
if (dfs(neighbor, [...path])) {
|
||||
return true;
|
||||
}
|
||||
} else if (recursionStack.has(neighbor)) {
|
||||
// 순환 발견
|
||||
const cycleStart = path.indexOf(neighbor);
|
||||
const cycle = path.slice(cycleStart).concat(neighbor);
|
||||
const nodeNames = cycle.map((id) => {
|
||||
const node = nodes.find((n) => n.id === id);
|
||||
return node?.data.displayName || id;
|
||||
});
|
||||
cycles.push(nodeNames);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
recursionStack.delete(nodeId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 모든 노드에서 DFS 시작
|
||||
nodes.forEach((node) => {
|
||||
if (!visited.has(node.id)) {
|
||||
dfs(node.id, []);
|
||||
}
|
||||
});
|
||||
|
||||
return cycles;
|
||||
}
|
||||
|
||||
/**
|
||||
* 노드별 필수 속성 검증
|
||||
*/
|
||||
function validateNodeProperties(node: FlowNode): ValidationResult["errors"] {
|
||||
const errors: ValidationResult["errors"] = [];
|
||||
|
||||
switch (node.type) {
|
||||
case "tableSource":
|
||||
if (!node.data.tableName || node.data.tableName.trim() === "") {
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: `테이블 소스 노드 "${node.data.displayName || node.id}": 테이블명이 필요합니다.`,
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "externalDBSource":
|
||||
if (!node.data.connectionName || node.data.connectionName.trim() === "") {
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: `외부 DB 소스 노드 "${node.data.displayName || node.id}": 연결 이름이 필요합니다.`,
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
if (!node.data.tableName || node.data.tableName.trim() === "") {
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: `외부 DB 소스 노드 "${node.data.displayName || node.id}": 테이블명이 필요합니다.`,
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "restAPISource":
|
||||
if (!node.data.url || node.data.url.trim() === "") {
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: `REST API 소스 노드 "${node.data.displayName || node.id}": URL이 필요합니다.`,
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
if (!node.data.method) {
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: `REST API 소스 노드 "${node.data.displayName || node.id}": HTTP 메서드가 필요합니다.`,
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "insertAction":
|
||||
case "updateAction":
|
||||
case "deleteAction":
|
||||
if (!node.data.targetTable || node.data.targetTable.trim() === "") {
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: `${getActionTypeName(node.type)} 노드 "${node.data.displayName || node.id}": 타겟 테이블이 필요합니다.`,
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
if (node.type === "insertAction" || node.type === "updateAction") {
|
||||
if (!node.data.fieldMappings || node.data.fieldMappings.length === 0) {
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: `${getActionTypeName(node.type)} 노드 "${node.data.displayName || node.id}": 최소 하나의 필드 매핑이 필요합니다.`,
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (node.type === "updateAction" || node.type === "deleteAction") {
|
||||
if (!node.data.whereConditions || node.data.whereConditions.length === 0) {
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: `${getActionTypeName(node.type)} 노드 "${node.data.displayName || node.id}": WHERE 조건이 필요합니다.`,
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "upsertAction":
|
||||
if (!node.data.targetTable || node.data.targetTable.trim() === "") {
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: `UPSERT 액션 노드 "${node.data.displayName || node.id}": 타겟 테이블이 필요합니다.`,
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
if (!node.data.conflictKeys || node.data.conflictKeys.length === 0) {
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: `UPSERT 액션 노드 "${node.data.displayName || node.id}": 충돌 키(ON CONFLICT)가 필요합니다.`,
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
if (!node.data.fieldMappings || node.data.fieldMappings.length === 0) {
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: `UPSERT 액션 노드 "${node.data.displayName || node.id}": 최소 하나의 필드 매핑이 필요합니다.`,
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "condition":
|
||||
if (!node.data.conditions || node.data.conditions.length === 0) {
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: `조건 노드 "${node.data.displayName || node.id}": 최소 하나의 조건이 필요합니다.`,
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "fieldMapping":
|
||||
if (!node.data.mappings || node.data.mappings.length === 0) {
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: `필드 매핑 노드 "${node.data.displayName || node.id}": 최소 하나의 매핑이 필요합니다.`,
|
||||
severity: "warning",
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "dataTransform":
|
||||
if (!node.data.transformations || node.data.transformations.length === 0) {
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: `데이터 변환 노드 "${node.data.displayName || node.id}": 최소 하나의 변환 규칙이 필요합니다.`,
|
||||
severity: "warning",
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "log":
|
||||
if (!node.data.message || node.data.message.trim() === "") {
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: `로그 노드 "${node.data.displayName || node.id}": 로그 메시지가 필요합니다.`,
|
||||
severity: "warning",
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "comment":
|
||||
// Comment 노드는 내용이 없어도 괜찮음
|
||||
break;
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* 액션 타입 이름 반환
|
||||
*/
|
||||
function getActionTypeName(type: string): string {
|
||||
const names: Record<string, string> = {
|
||||
insertAction: "INSERT",
|
||||
updateAction: "UPDATE",
|
||||
deleteAction: "DELETE",
|
||||
upsertAction: "UPSERT",
|
||||
};
|
||||
return names[type] || type;
|
||||
}
|
||||
|
|
@ -795,13 +795,74 @@ export class ButtonActionExecutor {
|
|||
});
|
||||
|
||||
// 🔥 새로운 버튼 액션 실행 시스템 사용
|
||||
if (config.dataflowConfig?.controlMode === "relationship" && config.dataflowConfig?.relationshipConfig) {
|
||||
if (config.dataflowConfig?.controlMode === "flow" && config.dataflowConfig?.flowConfig) {
|
||||
console.log("🎯 노드 플로우 실행:", config.dataflowConfig.flowConfig);
|
||||
|
||||
const { flowId, executionTiming } = config.dataflowConfig.flowConfig;
|
||||
|
||||
if (!flowId) {
|
||||
console.error("❌ 플로우 ID가 없습니다");
|
||||
toast.error("플로우가 설정되지 않았습니다.");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 노드 플로우 실행 API 호출 (API 클라이언트 사용)
|
||||
const { executeNodeFlow } = await import("@/lib/api/nodeFlows");
|
||||
|
||||
// 데이터 소스 준비: 선택된 행 또는 폼 데이터
|
||||
let sourceData: any = null;
|
||||
let dataSourceType: string = "none";
|
||||
|
||||
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
|
||||
// 테이블에서 선택된 행 데이터 사용
|
||||
sourceData = context.selectedRowsData;
|
||||
dataSourceType = "table-selection";
|
||||
console.log("📊 테이블 선택 데이터 사용:", sourceData);
|
||||
} else if (context.formData && Object.keys(context.formData).length > 0) {
|
||||
// 폼 데이터 사용 (배열로 감싸서 일관성 유지)
|
||||
sourceData = [context.formData];
|
||||
dataSourceType = "form";
|
||||
console.log("📝 폼 데이터 사용:", sourceData);
|
||||
}
|
||||
|
||||
const result = await executeNodeFlow(flowId, {
|
||||
dataSourceType,
|
||||
sourceData,
|
||||
context,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
console.log("✅ 노드 플로우 실행 완료:", result);
|
||||
toast.success(config.successMessage || "플로우 실행이 완료되었습니다.");
|
||||
|
||||
// 새로고침이 필요한 경우
|
||||
if (context.onRefresh) {
|
||||
context.onRefresh();
|
||||
}
|
||||
|
||||
return true;
|
||||
} else {
|
||||
console.error("❌ 노드 플로우 실행 실패:", result);
|
||||
toast.error(config.errorMessage || result.message || "플로우 실행 중 오류가 발생했습니다.");
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 노드 플로우 실행 오류:", error);
|
||||
toast.error("플로우 실행 중 오류가 발생했습니다.");
|
||||
return false;
|
||||
}
|
||||
} else if (config.dataflowConfig?.controlMode === "relationship" && config.dataflowConfig?.relationshipConfig) {
|
||||
console.log("🔗 관계 기반 제어 실행:", config.dataflowConfig.relationshipConfig);
|
||||
|
||||
// 🔥 table-selection 모드일 때 선택된 행 데이터를 formData에 병합
|
||||
let mergedFormData = { ...context.formData } || {};
|
||||
|
||||
if (controlDataSource === "table-selection" && context.selectedRowsData && context.selectedRowsData.length > 0) {
|
||||
if (
|
||||
controlDataSource === "table-selection" &&
|
||||
context.selectedRowsData &&
|
||||
context.selectedRowsData.length > 0
|
||||
) {
|
||||
// 선택된 첫 번째 행의 데이터를 formData에 병합
|
||||
const selectedRowData = context.selectedRowsData[0];
|
||||
mergedFormData = { ...mergedFormData, ...selectedRowData };
|
||||
|
|
@ -819,18 +880,14 @@ export class ButtonActionExecutor {
|
|||
enableDataflowControl: true, // 관계 기반 제어가 설정된 경우 활성화
|
||||
};
|
||||
|
||||
const executionResult = await ImprovedButtonActionExecutor.executeButtonAction(
|
||||
buttonConfig,
|
||||
mergedFormData,
|
||||
{
|
||||
buttonId: context.buttonId || "unknown",
|
||||
screenId: context.screenId || "unknown",
|
||||
userId: context.userId || "unknown",
|
||||
companyCode: context.companyCode || "*",
|
||||
startTime: Date.now(),
|
||||
contextData: context,
|
||||
}
|
||||
);
|
||||
const executionResult = await ImprovedButtonActionExecutor.executeButtonAction(buttonConfig, mergedFormData, {
|
||||
buttonId: context.buttonId || "unknown",
|
||||
screenId: context.screenId || "unknown",
|
||||
userId: context.userId || "unknown",
|
||||
companyCode: context.companyCode || "*",
|
||||
startTime: Date.now(),
|
||||
contextData: context,
|
||||
});
|
||||
|
||||
if (executionResult.success) {
|
||||
console.log("✅ 관계 실행 완료:", executionResult);
|
||||
|
|
@ -848,9 +905,8 @@ export class ButtonActionExecutor {
|
|||
return false;
|
||||
}
|
||||
} else {
|
||||
// 제어 없음 - 메인 액션만 실행
|
||||
console.log("⚡ 제어 없음 - 메인 액션 실행");
|
||||
await this.executeMainAction(config, context);
|
||||
// 제어 없음 - 성공 처리
|
||||
console.log("⚡ 제어 없음 - 버튼 액션만 실행");
|
||||
|
||||
// 새로고침이 필요한 경우
|
||||
if (context.onRefresh) {
|
||||
|
|
@ -869,7 +925,10 @@ export class ButtonActionExecutor {
|
|||
/**
|
||||
* 저장 후 제어 실행 (After Timing)
|
||||
*/
|
||||
private static async executeAfterSaveControl(config: ButtonActionConfig, context: ButtonActionContext): Promise<void> {
|
||||
private static async executeAfterSaveControl(
|
||||
config: ButtonActionConfig,
|
||||
context: ButtonActionContext,
|
||||
): Promise<void> {
|
||||
console.log("🎯 저장 후 제어 실행:", {
|
||||
enableDataflowControl: config.enableDataflowControl,
|
||||
dataflowConfig: config.dataflowConfig,
|
||||
|
|
@ -915,7 +974,7 @@ export class ButtonActionExecutor {
|
|||
companyCode: context.companyCode || "*",
|
||||
startTime: Date.now(),
|
||||
contextData: context,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (executionResult.success) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,189 @@
|
|||
/**
|
||||
* 노드 플로우 기반 버튼 실행기
|
||||
*/
|
||||
|
||||
import { executeNodeFlow, ExecutionResult } from "../api/nodeFlows";
|
||||
import { logger } from "../utils/logger";
|
||||
import { toast } from "sonner";
|
||||
import type { ButtonDataflowConfig, ExtendedControlContext } from "@/types/control-management";
|
||||
|
||||
export interface ButtonExecutionContext {
|
||||
buttonId: string;
|
||||
screenId?: number;
|
||||
companyCode?: string;
|
||||
userId?: string;
|
||||
formData: Record<string, any>;
|
||||
selectedRows?: any[];
|
||||
selectedRowsData?: Record<string, any>[];
|
||||
controlDataSource?: "form" | "table-selection" | "both";
|
||||
onRefresh?: () => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export interface FlowExecutionResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
executionTime?: number;
|
||||
data?: ExecutionResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* 노드 플로우 실행 함수
|
||||
*/
|
||||
export async function executeButtonWithFlow(
|
||||
flowConfig: ButtonDataflowConfig["flowConfig"],
|
||||
context: ButtonExecutionContext,
|
||||
originalAction?: () => Promise<void>,
|
||||
): Promise<FlowExecutionResult> {
|
||||
if (!flowConfig) {
|
||||
throw new Error("플로우 설정이 없습니다.");
|
||||
}
|
||||
|
||||
const { flowId, flowName, executionTiming = "before" } = flowConfig;
|
||||
|
||||
logger.info(`🚀 노드 플로우 실행 시작:`, {
|
||||
flowId,
|
||||
flowName,
|
||||
timing: executionTiming,
|
||||
contextKeys: Object.keys(context),
|
||||
});
|
||||
|
||||
try {
|
||||
// 컨텍스트 데이터 준비
|
||||
const contextData = prepareContextData(context);
|
||||
|
||||
// 타이밍에 따라 실행
|
||||
switch (executionTiming) {
|
||||
case "before":
|
||||
// 1. 플로우 먼저 실행
|
||||
const beforeResult = await executeNodeFlow(flowId, contextData);
|
||||
|
||||
if (!beforeResult.success) {
|
||||
toast.error(`플로우 실행 실패: ${beforeResult.message}`);
|
||||
return {
|
||||
success: false,
|
||||
message: beforeResult.message,
|
||||
data: beforeResult,
|
||||
};
|
||||
}
|
||||
|
||||
toast.success(`플로우 실행 완료: ${flowName}`);
|
||||
|
||||
// 2. 원래 버튼 액션 실행
|
||||
if (originalAction) {
|
||||
await originalAction();
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "플로우 및 버튼 액션이 성공적으로 실행되었습니다.",
|
||||
executionTime: beforeResult.executionTime,
|
||||
data: beforeResult,
|
||||
};
|
||||
|
||||
case "after":
|
||||
// 1. 원래 버튼 액션 먼저 실행
|
||||
if (originalAction) {
|
||||
await originalAction();
|
||||
}
|
||||
|
||||
// 2. 플로우 실행
|
||||
const afterResult = await executeNodeFlow(flowId, contextData);
|
||||
|
||||
if (!afterResult.success) {
|
||||
toast.warning(`버튼 액션은 성공했으나 플로우 실행 실패: ${afterResult.message}`);
|
||||
} else {
|
||||
toast.success(`플로우 실행 완료: ${flowName}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: afterResult.success,
|
||||
message: afterResult.message,
|
||||
executionTime: afterResult.executionTime,
|
||||
data: afterResult,
|
||||
};
|
||||
|
||||
case "replace":
|
||||
// 플로우만 실행 (원래 액션 대체)
|
||||
const replaceResult = await executeNodeFlow(flowId, contextData);
|
||||
|
||||
if (!replaceResult.success) {
|
||||
toast.error(`플로우 실행 실패: ${replaceResult.message}`);
|
||||
} else {
|
||||
toast.success(`플로우 실행 완료: ${flowName}`, {
|
||||
description: `${replaceResult.summary.success}/${replaceResult.summary.total} 노드 성공`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: replaceResult.success,
|
||||
message: replaceResult.message,
|
||||
executionTime: replaceResult.executionTime,
|
||||
data: replaceResult,
|
||||
};
|
||||
|
||||
default:
|
||||
throw new Error(`지원하지 않는 실행 타이밍: ${executionTiming}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("플로우 실행 오류:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.";
|
||||
toast.error(`플로우 실행 오류: ${errorMessage}`);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: errorMessage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 컨텍스트 데이터 준비
|
||||
*/
|
||||
function prepareContextData(context: ButtonExecutionContext): Record<string, any> {
|
||||
return {
|
||||
buttonId: context.buttonId,
|
||||
screenId: context.screenId,
|
||||
companyCode: context.companyCode,
|
||||
userId: context.userId,
|
||||
formData: context.formData || {},
|
||||
selectedRowsData: context.selectedRowsData || [],
|
||||
controlDataSource: context.controlDataSource || "form",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 플로우 실행 결과 처리
|
||||
*/
|
||||
export function handleFlowExecutionResult(result: FlowExecutionResult, context: ButtonExecutionContext): void {
|
||||
if (result.success) {
|
||||
logger.info("✅ 플로우 실행 성공:", result);
|
||||
|
||||
// 성공 시 데이터 새로고침
|
||||
if (context.onRefresh) {
|
||||
context.onRefresh();
|
||||
}
|
||||
|
||||
// 실행 결과 요약 표시
|
||||
if (result.data) {
|
||||
const { summary } = result.data;
|
||||
console.log("📊 플로우 실행 요약:", {
|
||||
전체: summary.total,
|
||||
성공: summary.success,
|
||||
실패: summary.failed,
|
||||
스킵: summary.skipped,
|
||||
실행시간: `${result.executionTime}ms`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
logger.error("❌ 플로우 실행 실패:", result);
|
||||
|
||||
// 실패한 노드 정보 표시
|
||||
if (result.data) {
|
||||
const failedNodes = result.data.nodes.filter((n) => n.status === "failed");
|
||||
if (failedNodes.length > 0) {
|
||||
console.error("❌ 실패한 노드들:", failedNodes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -49,6 +49,7 @@
|
|||
"react-hook-form": "^7.62.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-window": "^2.1.0",
|
||||
"reactflow": "^11.10.4",
|
||||
"recharts": "^3.2.1",
|
||||
"sheetjs-style": "^0.15.8",
|
||||
"sonner": "^2.0.7",
|
||||
|
|
@ -2280,6 +2281,108 @@
|
|||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@reactflow/background": {
|
||||
"version": "11.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.9.tgz",
|
||||
"integrity": "sha512-byj/G9pEC8tN0wT/ptcl/LkEP/BBfa33/SvBkqE4XwyofckqF87lKp573qGlisfnsijwAbpDlf81PuFL41So4Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/core": "11.10.4",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/controls": {
|
||||
"version": "11.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.9.tgz",
|
||||
"integrity": "sha512-e8nWplbYfOn83KN1BrxTXS17+enLyFnjZPbyDgHSRLtI5ZGPKF/8iRXV+VXb2LFVzlu4Wh3la/pkxtfP/0aguA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/core": "11.10.4",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/core": {
|
||||
"version": "11.10.4",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.10.4.tgz",
|
||||
"integrity": "sha512-j3i9b2fsTX/sBbOm+RmNzYEFWbNx4jGWGuGooh2r1jQaE2eV+TLJgiG/VNOp0q5mBl9f6g1IXs3Gm86S9JfcGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3": "^7.4.0",
|
||||
"@types/d3-drag": "^3.0.1",
|
||||
"@types/d3-selection": "^3.0.3",
|
||||
"@types/d3-zoom": "^3.0.1",
|
||||
"classcat": "^5.0.3",
|
||||
"d3-drag": "^3.0.0",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/minimap": {
|
||||
"version": "11.7.9",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.9.tgz",
|
||||
"integrity": "sha512-le95jyTtt3TEtJ1qa7tZ5hyM4S7gaEQkW43cixcMOZLu33VAdc2aCpJg/fXcRrrf7moN2Mbl9WIMNXUKsp5ILA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/core": "11.10.4",
|
||||
"@types/d3-selection": "^3.0.3",
|
||||
"@types/d3-zoom": "^3.0.1",
|
||||
"classcat": "^5.0.3",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/node-resizer": {
|
||||
"version": "2.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.9.tgz",
|
||||
"integrity": "sha512-HfickMm0hPDIHt9qH997nLdgLt0kayQyslKE0RS/GZvZ4UMQJlx/NRRyj5y47Qyg0NnC66KYOQWDM9LLzRTnUg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/core": "11.10.4",
|
||||
"classcat": "^5.0.4",
|
||||
"d3-drag": "^3.0.0",
|
||||
"d3-selection": "^3.0.0",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/node-toolbar": {
|
||||
"version": "1.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.9.tgz",
|
||||
"integrity": "sha512-VmgxKmToax4sX1biZ9LXA7cj/TBJ+E5cklLGwquCCVVxh+lxpZGTBF3a5FJGVHiUNBBtFsC8ldcSZIK4cAlQww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/core": "11.10.4",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.0.tgz",
|
||||
|
|
@ -2716,18 +2819,102 @@
|
|||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3": {
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
|
||||
"integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "*",
|
||||
"@types/d3-axis": "*",
|
||||
"@types/d3-brush": "*",
|
||||
"@types/d3-chord": "*",
|
||||
"@types/d3-color": "*",
|
||||
"@types/d3-contour": "*",
|
||||
"@types/d3-delaunay": "*",
|
||||
"@types/d3-dispatch": "*",
|
||||
"@types/d3-drag": "*",
|
||||
"@types/d3-dsv": "*",
|
||||
"@types/d3-ease": "*",
|
||||
"@types/d3-fetch": "*",
|
||||
"@types/d3-force": "*",
|
||||
"@types/d3-format": "*",
|
||||
"@types/d3-geo": "*",
|
||||
"@types/d3-hierarchy": "*",
|
||||
"@types/d3-interpolate": "*",
|
||||
"@types/d3-path": "*",
|
||||
"@types/d3-polygon": "*",
|
||||
"@types/d3-quadtree": "*",
|
||||
"@types/d3-random": "*",
|
||||
"@types/d3-scale": "*",
|
||||
"@types/d3-scale-chromatic": "*",
|
||||
"@types/d3-selection": "*",
|
||||
"@types/d3-shape": "*",
|
||||
"@types/d3-time": "*",
|
||||
"@types/d3-time-format": "*",
|
||||
"@types/d3-timer": "*",
|
||||
"@types/d3-transition": "*",
|
||||
"@types/d3-zoom": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-axis": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
|
||||
"integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-brush": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
|
||||
"integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-chord": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
|
||||
"integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-contour": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
|
||||
"integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "*",
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-delaunay": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
|
||||
"integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-dispatch": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
|
||||
"integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-drag": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
||||
|
|
@ -2737,12 +2924,54 @@
|
|||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-dsv": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
|
||||
"integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-fetch": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
|
||||
"integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-dsv": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-force": {
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
|
||||
"integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-format": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
|
||||
"integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-geo": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
|
||||
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-hierarchy": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
|
||||
"integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
|
|
@ -2758,6 +2987,24 @@
|
|||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-polygon": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
|
||||
"integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-quadtree": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
|
||||
"integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-random": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz",
|
||||
"integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
|
|
@ -2767,6 +3014,12 @@
|
|||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-scale-chromatic": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
|
||||
"integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-selection": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
||||
|
|
@ -2788,6 +3041,12 @@
|
|||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-time-format": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
|
||||
"integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
|
|
@ -2820,6 +3079,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
|
|
@ -7943,6 +8208,24 @@
|
|||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reactflow": {
|
||||
"version": "11.10.4",
|
||||
"resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.10.4.tgz",
|
||||
"integrity": "sha512-0CApYhtYicXEDg/x2kvUHiUk26Qur8lAtTtiSlptNKuyEuGti6P1y5cS32YGaUoDMoCqkm/m+jcKkfMOvSCVRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/background": "11.3.9",
|
||||
"@reactflow/controls": "11.2.9",
|
||||
"@reactflow/core": "11.10.4",
|
||||
"@reactflow/minimap": "11.7.9",
|
||||
"@reactflow/node-resizer": "2.2.9",
|
||||
"@reactflow/node-toolbar": "1.3.9"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@
|
|||
"react-hook-form": "^7.62.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-window": "^2.1.0",
|
||||
"reactflow": "^11.10.4",
|
||||
"recharts": "^3.2.1",
|
||||
"sheetjs-style": "^0.15.8",
|
||||
"sonner": "^2.0.7",
|
||||
|
|
|
|||
|
|
@ -59,8 +59,8 @@ export interface ExtendedButtonTypeConfig {
|
|||
* 🔥 단순화된 버튼 데이터플로우 설정
|
||||
*/
|
||||
export interface ButtonDataflowConfig {
|
||||
// 제어 방식 선택 (관계 실행만)
|
||||
controlMode: "relationship" | "none";
|
||||
// 제어 방식 선택 (관계 실행 + 🆕 노드 플로우)
|
||||
controlMode: "relationship" | "flow" | "none";
|
||||
|
||||
// 관계 기반 제어
|
||||
relationshipConfig?: {
|
||||
|
|
@ -70,6 +70,14 @@ export interface ButtonDataflowConfig {
|
|||
contextData?: Record<string, any>; // 실행 시 전달할 컨텍스트
|
||||
};
|
||||
|
||||
// 🆕 노드 플로우 기반 제어
|
||||
flowConfig?: {
|
||||
flowId: number; // 노드 플로우 ID
|
||||
flowName: string; // 플로우명 표시
|
||||
executionTiming: "before" | "after" | "replace";
|
||||
contextData?: Record<string, any>; // 실행 시 전달할 컨텍스트
|
||||
};
|
||||
|
||||
// 제어 데이터 소스 (기존 호환성 유지)
|
||||
controlDataSource?: ControlDataSource;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,414 @@
|
|||
/**
|
||||
* 노드 기반 데이터 제어 시스템 타입 정의
|
||||
*/
|
||||
|
||||
import { Node as ReactFlowNode, Edge as ReactFlowEdge } from "reactflow";
|
||||
|
||||
// ============================================================================
|
||||
// 기본 노드 타입
|
||||
// ============================================================================
|
||||
|
||||
export type NodeType =
|
||||
| "tableSource" // 테이블 소스
|
||||
| "externalDBSource" // 외부 DB 소스
|
||||
| "restAPISource" // REST API 소스
|
||||
| "condition" // 조건 분기
|
||||
| "fieldMapping" // 필드 매핑
|
||||
| "dataTransform" // 데이터 변환
|
||||
| "insertAction" // INSERT 액션
|
||||
| "updateAction" // UPDATE 액션
|
||||
| "deleteAction" // DELETE 액션
|
||||
| "upsertAction" // UPSERT 액션
|
||||
| "comment" // 주석
|
||||
| "log"; // 로그
|
||||
|
||||
// ============================================================================
|
||||
// 필드 정의
|
||||
// ============================================================================
|
||||
|
||||
export interface FieldDefinition {
|
||||
name: string;
|
||||
type: string;
|
||||
nullable: boolean;
|
||||
primaryKey?: boolean;
|
||||
label?: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 노드 데이터 타입별 정의
|
||||
// ============================================================================
|
||||
|
||||
// 테이블 소스 노드
|
||||
export interface TableSourceNodeData {
|
||||
connectionId: number;
|
||||
tableName: string;
|
||||
schema?: string;
|
||||
fields: FieldDefinition[];
|
||||
filters?: Array<{
|
||||
field: string;
|
||||
operator: string;
|
||||
value: any;
|
||||
}>;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
// 외부 DB 소스 노드
|
||||
export interface ExternalDBSourceNodeData {
|
||||
connectionId: number;
|
||||
connectionName: string;
|
||||
dbType: string;
|
||||
tableName: string;
|
||||
fields: FieldDefinition[];
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
// REST API 소스 노드
|
||||
export interface RestAPISourceNodeData {
|
||||
method: "GET" | "POST" | "PUT" | "DELETE";
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
responseFields: FieldDefinition[];
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
// 조건 분기 노드
|
||||
export interface ConditionNodeData {
|
||||
conditions: Array<{
|
||||
field: string;
|
||||
operator:
|
||||
| "EQUALS"
|
||||
| "NOT_EQUALS"
|
||||
| "GREATER_THAN"
|
||||
| "LESS_THAN"
|
||||
| "GREATER_THAN_OR_EQUAL"
|
||||
| "LESS_THAN_OR_EQUAL"
|
||||
| "LIKE"
|
||||
| "NOT_LIKE"
|
||||
| "IN"
|
||||
| "NOT_IN"
|
||||
| "IS_NULL"
|
||||
| "IS_NOT_NULL";
|
||||
value: any;
|
||||
}>;
|
||||
logic: "AND" | "OR";
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
// 필드 매핑 노드
|
||||
export interface FieldMappingNodeData {
|
||||
mappings: Array<{
|
||||
id: string;
|
||||
sourceField: string | null;
|
||||
targetField: string;
|
||||
transform?: string;
|
||||
staticValue?: any;
|
||||
}>;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
// 데이터 변환 노드
|
||||
export interface DataTransformNodeData {
|
||||
transformations: Array<{
|
||||
type:
|
||||
| "UPPERCASE"
|
||||
| "LOWERCASE"
|
||||
| "TRIM"
|
||||
| "CONCAT"
|
||||
| "SPLIT"
|
||||
| "REPLACE"
|
||||
| "CALCULATE"
|
||||
| "EXPLODE"
|
||||
| "CAST"
|
||||
| "FORMAT"
|
||||
| "JSON_EXTRACT"
|
||||
| "CUSTOM";
|
||||
sourceField?: string; // 소스 필드
|
||||
sourceFieldLabel?: string; // 소스 필드 라벨
|
||||
targetField?: string; // 타겟 필드 (선택, 비어있으면 소스 필드에 적용)
|
||||
targetFieldLabel?: string; // 타겟 필드 라벨
|
||||
expression?: string; // 표현식
|
||||
parameters?: Record<string, any>; // 추가 파라미터
|
||||
// EXPLODE 전용
|
||||
delimiter?: string; // 구분자 (예: ",")
|
||||
// CONCAT 전용
|
||||
sourceFields?: string[]; // 다중 소스 필드
|
||||
sourceFieldLabels?: string[]; // 다중 소스 필드 라벨
|
||||
separator?: string; // 구분자 (예: " ")
|
||||
// SPLIT 전용
|
||||
splitIndex?: number; // 분리 후 가져올 인덱스
|
||||
// REPLACE 전용
|
||||
searchValue?: string; // 찾을 문자열
|
||||
replaceValue?: string; // 바꿀 문자열
|
||||
// CAST 전용
|
||||
castType?: "string" | "number" | "boolean" | "date"; // 변환할 타입
|
||||
}>;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
// INSERT 액션 노드
|
||||
export interface InsertActionNodeData {
|
||||
targetConnection: number;
|
||||
targetTable: string;
|
||||
fieldMappings: Array<{
|
||||
sourceField: string | null;
|
||||
sourceFieldLabel?: string; // 소스 필드 라벨
|
||||
targetField: string;
|
||||
targetFieldLabel?: string; // 타겟 필드 라벨
|
||||
staticValue?: any;
|
||||
}>;
|
||||
options: {
|
||||
batchSize?: number;
|
||||
ignoreErrors?: boolean;
|
||||
ignoreDuplicates?: boolean;
|
||||
};
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
// UPDATE 액션 노드
|
||||
export interface UpdateActionNodeData {
|
||||
targetConnection: number;
|
||||
targetTable: string;
|
||||
fieldMappings: Array<{
|
||||
sourceField: string | null;
|
||||
sourceFieldLabel?: string; // 소스 필드 라벨
|
||||
targetField: string;
|
||||
targetFieldLabel?: string; // 타겟 필드 라벨
|
||||
staticValue?: any;
|
||||
}>;
|
||||
whereConditions: Array<{
|
||||
field: string;
|
||||
fieldLabel?: string; // 필드 라벨
|
||||
operator: string;
|
||||
sourceField?: string;
|
||||
sourceFieldLabel?: string; // 소스 필드 라벨
|
||||
staticValue?: any;
|
||||
}>;
|
||||
options: {
|
||||
batchSize?: number;
|
||||
ignoreErrors?: boolean;
|
||||
};
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
// DELETE 액션 노드
|
||||
export interface DeleteActionNodeData {
|
||||
targetConnection: number;
|
||||
targetTable: string;
|
||||
whereConditions: Array<{
|
||||
field: string;
|
||||
operator: string;
|
||||
sourceField?: string;
|
||||
staticValue?: any;
|
||||
}>;
|
||||
options: {
|
||||
requireConfirmation?: boolean;
|
||||
};
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
// UPSERT 액션 노드
|
||||
export interface UpsertActionNodeData {
|
||||
targetConnection: number;
|
||||
targetTable: string;
|
||||
conflictKeys: string[]; // ON CONFLICT 키
|
||||
conflictKeyLabels?: string[]; // 충돌 키 라벨
|
||||
fieldMappings: Array<{
|
||||
sourceField: string | null;
|
||||
sourceFieldLabel?: string; // 소스 필드 라벨
|
||||
targetField: string;
|
||||
targetFieldLabel?: string; // 타겟 필드 라벨
|
||||
staticValue?: any;
|
||||
}>;
|
||||
options?: {
|
||||
batchSize?: number;
|
||||
updateOnConflict?: boolean;
|
||||
};
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
// REST API 소스 노드
|
||||
export interface RestAPISourceNodeData {
|
||||
url: string;
|
||||
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
||||
headers?: Record<string, string>;
|
||||
body?: any;
|
||||
authentication?: {
|
||||
type: "none" | "bearer" | "basic" | "apikey";
|
||||
token?: string;
|
||||
};
|
||||
timeout?: number;
|
||||
responseMapping?: string; // JSON 경로 (예: "data.items")
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
// 주석 노드
|
||||
export interface CommentNodeData {
|
||||
content: string;
|
||||
}
|
||||
|
||||
// 로그 노드
|
||||
export interface LogNodeData {
|
||||
level: "debug" | "info" | "warn" | "error";
|
||||
message: string;
|
||||
includeData?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 통합 노드 데이터 타입
|
||||
// ============================================================================
|
||||
|
||||
export type NodeData =
|
||||
| TableSourceNodeData
|
||||
| ExternalDBSourceNodeData
|
||||
| RestAPISourceNodeData
|
||||
| ConditionNodeData
|
||||
| FieldMappingNodeData
|
||||
| DataTransformNodeData
|
||||
| InsertActionNodeData
|
||||
| UpdateActionNodeData
|
||||
| DeleteActionNodeData
|
||||
| UpsertActionNodeData
|
||||
| CommentNodeData
|
||||
| LogNodeData;
|
||||
|
||||
// ============================================================================
|
||||
// React Flow 노드 확장
|
||||
// ============================================================================
|
||||
|
||||
export interface FlowNode extends ReactFlowNode {
|
||||
type: NodeType;
|
||||
data: NodeData & {
|
||||
// 공통 메타데이터
|
||||
validation?: {
|
||||
valid: boolean;
|
||||
errors?: string[];
|
||||
warnings?: string[];
|
||||
};
|
||||
// 실행 상태 (디버그 모드용)
|
||||
executionState?: {
|
||||
status: "pending" | "running" | "success" | "error" | "idle";
|
||||
progress?: number;
|
||||
processedCount?: number;
|
||||
errorMessage?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 엣지 타입
|
||||
// ============================================================================
|
||||
|
||||
export type EdgeType =
|
||||
| "dataFlow" // 일반 데이터 흐름
|
||||
| "fieldConnection" // 필드 연결
|
||||
| "conditionalTrue" // 조건 TRUE
|
||||
| "conditionalFalse"; // 조건 FALSE
|
||||
|
||||
export interface FlowEdge extends ReactFlowEdge {
|
||||
type?: EdgeType;
|
||||
data?: {
|
||||
dataType?: string;
|
||||
validation?: {
|
||||
valid: boolean;
|
||||
errors?: string[];
|
||||
};
|
||||
// 조건 분기용
|
||||
condition?: "TRUE" | "FALSE";
|
||||
// 필드 연결용
|
||||
sourceFieldName?: string;
|
||||
targetFieldName?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 전체 플로우 데이터
|
||||
// ============================================================================
|
||||
|
||||
export interface DataFlow {
|
||||
id?: number;
|
||||
name: string;
|
||||
description: string;
|
||||
companyCode: string;
|
||||
nodes: FlowNode[];
|
||||
edges: FlowEdge[];
|
||||
viewport?: {
|
||||
x: number;
|
||||
y: number;
|
||||
zoom: number;
|
||||
};
|
||||
metadata?: {
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
createdBy?: string;
|
||||
version?: number;
|
||||
tags?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 검증 결과
|
||||
// ============================================================================
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors: Array<{
|
||||
nodeId?: string;
|
||||
edgeId?: string;
|
||||
message: string;
|
||||
severity: "error" | "warning" | "info";
|
||||
}>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 실행 결과
|
||||
// ============================================================================
|
||||
|
||||
export interface ExecutionResult {
|
||||
success: boolean;
|
||||
processedCount: number;
|
||||
errorCount: number;
|
||||
duration: number;
|
||||
nodeResults: Array<{
|
||||
nodeId: string;
|
||||
status: "success" | "error" | "skipped";
|
||||
processedCount?: number;
|
||||
errorMessage?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 노드 팔레트 아이템
|
||||
// ============================================================================
|
||||
|
||||
export interface NodePaletteItem {
|
||||
type: NodeType;
|
||||
label: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
category: "source" | "transform" | "action" | "utility";
|
||||
color: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 템플릿
|
||||
// ============================================================================
|
||||
|
||||
export interface FlowTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: "builtin" | "custom";
|
||||
thumbnail?: string;
|
||||
nodes: FlowNode[];
|
||||
edges: FlowEdge[];
|
||||
parameters?: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
type: "string" | "number" | "select";
|
||||
defaultValue?: any;
|
||||
options?: Array<{ label: string; value: any }>;
|
||||
}>;
|
||||
tags?: string[];
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
"dependencies": {
|
||||
"@prisma/client": "^6.16.2",
|
||||
"@types/mssql": "^9.1.8",
|
||||
"@xyflow/react": "^12.8.6",
|
||||
"axios": "^1.12.2",
|
||||
"mssql": "^11.0.1",
|
||||
"prisma": "^6.16.2"
|
||||
|
|
@ -371,6 +372,55 @@
|
|||
"integrity": "sha512-7qSgZbincDDDFyRweCIEvZULFAw5iz/DeunhvuxpL31nfntX3P4Yd4HkHBRg9H8CdqY1e5WFN1PZIz/REL9MVQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-drag": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
||||
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-selection": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
||||
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-transition": {
|
||||
"version": "3.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
||||
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-zoom": {
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
|
||||
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-interpolate": "*",
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mssql": {
|
||||
"version": "9.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/mssql/-/mssql-9.1.8.tgz",
|
||||
|
|
@ -436,6 +486,38 @@
|
|||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/react": {
|
||||
"version": "12.8.6",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.8.6.tgz",
|
||||
"integrity": "sha512-SksAm2m4ySupjChphMmzvm55djtgMDPr+eovPDdTnyGvShf73cvydfoBfWDFllooIQ4IaiUL5yfxHRwU0c37EA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@xyflow/system": "0.0.70",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/system": {
|
||||
"version": "0.0.70",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.70.tgz",
|
||||
"integrity": "sha512-PpC//u9zxdjj0tfTSmZrg3+sRbTz6kop/Amky44U2Dl51sxzDTIUfXMwETOYpmr2dqICWXBIJwXL2a9QWtX2XA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-drag": "^3.0.7",
|
||||
"@types/d3-interpolate": "^3.0.4",
|
||||
"@types/d3-selection": "^3.0.10",
|
||||
"@types/d3-transition": "^3.0.8",
|
||||
"@types/d3-zoom": "^3.0.8",
|
||||
"d3-drag": "^3.0.0",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/abort-controller": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
|
|
@ -616,6 +698,12 @@
|
|||
"consola": "^3.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/classcat": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
|
||||
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
|
|
@ -652,6 +740,111 @@
|
|||
"node": "^14.18.0 || >=16.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dispatch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-drag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-selection": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-selection": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-transition": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-ease": "1 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"d3-selection": "2 - 3"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-zoom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-selection": "2 - 3",
|
||||
"d3-transition": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
|
@ -1523,6 +1716,29 @@
|
|||
"destr": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
|
||||
|
|
@ -1596,6 +1812,13 @@
|
|||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
||||
|
|
@ -1671,6 +1894,15 @@
|
|||
"integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
|
|
@ -1704,6 +1936,34 @@
|
|||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "4.5.7",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=16.8",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=16.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
"dependencies": {
|
||||
"@prisma/client": "^6.16.2",
|
||||
"@types/mssql": "^9.1.8",
|
||||
"@xyflow/react": "^12.8.6",
|
||||
"axios": "^1.12.2",
|
||||
"mssql": "^11.0.1",
|
||||
"prisma": "^6.16.2"
|
||||
|
|
|
|||
Loading…
Reference in New Issue