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:
kjs 2025-10-02 16:22:29 +09:00
parent db25b0435f
commit 0743786f9b
51 changed files with 13838 additions and 131 deletions

View File

@ -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;

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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 구현 시작

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
</>
);
}

View File

@ -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>
);
}

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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;
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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);
}

View File

@ -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,

View File

@ -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}
onChange={(timing) =>
onUpdateProperty("webTypeConfig.dataflowConfig.relationshipConfig.executionTiming", timing)
value={dataflowConfig.flowConfig.executionTiming}
onChange={(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<{
);
};
/**
* 🔥
*/
@ -249,7 +222,7 @@ const ExecutionTimingSelector: React.FC<{
<Clock className="h-4 w-4 text-orange-600" />
<Label> </Label>
</div>
<Select value={value} onValueChange={onChange}>
<SelectTrigger>
<SelectValue placeholder="실행 타이밍을 선택하세요" />
@ -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>
);
};

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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,28 +880,24 @@ 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);
toast.success(config.successMessage || "관계 실행이 완료되었습니다.");
// 새로고침이 필요한 경우
if (context.onRefresh) {
context.onRefresh();
}
return true;
} else {
console.error("❌ 관계 실행 실패:", executionResult);
@ -848,15 +905,14 @@ export class ButtonActionExecutor {
return false;
}
} else {
// 제어 없음 - 메인 액션만 실행
console.log("⚡ 제어 없음 - 메인 액션 실행");
await this.executeMainAction(config, context);
// 제어 없음 - 성공 처리
console.log("⚡ 제어 없음 - 버튼 액션만 실행");
// 새로고침이 필요한 경우
if (context.onRefresh) {
context.onRefresh();
}
return true;
}
} catch (error) {
@ -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) {

View File

@ -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);
}
}
}
}

View File

@ -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",

View File

@ -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",

View File

@ -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;
@ -80,7 +88,7 @@ export interface ButtonDataflowConfig {
selectedDiagramId?: number;
selectedRelationshipId?: number;
directControl?: DirectControlConfig;
// 🔧 제거된 필드들 (하위 호환성을 위해 optional로 유지)
externalCallConfig?: any; // deprecated
customConfig?: any; // deprecated

View File

@ -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[];
}

260
package-lock.json generated
View File

@ -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
}
}
}
}
}

View File

@ -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"