플로우 페이지네이션 안보임
This commit is contained in:
parent
0a57a2cef1
commit
7d6281d289
|
|
@ -0,0 +1,471 @@
|
|||
---
|
||||
description: 스크롤 문제 디버깅 및 해결 가이드 - Flexbox 레이아웃에서 스크롤이 작동하지 않을 때 체계적인 진단과 해결 방법
|
||||
---
|
||||
|
||||
# 스크롤 문제 디버깅 및 해결 가이드
|
||||
|
||||
React/Next.js 프로젝트에서 Flexbox 레이아웃의 스크롤이 작동하지 않을 때 사용하는 체계적인 디버깅 및 해결 방법입니다.
|
||||
|
||||
## 1. 스크롤 문제의 일반적인 원인
|
||||
|
||||
### 근본 원인: Flexbox의 높이 계산 실패
|
||||
|
||||
Flexbox 레이아웃에서 스크롤이 작동하지 않는 이유:
|
||||
|
||||
1. **부모 컨테이너의 높이가 확정되지 않음**: `h-full`은 부모가 명시적인 높이를 가져야만 작동
|
||||
2. **`minHeight: auto` 기본값**: Flex item은 콘텐츠 크기만큼 늘어나려고 함
|
||||
3. **`overflow` 속성 누락**: 부모가 `overflow: hidden`이 없으면 자식이 부모를 밀어냄
|
||||
4. **`display: flex` 누락**: Flex container가 명시적으로 선언되지 않음
|
||||
|
||||
## 2. 디버깅 프로세스
|
||||
|
||||
### 단계 1: 시각적 디버깅 (컬러 테두리)
|
||||
|
||||
문제가 발생한 컴포넌트에 **컬러 테두리**를 추가하여 각 레이어의 실제 크기를 확인:
|
||||
|
||||
```tsx
|
||||
// 최상위 컨테이너 (빨간색)
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
border: "3px solid red", // 🔍 디버그
|
||||
}}
|
||||
>
|
||||
{/* 헤더 (파란색) */}
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
height: "64px",
|
||||
border: "3px solid blue", // 🔍 디버그
|
||||
}}
|
||||
>
|
||||
헤더
|
||||
</div>
|
||||
|
||||
{/* 스크롤 영역 (초록색) */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: "auto",
|
||||
border: "3px solid green", // 🔍 디버그
|
||||
}}
|
||||
>
|
||||
콘텐츠
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**브라우저에서 확인할 사항:**
|
||||
|
||||
- 🔴 빨간색 테두리가 화면 전체 높이를 차지하는가?
|
||||
- 🔵 파란색 테두리가 고정된 높이를 유지하는가?
|
||||
- 🟢 초록색 테두리가 남은 공간을 차지하는가?
|
||||
|
||||
### 단계 2: 부모 체인 추적
|
||||
|
||||
스크롤이 작동하지 않으면 **부모 컨테이너부터 역순으로 추적**:
|
||||
|
||||
```tsx
|
||||
// ❌ 문제 예시
|
||||
<div className="flex flex-col"> {/* 높이가 확정되지 않음 */}
|
||||
<div className="flex-1"> {/* flex-1이 작동하지 않음 */}
|
||||
<ComponentWithScroll /> {/* 스크롤 실패 */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// ✅ 해결
|
||||
<div className="flex flex-col h-screen"> {/* 높이 확정 */}
|
||||
<div className="flex-1 overflow-hidden"> {/* overflow 제한 */}
|
||||
<ComponentWithScroll /> {/* 스크롤 성공 */}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 단계 3: 개발자 도구로 Computed Style 확인
|
||||
|
||||
브라우저 개발자 도구에서 확인:
|
||||
|
||||
1. **Height**: `auto`가 아닌 구체적인 px 값이 있는가?
|
||||
2. **Display**: `flex`가 제대로 적용되었는가?
|
||||
3. **Overflow**: `overflow-y: auto` 또는 `scroll`이 적용되었는가?
|
||||
4. **Min-height**: `minHeight: 0`이 적용되었는가? (Flex item의 경우)
|
||||
|
||||
## 3. 해결 패턴
|
||||
|
||||
### 패턴 A: 최상위 Fixed/Absolute 컨테이너
|
||||
|
||||
```tsx
|
||||
// 페이지 레벨 (예: dataflow/page.tsx)
|
||||
<div className="fixed inset-0 z-50 bg-background">
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 헤더 (고정) */}
|
||||
<div className="flex items-center gap-4 border-b bg-background p-4">
|
||||
헤더
|
||||
</div>
|
||||
|
||||
{/* 에디터 (flex-1) */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{" "}
|
||||
{/* ⚠️ overflow-hidden 필수! */}
|
||||
<FlowEditor />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**핵심 포인트:**
|
||||
|
||||
- `fixed inset-0`: 뷰포트 전체 차지
|
||||
- `flex h-full flex-col`: Flex column 레이아웃
|
||||
- `flex-1 overflow-hidden`: 자식이 부모를 넘지 못하게 제한
|
||||
|
||||
### 패턴 B: 중첩된 Flex 컨테이너
|
||||
|
||||
```tsx
|
||||
// 컴포넌트 레벨 (예: FlowEditor.tsx)
|
||||
<div
|
||||
className="flex h-full w-full"
|
||||
style={{ height: "100%", overflow: "hidden" }} // ⚠️ 인라인 스타일로 강제
|
||||
>
|
||||
{/* 좌측 사이드바 */}
|
||||
<div className="h-full w-[300px] border-r bg-white">사이드바</div>
|
||||
|
||||
{/* 중앙 캔버스 */}
|
||||
<div className="relative flex-1">캔버스</div>
|
||||
|
||||
{/* 우측 속성 패널 */}
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "350px",
|
||||
display: "flex", // ⚠️ Flex 컨테이너 명시
|
||||
flexDirection: "column",
|
||||
}}
|
||||
className="border-l bg-white"
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**핵심 포인트:**
|
||||
|
||||
- 인라인 스타일 `height: '100%'`: Tailwind보다 우선순위 높음
|
||||
- `display: "flex"`: Flex 컨테이너 명시
|
||||
- `overflow: 'hidden'`: 자식 크기 제한
|
||||
|
||||
### 패턴 C: 스크롤 가능 영역
|
||||
|
||||
```tsx
|
||||
// 스크롤 영역 (예: PropertiesPanel.tsx)
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
overflow: "hidden", // ⚠️ 최상위는 overflow hidden
|
||||
}}
|
||||
>
|
||||
{/* 헤더 (고정) */}
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0, // ⚠️ 축소 방지
|
||||
height: "64px", // ⚠️ 명시적 높이
|
||||
}}
|
||||
className="flex items-center justify-between border-b bg-white p-4"
|
||||
>
|
||||
헤더
|
||||
</div>
|
||||
|
||||
{/* 스크롤 영역 */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1, // ⚠️ 남은 공간 차지
|
||||
minHeight: 0, // ⚠️ 핵심! Flex item 축소 허용
|
||||
overflowY: "auto", // ⚠️ 세로 스크롤
|
||||
overflowX: "hidden", // ⚠️ 가로 스크롤 방지
|
||||
}}
|
||||
>
|
||||
{/* 실제 콘텐츠 */}
|
||||
<PropertiesContent />
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**핵심 포인트:**
|
||||
|
||||
- `flexShrink: 0`: 헤더가 축소되지 않도록 고정
|
||||
- `minHeight: 0`: **가장 중요!** Flex item이 축소되도록 허용
|
||||
- `flex: 1`: 남은 공간 모두 차지
|
||||
- `overflowY: 'auto'`: 콘텐츠가 넘치면 스크롤 생성
|
||||
|
||||
## 4. 왜 `minHeight: 0`이 필요한가?
|
||||
|
||||
### Flexbox의 기본 동작
|
||||
|
||||
```css
|
||||
/* Flexbox의 기본값 */
|
||||
.flex-item {
|
||||
min-height: auto; /* 콘텐츠 크기만큼 늘어남 */
|
||||
}
|
||||
```
|
||||
|
||||
**문제:**
|
||||
|
||||
- Flex item은 **콘텐츠 크기만큼 늘어나려고 함**
|
||||
- `flex: 1`만으로는 **스크롤이 생기지 않고 부모를 밀어냄**
|
||||
- 결과: 스크롤 영역이 화면 밖으로 넘어감
|
||||
|
||||
**해결:**
|
||||
|
||||
```css
|
||||
.flex-item {
|
||||
flex: 1;
|
||||
min-height: 0; /* 축소 허용 → 스크롤 발생 */
|
||||
overflow-y: auto;
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Tailwind vs 인라인 스타일
|
||||
|
||||
### 언제 인라인 스타일을 사용하는가?
|
||||
|
||||
**Tailwind가 작동하지 않을 때:**
|
||||
|
||||
```tsx
|
||||
// ❌ Tailwind가 작동하지 않음
|
||||
<div className="flex flex-col h-full">
|
||||
|
||||
// ✅ 인라인 스타일로 강제
|
||||
<div
|
||||
className="flex flex-col"
|
||||
style={{ height: '100%', overflow: 'hidden' }}
|
||||
>
|
||||
```
|
||||
|
||||
**이유:**
|
||||
|
||||
1. **CSS 특이성**: 인라인 스타일이 가장 높은 우선순위
|
||||
2. **동적 계산**: 브라우저가 직접 해석
|
||||
3. **디버깅 쉬움**: 개발자 도구에서 바로 확인 가능
|
||||
|
||||
## 6. 체크리스트
|
||||
|
||||
스크롤 문제 발생 시 확인할 사항:
|
||||
|
||||
### 레이아웃 체크
|
||||
|
||||
- [ ] 최상위 컨테이너: `fixed` 또는 `absolute`로 높이 확정
|
||||
- [ ] 부모: `flex flex-col h-full`
|
||||
- [ ] 중간 컨테이너: `flex-1 overflow-hidden`
|
||||
- [ ] 스크롤 컨테이너 부모: `display: flex, flexDirection: column, height: 100%`
|
||||
|
||||
### 스크롤 영역 체크
|
||||
|
||||
- [ ] 헤더: `flexShrink: 0` + 명시적 높이
|
||||
- [ ] 스크롤 영역: `flex: 1, minHeight: 0, overflowY: auto`
|
||||
- [ ] 콘텐츠: 자연스러운 높이 (height 제약 없음)
|
||||
|
||||
### 디버깅 체크
|
||||
|
||||
- [ ] 컬러 테두리로 각 레이어의 크기 확인
|
||||
- [ ] 개발자 도구로 Computed Style 확인
|
||||
- [ ] 부모 체인을 역순으로 추적
|
||||
- [ ] `minHeight: 0` 적용 확인
|
||||
|
||||
## 7. 일반적인 실수
|
||||
|
||||
### 실수 1: 부모의 높이 미확정
|
||||
|
||||
```tsx
|
||||
// ❌ 부모의 높이가 auto
|
||||
<div className="flex flex-col">
|
||||
<div className="flex-1">
|
||||
<ScrollComponent /> {/* 작동 안 함 */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// ✅ 부모의 높이 확정
|
||||
<div className="flex flex-col h-screen">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ScrollComponent /> {/* 작동 */}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 실수 2: overflow-hidden 누락
|
||||
|
||||
```tsx
|
||||
// ❌ overflow-hidden 없음
|
||||
<div className="flex-1">
|
||||
<ScrollComponent /> {/* 부모를 밀어냄 */}
|
||||
</div>
|
||||
|
||||
// ✅ overflow-hidden 추가
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ScrollComponent /> {/* 제한됨 */}
|
||||
</div>
|
||||
```
|
||||
|
||||
### 실수 3: minHeight: 0 누락
|
||||
|
||||
```tsx
|
||||
// ❌ minHeight: 0 없음
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{/* 스크롤 안 됨 */}
|
||||
</div>
|
||||
|
||||
// ✅ minHeight: 0 추가
|
||||
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
{/* 스크롤 됨 */}
|
||||
</div>
|
||||
```
|
||||
|
||||
### 실수 4: display: flex 누락
|
||||
|
||||
```tsx
|
||||
// ❌ Flex 컨테이너 미지정
|
||||
<div style={{ height: '100%', width: '350px' }}>
|
||||
<PropertiesPanel /> {/* flex-1이 작동 안 함 */}
|
||||
</div>
|
||||
|
||||
// ✅ Flex 컨테이너 명시
|
||||
<div style={{
|
||||
height: '100%',
|
||||
width: '350px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
<PropertiesPanel /> {/* 작동 */}
|
||||
</div>
|
||||
```
|
||||
|
||||
## 8. 완전한 예시
|
||||
|
||||
### 전체 레이아웃 구조
|
||||
|
||||
```tsx
|
||||
// 페이지 (dataflow/page.tsx)
|
||||
<div className="fixed inset-0 z-50 bg-background">
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-4 border-b bg-background p-4">
|
||||
헤더
|
||||
</div>
|
||||
|
||||
{/* 에디터 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<FlowEditor />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// 에디터 (FlowEditor.tsx)
|
||||
<div
|
||||
className="flex h-full w-full"
|
||||
style={{ height: '100%', overflow: 'hidden' }}
|
||||
>
|
||||
{/* 사이드바 */}
|
||||
<div className="h-full w-[300px] border-r">
|
||||
사이드바
|
||||
</div>
|
||||
|
||||
{/* 캔버스 */}
|
||||
<div className="relative flex-1">
|
||||
캔버스
|
||||
</div>
|
||||
|
||||
{/* 속성 패널 */}
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "350px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
className="border-l bg-white"
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// 속성 패널 (PropertiesPanel.tsx)
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
height: '64px'
|
||||
}}
|
||||
className="flex items-center justify-between border-b bg-white p-4"
|
||||
>
|
||||
헤더
|
||||
</div>
|
||||
|
||||
{/* 스크롤 영역 */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* 콘텐츠 */}
|
||||
<PropertiesContent />
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 9. 요약
|
||||
|
||||
### 핵심 원칙
|
||||
|
||||
1. **높이 확정**: 부모 체인의 모든 요소가 명시적인 높이를 가져야 함
|
||||
2. **overflow 제어**: 중간 컨테이너는 `overflow-hidden`으로 자식 제한
|
||||
3. **Flex 명시**: `display: flex` + `flexDirection: column` 명시
|
||||
4. **minHeight: 0**: 스크롤 영역의 Flex item은 반드시 `minHeight: 0` 적용
|
||||
5. **인라인 스타일**: Tailwind가 작동하지 않으면 인라인 스타일 사용
|
||||
|
||||
### 디버깅 순서
|
||||
|
||||
1. 🎨 **컬러 테두리** 추가로 시각적 확인
|
||||
2. 🔍 **개발자 도구**로 Computed Style 확인
|
||||
3. 🔗 **부모 체인** 역순으로 추적
|
||||
4. ✅ **체크리스트** 항목 확인
|
||||
5. 🔧 **패턴 적용** 및 테스트
|
||||
|
||||
### 최종 구조
|
||||
|
||||
```
|
||||
페이지 (fixed inset-0)
|
||||
└─ flex flex-col h-full
|
||||
├─ 헤더 (고정)
|
||||
└─ 컨테이너 (flex-1 overflow-hidden)
|
||||
└─ 에디터 (height: 100%, overflow: hidden)
|
||||
└─ flex row
|
||||
└─ 패널 (display: flex, flexDirection: column)
|
||||
└─ 패널 내부 (height: 100%)
|
||||
├─ 헤더 (flexShrink: 0, height: 64px)
|
||||
└─ 스크롤 (flex: 1, minHeight: 0, overflowY: auto)
|
||||
```
|
||||
|
||||
## 10. 참고 자료
|
||||
|
||||
이 가이드는 다음 파일을 기반으로 작성되었습니다:
|
||||
|
||||
- [dataflow/page.tsx](<mdc:frontend/app/(main)/admin/dataflow/page.tsx>)
|
||||
- [FlowEditor.tsx](mdc:frontend/components/dataflow/node-editor/FlowEditor.tsx)
|
||||
- [PropertiesPanel.tsx](mdc:frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx)
|
||||
|
|
@ -68,7 +68,7 @@ export default function DataFlowPage() {
|
|||
</div>
|
||||
|
||||
{/* 플로우 에디터 */}
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<FlowEditor key={loadingFlowId || "new"} initialFlowId={loadingFlowId} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* 노드 기반 플로우 에디터 메인 컴포넌트
|
||||
*/
|
||||
|
||||
import { useCallback, useRef, useEffect, useState } from "react";
|
||||
import { useCallback, useRef, useEffect, useState, useMemo } from "react";
|
||||
import ReactFlow, { Background, Controls, MiniMap, Panel, ReactFlowProvider, useReactFlow } from "reactflow";
|
||||
import "reactflow/dist/style.css";
|
||||
|
||||
|
|
@ -14,6 +14,7 @@ import { NodePalette } from "./sidebar/NodePalette";
|
|||
import { LeftUnifiedToolbar, ToolbarButton } from "@/components/screen/toolbar/LeftUnifiedToolbar";
|
||||
import { Boxes, Settings } from "lucide-react";
|
||||
import { PropertiesPanel } from "./panels/PropertiesPanel";
|
||||
import { ValidationNotification } from "./ValidationNotification";
|
||||
import { FlowToolbar } from "./FlowToolbar";
|
||||
import { TableSourceNode } from "./nodes/TableSourceNode";
|
||||
import { ExternalDBSourceNode } from "./nodes/ExternalDBSourceNode";
|
||||
|
|
@ -27,6 +28,8 @@ import { DataTransformNode } from "./nodes/DataTransformNode";
|
|||
import { RestAPISourceNode } from "./nodes/RestAPISourceNode";
|
||||
import { CommentNode } from "./nodes/CommentNode";
|
||||
import { LogNode } from "./nodes/LogNode";
|
||||
import { validateFlow } from "@/lib/utils/flowValidation";
|
||||
import type { FlowValidation } from "@/lib/utils/flowValidation";
|
||||
|
||||
// 노드 타입들
|
||||
const nodeTypes = {
|
||||
|
|
@ -77,7 +80,7 @@ const flowToolbarButtons: ToolbarButton[] = [
|
|||
|
||||
function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
|
||||
const reactFlowWrapper = useRef<HTMLDivElement>(null);
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
const { screenToFlowPosition, setCenter } = useReactFlow();
|
||||
|
||||
// 패널 표시 상태
|
||||
const [showNodesPanel, setShowNodesPanel] = useState(true);
|
||||
|
|
@ -91,8 +94,6 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
|
|||
onConnect,
|
||||
onNodeDragStart,
|
||||
addNode,
|
||||
showPropertiesPanel,
|
||||
setShowPropertiesPanel,
|
||||
selectNodes,
|
||||
selectedNodes,
|
||||
removeNodes,
|
||||
|
|
@ -101,6 +102,26 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
|
|||
loadFlow,
|
||||
} = useFlowEditorStore();
|
||||
|
||||
// 🆕 실시간 플로우 검증
|
||||
const validations = useMemo<FlowValidation[]>(() => {
|
||||
return validateFlow(nodes, edges);
|
||||
}, [nodes, edges]);
|
||||
|
||||
// 🆕 노드 클릭 핸들러 (검증 패널에서 사용)
|
||||
const handleValidationNodeClick = useCallback(
|
||||
(nodeId: string) => {
|
||||
const node = nodes.find((n) => n.id === nodeId);
|
||||
if (node) {
|
||||
selectNodes([nodeId]);
|
||||
setCenter(node.position.x + 125, node.position.y + 50, {
|
||||
zoom: 1.5,
|
||||
duration: 500,
|
||||
});
|
||||
}
|
||||
},
|
||||
[nodes, selectNodes, setCenter],
|
||||
);
|
||||
|
||||
// 속성 패널 상태 동기화
|
||||
useEffect(() => {
|
||||
if (selectedNodes.length > 0 && !showPropertiesPanelLocal) {
|
||||
|
|
@ -245,7 +266,7 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
|
|||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full">
|
||||
<div className="flex h-full w-full" style={{ height: "100%", overflow: "hidden" }}>
|
||||
{/* 좌측 통합 툴바 */}
|
||||
<LeftUnifiedToolbar
|
||||
buttons={flowToolbarButtons}
|
||||
|
|
@ -258,7 +279,6 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
|
|||
setShowNodesPanel(!showNodesPanel);
|
||||
} else if (panelId === "properties") {
|
||||
setShowPropertiesPanelLocal(!showPropertiesPanelLocal);
|
||||
setShowPropertiesPanel(!showPropertiesPanelLocal);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
@ -273,8 +293,8 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
|
|||
{/* 중앙 캔버스 */}
|
||||
<div className="relative flex-1" ref={reactFlowWrapper} onKeyDown={onKeyDown} tabIndex={0}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodes={nodes as any}
|
||||
edges={edges as any}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
|
|
@ -305,17 +325,28 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
|
|||
|
||||
{/* 상단 툴바 */}
|
||||
<Panel position="top-center" className="pointer-events-auto">
|
||||
<FlowToolbar />
|
||||
<FlowToolbar validations={validations} />
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
{/* 우측 속성 패널 */}
|
||||
{showPropertiesPanelLocal && selectedNodes.length > 0 && (
|
||||
<div className="h-full w-[350px] border-l bg-white">
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "350px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
className="border-l bg-white"
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 검증 알림 (우측 상단 플로팅) */}
|
||||
<ValidationNotification validations={validations} onNodeClick={handleValidationNodeClick} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,18 +4,27 @@
|
|||
* 플로우 에디터 상단 툴바
|
||||
*/
|
||||
|
||||
import { Save, FileCheck, Undo2, Redo2, ZoomIn, ZoomOut, Download, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Save, Undo2, Redo2, ZoomIn, ZoomOut, 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 { SaveConfirmDialog } from "./dialogs/SaveConfirmDialog";
|
||||
import { validateFlow, summarizeValidations } from "@/lib/utils/flowValidation";
|
||||
import type { FlowValidation } from "@/lib/utils/flowValidation";
|
||||
|
||||
export function FlowToolbar() {
|
||||
interface FlowToolbarProps {
|
||||
validations?: FlowValidation[];
|
||||
}
|
||||
|
||||
export function FlowToolbar({ validations = [] }: FlowToolbarProps) {
|
||||
const { zoomIn, zoomOut, fitView } = useReactFlow();
|
||||
const {
|
||||
flowName,
|
||||
setFlowName,
|
||||
validateFlow,
|
||||
nodes,
|
||||
edges,
|
||||
saveFlow,
|
||||
exportFlow,
|
||||
isSaving,
|
||||
|
|
@ -27,22 +36,31 @@ export function FlowToolbar() {
|
|||
canRedo,
|
||||
} = useFlowEditorStore();
|
||||
|
||||
const handleValidate = () => {
|
||||
const result = validateFlow();
|
||||
if (result.valid) {
|
||||
alert("✅ 검증 성공! 오류가 없습니다.");
|
||||
} else {
|
||||
alert(`❌ 검증 실패\n\n${result.errors.map((e) => `- ${e.message}`).join("\n")}`);
|
||||
}
|
||||
};
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
// 검증 수행
|
||||
const currentValidations = validations.length > 0 ? validations : validateFlow(nodes, edges);
|
||||
const summary = summarizeValidations(currentValidations);
|
||||
|
||||
// 오류나 경고가 있으면 다이얼로그 표시
|
||||
if (currentValidations.length > 0) {
|
||||
setShowSaveDialog(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 문제 없으면 바로 저장
|
||||
await performSave();
|
||||
};
|
||||
|
||||
const performSave = async () => {
|
||||
const result = await saveFlow();
|
||||
if (result.success) {
|
||||
alert(`✅ ${result.message}\nFlow ID: ${result.flowId}`);
|
||||
} else {
|
||||
alert(`❌ 저장 실패\n\n${result.message}`);
|
||||
}
|
||||
setShowSaveDialog(false);
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
|
|
@ -70,74 +88,76 @@ export function FlowToolbar() {
|
|||
};
|
||||
|
||||
return (
|
||||
<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="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="실행 취소 (Ctrl+Z)" disabled={!canUndo()} onClick={undo}>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" title="다시 실행 (Ctrl+Y)" disabled={!canRedo()} onClick={redo}>
|
||||
<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={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>
|
||||
|
||||
{/* 저장 확인 다이얼로그 */}
|
||||
<SaveConfirmDialog
|
||||
open={showSaveDialog}
|
||||
validations={validations.length > 0 ? validations : validateFlow(nodes, edges)}
|
||||
onConfirm={performSave}
|
||||
onCancel={() => setShowSaveDialog(false)}
|
||||
/>
|
||||
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
|
||||
{/* 실행 취소/다시 실행 */}
|
||||
<Button variant="ghost" size="sm" title="실행 취소 (Ctrl+Z)" disabled={!canUndo()} onClick={undo}>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" title="다시 실행 (Ctrl+Y)" disabled={!canRedo()} onClick={redo}>
|
||||
<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={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>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,206 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 플로우 검증 결과 알림 (우측 상단 플로팅)
|
||||
*/
|
||||
|
||||
import { memo, useState } from "react";
|
||||
import { AlertTriangle, AlertCircle, Info, X, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import type { FlowValidation } from "@/lib/utils/flowValidation";
|
||||
import { summarizeValidations } from "@/lib/utils/flowValidation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ValidationNotificationProps {
|
||||
validations: FlowValidation[];
|
||||
onNodeClick?: (nodeId: string) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const ValidationNotification = memo(
|
||||
({ validations, onNodeClick, onClose }: ValidationNotificationProps) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const summary = summarizeValidations(validations);
|
||||
|
||||
if (validations.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getTypeLabel = (type: string): string => {
|
||||
const labels: Record<string, string> = {
|
||||
"parallel-conflict": "병렬 실행 충돌",
|
||||
"missing-where": "WHERE 조건 누락",
|
||||
"circular-reference": "순환 참조",
|
||||
"data-source-mismatch": "데이터 소스 불일치",
|
||||
"parallel-table-access": "병렬 테이블 접근",
|
||||
};
|
||||
return labels[type] || type;
|
||||
};
|
||||
|
||||
// 타입별로 그룹화
|
||||
const groupedValidations = validations.reduce((acc, validation) => {
|
||||
if (!acc[validation.type]) {
|
||||
acc[validation.type] = [];
|
||||
}
|
||||
acc[validation.type].push(validation);
|
||||
return acc;
|
||||
}, {} as Record<string, FlowValidation[]>);
|
||||
|
||||
return (
|
||||
<div className="fixed right-4 top-4 z-50 w-80 animate-in slide-in-from-right-5 duration-300">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border-2 bg-white shadow-2xl",
|
||||
summary.hasBlockingIssues
|
||||
? "border-red-500"
|
||||
: summary.warningCount > 0
|
||||
? "border-yellow-500"
|
||||
: "border-blue-500"
|
||||
)}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center justify-between p-3",
|
||||
summary.hasBlockingIssues
|
||||
? "bg-red-50"
|
||||
: summary.warningCount > 0
|
||||
? "bg-yellow-50"
|
||||
: "bg-blue-50"
|
||||
)}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{summary.hasBlockingIssues ? (
|
||||
<AlertCircle className="h-5 w-5 text-red-600" />
|
||||
) : summary.warningCount > 0 ? (
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-600" />
|
||||
) : (
|
||||
<Info className="h-5 w-5 text-blue-600" />
|
||||
)}
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
플로우 검증
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{summary.errorCount > 0 && (
|
||||
<Badge variant="destructive" className="h-5 text-[10px]">
|
||||
{summary.errorCount}
|
||||
</Badge>
|
||||
)}
|
||||
{summary.warningCount > 0 && (
|
||||
<Badge className="h-5 bg-yellow-500 text-[10px] hover:bg-yellow-600">
|
||||
{summary.warningCount}
|
||||
</Badge>
|
||||
)}
|
||||
{summary.infoCount > 0 && (
|
||||
<Badge variant="secondary" className="h-5 text-[10px]">
|
||||
{summary.infoCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
className="h-6 w-6 p-0 hover:bg-white/50"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 확장된 내용 */}
|
||||
{isExpanded && (
|
||||
<div className="max-h-[60vh] overflow-y-auto border-t">
|
||||
<div className="p-2 space-y-2">
|
||||
{Object.entries(groupedValidations).map(([type, typeValidations]) => {
|
||||
const firstValidation = typeValidations[0];
|
||||
const Icon =
|
||||
firstValidation.severity === "error"
|
||||
? AlertCircle
|
||||
: firstValidation.severity === "warning"
|
||||
? AlertTriangle
|
||||
: Info;
|
||||
|
||||
return (
|
||||
<div key={type}>
|
||||
{/* 타입 헤더 */}
|
||||
<div
|
||||
className={cn(
|
||||
"mb-1 flex items-center gap-2 rounded-md px-2 py-1 text-xs font-medium",
|
||||
firstValidation.severity === "error"
|
||||
? "bg-red-100 text-red-700"
|
||||
: firstValidation.severity === "warning"
|
||||
? "bg-yellow-100 text-yellow-700"
|
||||
: "bg-blue-100 text-blue-700"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
{getTypeLabel(type)}
|
||||
<span className="ml-auto">
|
||||
{typeValidations.length}개
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 검증 항목들 */}
|
||||
<div className="space-y-1 pl-5">
|
||||
{typeValidations.map((validation, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="group cursor-pointer rounded-md border border-gray-200 bg-gray-50 p-2 text-xs transition-all hover:border-gray-300 hover:bg-white hover:shadow-sm"
|
||||
onClick={() => onNodeClick?.(validation.nodeId)}
|
||||
>
|
||||
<p className="text-gray-700 leading-relaxed">
|
||||
{validation.message}
|
||||
</p>
|
||||
{validation.affectedNodes && validation.affectedNodes.length > 1 && (
|
||||
<div className="mt-1 text-[10px] text-gray-500">
|
||||
영향받는 노드: {validation.affectedNodes.length}개
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1 text-[10px] text-blue-600 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
클릭하여 노드 보기 →
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 요약 메시지 (닫혀있을 때) */}
|
||||
{!isExpanded && (
|
||||
<div className="border-t px-3 py-2">
|
||||
<p className="text-xs text-gray-600">
|
||||
{summary.hasBlockingIssues
|
||||
? "⛔ 오류를 해결해야 저장할 수 있습니다"
|
||||
: summary.warningCount > 0
|
||||
? "⚠️ 경고 사항을 확인하세요"
|
||||
: "ℹ️ 정보를 확인하세요"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ValidationNotification.displayName = "ValidationNotification";
|
||||
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 저장 확인 다이얼로그
|
||||
*
|
||||
* 경고가 있을 때 저장 전 확인을 받습니다
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { AlertTriangle, AlertCircle, Info } from "lucide-react";
|
||||
import type { FlowValidation } from "@/lib/utils/flowValidation";
|
||||
import { summarizeValidations } from "@/lib/utils/flowValidation";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
interface SaveConfirmDialogProps {
|
||||
open: boolean;
|
||||
validations: FlowValidation[];
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const SaveConfirmDialog = memo(
|
||||
({ open, validations, onConfirm, onCancel }: SaveConfirmDialogProps) => {
|
||||
const summary = summarizeValidations(validations);
|
||||
|
||||
// 오류가 있으면 저장 불가
|
||||
if (summary.hasBlockingIssues) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onCancel}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||
<AlertCircle className="h-5 w-5 text-red-500" />
|
||||
저장할 수 없습니다
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
오류를 수정한 후 다시 시도하세요
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
오류 {summary.errorCount}
|
||||
</Badge>
|
||||
{summary.warningCount > 0 && (
|
||||
<Badge className="gap-1 bg-yellow-500 hover:bg-yellow-600">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
경고 {summary.warningCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollArea className="max-h-[300px]">
|
||||
<div className="space-y-2">
|
||||
{validations
|
||||
.filter((v) => v.severity === "error")
|
||||
.map((validation, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-lg border-2 border-red-200 bg-red-50 p-3"
|
||||
>
|
||||
<div className="mb-1 text-xs font-medium text-red-600">
|
||||
{validation.type}
|
||||
</div>
|
||||
<div className="text-sm text-red-800">
|
||||
{validation.message}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<p className="text-xs text-gray-500">
|
||||
위의 오류를 먼저 해결해주세요. 경고는 저장 후에도 확인할 수
|
||||
있습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
확인
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// 경고만 있는 경우 - 저장 가능하지만 확인 필요
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onCancel}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-500" />
|
||||
경고가 있습니다
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
플로우에 {summary.warningCount + summary.infoCount}개의 경고가
|
||||
발견되었습니다
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{summary.warningCount > 0 && (
|
||||
<Badge className="gap-1 bg-yellow-500 hover:bg-yellow-600">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
경고 {summary.warningCount}
|
||||
</Badge>
|
||||
)}
|
||||
{summary.infoCount > 0 && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Info className="h-3 w-3" />
|
||||
정보 {summary.infoCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollArea className="max-h-[300px]">
|
||||
<div className="space-y-2">
|
||||
{validations
|
||||
.filter((v) => v.severity === "warning")
|
||||
.map((validation, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-lg border-2 border-yellow-200 bg-yellow-50 p-3"
|
||||
>
|
||||
<div className="mb-1 text-xs font-medium text-yellow-600">
|
||||
{validation.type}
|
||||
</div>
|
||||
<div className="text-sm text-yellow-800">
|
||||
{validation.message}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{validations
|
||||
.filter((v) => v.severity === "info")
|
||||
.map((validation, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-lg border-2 border-blue-200 bg-blue-50 p-3"
|
||||
>
|
||||
<div className="mb-1 text-xs font-medium text-blue-600">
|
||||
{validation.type}
|
||||
</div>
|
||||
<div className="text-sm text-blue-800">
|
||||
{validation.message}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="rounded-lg bg-gray-50 p-3">
|
||||
<p className="text-xs text-gray-600">
|
||||
⚠️ 이 경고들은 플로우의 동작에 영향을 줄 수 있습니다.
|
||||
<br />
|
||||
그래도 저장하시겠습니까?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
className="h-8 flex-1 bg-yellow-500 text-xs hover:bg-yellow-600 sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
경고 무시하고 저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
SaveConfirmDialog.displayName = "SaveConfirmDialog";
|
||||
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 검증 기능이 포함된 노드 래퍼
|
||||
*
|
||||
* 모든 노드에 경고/에러 아이콘을 표시하는 공통 래퍼
|
||||
*/
|
||||
|
||||
import { memo, ReactNode } from "react";
|
||||
import { AlertTriangle, AlertCircle, Info } from "lucide-react";
|
||||
import type { FlowValidation } from "@/lib/utils/flowValidation";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
interface NodeWithValidationProps {
|
||||
nodeId: string;
|
||||
validations: FlowValidation[];
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export const NodeWithValidation = memo(
|
||||
({ nodeId, validations, children, onClick }: NodeWithValidationProps) => {
|
||||
// 이 노드와 관련된 검증 결과 필터링
|
||||
const nodeValidations = validations.filter(
|
||||
(v) => v.nodeId === nodeId || v.affectedNodes?.includes(nodeId)
|
||||
);
|
||||
|
||||
// 가장 높은 심각도 결정
|
||||
const hasError = nodeValidations.some((v) => v.severity === "error");
|
||||
const hasWarning = nodeValidations.some((v) => v.severity === "warning");
|
||||
const hasInfo = nodeValidations.some((v) => v.severity === "info");
|
||||
|
||||
if (nodeValidations.length === 0) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// 심각도별 아이콘 및 색상
|
||||
const getIconAndColor = () => {
|
||||
if (hasError) {
|
||||
return {
|
||||
Icon: AlertCircle,
|
||||
bgColor: "bg-red-500",
|
||||
textColor: "text-red-700",
|
||||
borderColor: "border-red-500",
|
||||
hoverBgColor: "hover:bg-red-600",
|
||||
};
|
||||
}
|
||||
if (hasWarning) {
|
||||
return {
|
||||
Icon: AlertTriangle,
|
||||
bgColor: "bg-yellow-500",
|
||||
textColor: "text-yellow-700",
|
||||
borderColor: "border-yellow-500",
|
||||
hoverBgColor: "hover:bg-yellow-600",
|
||||
};
|
||||
}
|
||||
return {
|
||||
Icon: Info,
|
||||
bgColor: "bg-blue-500",
|
||||
textColor: "text-blue-700",
|
||||
borderColor: "border-blue-500",
|
||||
hoverBgColor: "hover:bg-blue-600",
|
||||
};
|
||||
};
|
||||
|
||||
const { Icon, bgColor, textColor, borderColor, hoverBgColor } =
|
||||
getIconAndColor();
|
||||
|
||||
return (
|
||||
<div className="relative" onClick={onClick}>
|
||||
{children}
|
||||
|
||||
{/* 경고 배지 */}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={`absolute -right-2 -top-2 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full ${bgColor} ${hoverBgColor} shadow-lg transition-all hover:scale-110`}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 text-white" />
|
||||
{nodeValidations.length > 1 && (
|
||||
<span className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-white text-[10px] font-bold shadow-sm">
|
||||
{nodeValidations.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
className="max-w-xs border-0 p-0"
|
||||
>
|
||||
<div className={`rounded-lg border-2 ${borderColor} bg-white p-3 shadow-lg`}>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Icon className={`h-4 w-4 ${textColor}`} />
|
||||
<span className="font-semibold text-gray-900">
|
||||
{hasError
|
||||
? "오류"
|
||||
: hasWarning
|
||||
? "경고"
|
||||
: "정보"} ({nodeValidations.length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{nodeValidations.map((validation, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded border-l-2 border-gray-300 bg-gray-50 p-2"
|
||||
>
|
||||
<div className="mb-1 text-xs font-medium text-gray-500">
|
||||
{validation.type}
|
||||
</div>
|
||||
<div className="text-sm text-gray-700">
|
||||
{validation.message}
|
||||
</div>
|
||||
{validation.affectedNodes && validation.affectedNodes.length > 1 && (
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
영향받는 노드: {validation.affectedNodes.length}개
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
NodeWithValidation.displayName = "NodeWithValidation";
|
||||
|
||||
|
|
@ -28,9 +28,23 @@ export function PropertiesPanel() {
|
|||
const selectedNode = selectedNodes.length === 1 ? nodes.find((n) => n.id === selectedNodes[0]) : null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex h-16 shrink-0 items-center justify-between border-b bg-white p-4">
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
height: '64px'
|
||||
}}
|
||||
className="flex items-center justify-between border-b bg-white p-4"
|
||||
>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">속성</h3>
|
||||
{selectedNode && (
|
||||
|
|
@ -44,11 +58,11 @@ export function PropertiesPanel() {
|
|||
|
||||
{/* 내용 - 스크롤 가능 영역 */}
|
||||
<div
|
||||
className="flex-1 overflow-y-scroll"
|
||||
style={{
|
||||
maxHeight: 'calc(100vh - 64px)',
|
||||
overflowY: 'scroll',
|
||||
WebkitOverflowScrolling: 'touch'
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden'
|
||||
}}
|
||||
>
|
||||
{selectedNodes.length === 0 ? (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,245 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 플로우 검증 결과 패널
|
||||
*
|
||||
* 모든 검증 결과를 사이드바에 표시
|
||||
*/
|
||||
|
||||
import { memo, useMemo } from "react";
|
||||
import { AlertTriangle, AlertCircle, Info, ChevronDown, ChevronUp, X } from "lucide-react";
|
||||
import type { FlowValidation } from "@/lib/utils/flowValidation";
|
||||
import { summarizeValidations } from "@/lib/utils/flowValidation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
|
||||
interface ValidationPanelProps {
|
||||
validations: FlowValidation[];
|
||||
onNodeClick?: (nodeId: string) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const ValidationPanel = memo(
|
||||
({ validations, onNodeClick, onClose }: ValidationPanelProps) => {
|
||||
const [expandedTypes, setExpandedTypes] = useState<Set<string>>(new Set());
|
||||
|
||||
const summary = useMemo(
|
||||
() => summarizeValidations(validations),
|
||||
[validations]
|
||||
);
|
||||
|
||||
// 타입별로 그룹화
|
||||
const groupedValidations = useMemo(() => {
|
||||
const groups = new Map<string, FlowValidation[]>();
|
||||
for (const validation of validations) {
|
||||
if (!groups.has(validation.type)) {
|
||||
groups.set(validation.type, []);
|
||||
}
|
||||
groups.get(validation.type)!.push(validation);
|
||||
}
|
||||
return Array.from(groups.entries()).sort((a, b) => {
|
||||
// 심각도 순으로 정렬
|
||||
const severityOrder = { error: 0, warning: 1, info: 2 };
|
||||
const aSeverity = Math.min(
|
||||
...a[1].map((v) => severityOrder[v.severity])
|
||||
);
|
||||
const bSeverity = Math.min(
|
||||
...b[1].map((v) => severityOrder[v.severity])
|
||||
);
|
||||
return aSeverity - bSeverity;
|
||||
});
|
||||
}, [validations]);
|
||||
|
||||
const toggleExpanded = (type: string) => {
|
||||
setExpandedTypes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(type)) {
|
||||
next.delete(type);
|
||||
} else {
|
||||
next.add(type);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const getTypeLabel = (type: string): string => {
|
||||
const labels: Record<string, string> = {
|
||||
"parallel-conflict": "병렬 실행 충돌",
|
||||
"missing-where": "WHERE 조건 누락",
|
||||
"circular-reference": "순환 참조",
|
||||
"data-source-mismatch": "데이터 소스 불일치",
|
||||
"parallel-table-access": "병렬 테이블 접근",
|
||||
};
|
||||
return labels[type] || type;
|
||||
};
|
||||
|
||||
if (validations.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full flex-col border-l border-gray-200 bg-white">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900">검증 결과</h3>
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center p-8 text-center">
|
||||
<div>
|
||||
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
||||
<Info className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-900">문제 없음</p>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
플로우에 문제가 발견되지 않았습니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col border-l border-gray-200 bg-white">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between border-b border-gray-200 p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900">검증 결과</h3>
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 요약 */}
|
||||
<div className="border-b border-gray-200 bg-gray-50 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{summary.errorCount > 0 && (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
오류 {summary.errorCount}
|
||||
</Badge>
|
||||
)}
|
||||
{summary.warningCount > 0 && (
|
||||
<Badge className="gap-1 bg-yellow-500 hover:bg-yellow-600">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
경고 {summary.warningCount}
|
||||
</Badge>
|
||||
)}
|
||||
{summary.infoCount > 0 && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Info className="h-3 w-3" />
|
||||
정보 {summary.infoCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{summary.hasBlockingIssues && (
|
||||
<p className="mt-2 text-xs text-red-600">
|
||||
⛔ 오류를 해결해야 저장할 수 있습니다
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 검증 결과 목록 */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2">
|
||||
{groupedValidations.map(([type, typeValidations]) => {
|
||||
const isExpanded = expandedTypes.has(type);
|
||||
const firstValidation = typeValidations[0];
|
||||
const Icon =
|
||||
firstValidation.severity === "error"
|
||||
? AlertCircle
|
||||
: firstValidation.severity === "warning"
|
||||
? AlertTriangle
|
||||
: Info;
|
||||
|
||||
return (
|
||||
<div key={type} className="mb-2">
|
||||
{/* 그룹 헤더 */}
|
||||
<button
|
||||
onClick={() => toggleExpanded(type)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-lg p-3 text-left transition-colors",
|
||||
firstValidation.severity === "error"
|
||||
? "bg-red-50 hover:bg-red-100"
|
||||
: firstValidation.severity === "warning"
|
||||
? "bg-yellow-50 hover:bg-yellow-100"
|
||||
: "bg-blue-50 hover:bg-blue-100"
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0",
|
||||
firstValidation.severity === "error"
|
||||
? "text-red-600"
|
||||
: firstValidation.severity === "warning"
|
||||
? "text-yellow-600"
|
||||
: "text-blue-600"
|
||||
)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{getTypeLabel(type)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{typeValidations.length}개 발견
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 상세 내용 */}
|
||||
{isExpanded && (
|
||||
<div className="mt-1 space-y-1 pl-6 pr-2">
|
||||
{typeValidations.map((validation, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="group cursor-pointer rounded-lg border border-gray-200 bg-white p-3 transition-all hover:border-gray-300 hover:shadow-sm"
|
||||
onClick={() => onNodeClick?.(validation.nodeId)}
|
||||
>
|
||||
<div className="text-xs text-gray-700">
|
||||
{validation.message}
|
||||
</div>
|
||||
{validation.affectedNodes && validation.affectedNodes.length > 1 && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
영향받는 노드: {validation.affectedNodes.length}개
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 text-[10px] text-gray-400 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
클릭하여 노드 보기
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ValidationPanel.displayName = "ValidationPanel";
|
||||
|
||||
|
|
@ -9,7 +9,6 @@ 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";
|
||||
|
|
@ -214,7 +213,7 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
|||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div>
|
||||
<div className="space-y-4 p-4 pb-8">
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
|
|
@ -420,6 +419,6 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ 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";
|
||||
|
|
@ -358,7 +357,7 @@ export function DataTransformProperties({ nodeId, data }: DataTransformPropertie
|
|||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div>
|
||||
<div className="space-y-4 p-4 pb-8">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-md bg-indigo-50 p-2">
|
||||
|
|
@ -454,6 +453,6 @@ export function DataTransformProperties({ nodeId, data }: DataTransformPropertie
|
|||
</div>
|
||||
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { Plus, Trash2, AlertTriangle, Database, Globe, Link2, Check, ChevronsUpD
|
|||
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 { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
|
|
@ -216,7 +215,7 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
|
|||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div>
|
||||
<div className="space-y-4 p-4 pb-8">
|
||||
{/* 경고 */}
|
||||
<div className="rounded-lg border-2 border-red-200 bg-red-50 p-4">
|
||||
|
|
@ -714,6 +713,6 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
|
|||
<div className="rounded bg-red-50 p-3 text-xs text-red-700">💡 실행 전 WHERE 조건을 꼭 확인하세요.</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight, Database, Globe, Link2
|
|||
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";
|
||||
|
|
@ -49,9 +48,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
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);
|
||||
|
||||
// 내부 DB 테이블 관련 상태
|
||||
const [tables, setTables] = useState<TableOption[]>([]);
|
||||
|
|
@ -92,9 +88,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
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]);
|
||||
|
||||
// 내부 DB 테이블 목록 로딩
|
||||
|
|
@ -439,11 +432,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
displayName: selectedTable.label,
|
||||
targetTable: selectedTable.tableName,
|
||||
fieldMappings,
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
ignoreErrors,
|
||||
ignoreDuplicates,
|
||||
},
|
||||
});
|
||||
|
||||
setTablesOpen(false);
|
||||
|
|
@ -517,39 +505,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
updateNode(nodeId, { fieldMappings: newMappings });
|
||||
};
|
||||
|
||||
const handleBatchSizeChange = (newBatchSize: string) => {
|
||||
setBatchSize(newBatchSize);
|
||||
updateNode(nodeId, {
|
||||
options: {
|
||||
batchSize: newBatchSize ? parseInt(newBatchSize) : undefined,
|
||||
ignoreErrors,
|
||||
ignoreDuplicates,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleIgnoreErrorsChange = (checked: boolean) => {
|
||||
setIgnoreErrors(checked);
|
||||
updateNode(nodeId, {
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
ignoreErrors: checked,
|
||||
ignoreDuplicates,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleIgnoreDuplicatesChange = (checked: boolean) => {
|
||||
setIgnoreDuplicates(checked);
|
||||
updateNode(nodeId, {
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
ignoreErrors,
|
||||
ignoreDuplicates: checked,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const selectedTableLabel = tables.find((t) => t.tableName === targetTable)?.label || targetTable;
|
||||
|
||||
// 🔥 타겟 타입 변경 핸들러
|
||||
|
|
@ -575,17 +530,12 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
}
|
||||
|
||||
updates.fieldMappings = fieldMappings;
|
||||
updates.options = {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
ignoreErrors,
|
||||
ignoreDuplicates,
|
||||
};
|
||||
|
||||
updateNode(nodeId, updates);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div>
|
||||
<div className="space-y-4 p-4 pb-8">
|
||||
{/* 🔥 타겟 타입 선택 */}
|
||||
<div>
|
||||
|
|
@ -753,11 +703,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
externalDbType: selectedConnection?.db_type,
|
||||
externalTargetTable: undefined,
|
||||
fieldMappings,
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
ignoreErrors,
|
||||
ignoreDuplicates,
|
||||
},
|
||||
});
|
||||
}}
|
||||
disabled={externalConnectionsLoading}
|
||||
|
|
@ -797,11 +742,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
externalConnectionId: selectedExternalConnectionId,
|
||||
externalTargetTable: value,
|
||||
fieldMappings,
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
ignoreErrors,
|
||||
ignoreDuplicates,
|
||||
},
|
||||
});
|
||||
}}
|
||||
disabled={externalTablesLoading}
|
||||
|
|
@ -1240,51 +1180,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
</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) => handleBatchSizeChange(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="한 번에 처리할 레코드 수"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="ignoreDuplicates"
|
||||
checked={ignoreDuplicates}
|
||||
onCheckedChange={(checked) => handleIgnoreDuplicatesChange(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) => handleIgnoreErrorsChange(checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="ignoreErrors" className="text-xs font-normal">
|
||||
오류 발생 시 계속 진행
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 저장 버튼 */}
|
||||
|
||||
{/* 안내 */}
|
||||
<div className="rounded bg-green-50 p-3 text-xs text-green-700">
|
||||
✅ 테이블과 필드는 실제 데이터베이스에서 조회됩니다.
|
||||
|
|
@ -1292,6 +1187,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
💡 소스 필드가 없으면 정적 값이 사용됩니다.
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { Plus, Trash2, Search } 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 { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
|
|
@ -262,7 +261,7 @@ export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPrope
|
|||
const selectedTableLabel = tables.find((t) => t.tableName === referenceTable)?.label || referenceTable;
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div>
|
||||
<div className="space-y-4 p-4 pb-8">
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
|
|
@ -633,6 +632,6 @@ export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPrope
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { Check, ChevronsUpDown, Table, FileText } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight, Database, Globe, Link2
|
|||
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";
|
||||
|
|
@ -65,8 +64,6 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
|||
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);
|
||||
|
||||
// 내부 DB 테이블 관련 상태
|
||||
const [tables, setTables] = useState<TableOption[]>([]);
|
||||
|
|
@ -108,8 +105,6 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
|||
setTargetTable(data.targetTable);
|
||||
setFieldMappings(data.fieldMappings || []);
|
||||
setWhereConditions(data.whereConditions || []);
|
||||
setBatchSize(data.options?.batchSize?.toString() || "");
|
||||
setIgnoreErrors(data.options?.ignoreErrors || false);
|
||||
}, [data]);
|
||||
|
||||
// 내부 DB 테이블 목록 로딩
|
||||
|
|
@ -368,10 +363,6 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
|||
targetTable: newTableName,
|
||||
fieldMappings,
|
||||
whereConditions,
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
ignoreErrors,
|
||||
},
|
||||
});
|
||||
|
||||
setTablesOpen(false);
|
||||
|
|
@ -511,31 +502,10 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
|||
updateNode(nodeId, { whereConditions: newConditions });
|
||||
};
|
||||
|
||||
const handleBatchSizeChange = (newBatchSize: string) => {
|
||||
setBatchSize(newBatchSize);
|
||||
updateNode(nodeId, {
|
||||
options: {
|
||||
batchSize: newBatchSize ? parseInt(newBatchSize) : undefined,
|
||||
ignoreErrors,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleIgnoreErrorsChange = (checked: boolean) => {
|
||||
setIgnoreErrors(checked);
|
||||
updateNode(nodeId, {
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
ignoreErrors: checked,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const selectedTableLabel = tables.find((t) => t.tableName === targetTable)?.label || targetTable;
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4 pb-8">
|
||||
<div className="space-y-4 p-4 pb-8">
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
||||
|
|
@ -1268,38 +1238,6 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
|||
</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) => handleBatchSizeChange(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="예: 100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="ignoreErrors"
|
||||
checked={ignoreErrors}
|
||||
onCheckedChange={(checked) => handleIgnoreErrorsChange(checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="ignoreErrors" className="cursor-pointer text-xs font-normal">
|
||||
오류 무시
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight, Database, Globe, Link2
|
|||
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";
|
||||
|
|
@ -51,8 +50,6 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
|||
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);
|
||||
|
||||
// 🔥 외부 DB 관련 상태
|
||||
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
|
||||
|
|
@ -95,8 +92,6 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
|||
setConflictKeys(data.conflictKeys || []);
|
||||
setConflictKeyLabels(data.conflictKeyLabels || []);
|
||||
setFieldMappings(data.fieldMappings || []);
|
||||
setBatchSize(data.options?.batchSize?.toString() || "");
|
||||
setUpdateOnConflict(data.options?.updateOnConflict ?? true);
|
||||
}, [data]);
|
||||
|
||||
// 🔥 내부 DB 테이블 목록 로딩
|
||||
|
|
@ -363,10 +358,6 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
|||
conflictKeys,
|
||||
conflictKeyLabels,
|
||||
fieldMappings,
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
updateOnConflict,
|
||||
},
|
||||
});
|
||||
|
||||
setTablesOpen(false);
|
||||
|
|
@ -460,30 +451,10 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
|||
updateNode(nodeId, { fieldMappings: newMappings });
|
||||
};
|
||||
|
||||
const handleBatchSizeChange = (newBatchSize: string) => {
|
||||
setBatchSize(newBatchSize);
|
||||
updateNode(nodeId, {
|
||||
options: {
|
||||
batchSize: newBatchSize ? parseInt(newBatchSize) : undefined,
|
||||
updateOnConflict,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateOnConflictChange = (checked: boolean) => {
|
||||
setUpdateOnConflict(checked);
|
||||
updateNode(nodeId, {
|
||||
options: {
|
||||
batchSize: batchSize ? parseInt(batchSize) : undefined,
|
||||
updateOnConflict: checked,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const selectedTableLabel = tables.find((t) => t.tableName === targetTable)?.label || targetTable;
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div>
|
||||
<div className="space-y-4 p-4 pb-8">
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
|
|
@ -1122,38 +1093,7 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
|||
)}
|
||||
</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>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -435,7 +435,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
{/* 컴포넌트 타입별 렌더링 */}
|
||||
<div
|
||||
ref={isFlowWidget ? contentRef : undefined}
|
||||
className={isFlowWidget ? "h-auto w-full" : "h-full w-full"}
|
||||
className="h-full w-full"
|
||||
>
|
||||
{/* 영역 타입 */}
|
||||
{type === "area" && renderArea(component, children)}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export interface ToolbarButton {
|
|||
|
||||
interface LeftUnifiedToolbarProps {
|
||||
buttons: ToolbarButton[];
|
||||
panelStates: Record<string, { isOpen: boolean }>;
|
||||
panelStates: Record<string, { isOpen: boolean; badge?: number }>;
|
||||
onTogglePanel: (panelId: string) => void;
|
||||
}
|
||||
|
||||
|
|
@ -28,6 +28,7 @@ export const LeftUnifiedToolbar: React.FC<LeftUnifiedToolbarProps> = ({ buttons,
|
|||
|
||||
const renderButton = (button: ToolbarButton) => {
|
||||
const isActive = panelStates[button.id]?.isOpen || false;
|
||||
const badge = panelStates[button.id]?.badge;
|
||||
|
||||
return (
|
||||
<Button
|
||||
|
|
@ -45,6 +46,11 @@ export const LeftUnifiedToolbar: React.FC<LeftUnifiedToolbarProps> = ({ buttons,
|
|||
<div className="relative">
|
||||
{button.icon}
|
||||
{isActive && <div className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-white" />}
|
||||
{badge !== undefined && badge > 0 && (
|
||||
<div className="absolute -top-2 -right-2 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white shadow-md">
|
||||
{badge > 99 ? "99+" : badge}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] font-medium">{button.label}</span>
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -36,7 +36,13 @@ interface FlowWidgetProps {
|
|||
onFlowRefresh?: () => void; // 새로고침 완료 콜백
|
||||
}
|
||||
|
||||
export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowRefreshKey, onFlowRefresh }: FlowWidgetProps) {
|
||||
export function FlowWidget({
|
||||
component,
|
||||
onStepClick,
|
||||
onSelectedDataChange,
|
||||
flowRefreshKey,
|
||||
onFlowRefresh,
|
||||
}: FlowWidgetProps) {
|
||||
// 🆕 전역 상태 관리
|
||||
const setSelectedStep = useFlowStepStore((state) => state.setSelectedStep);
|
||||
const resetFlow = useFlowStepStore((state) => state.resetFlow);
|
||||
|
|
@ -55,6 +61,10 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
|
|||
const [stepDataLoading, setStepDataLoading] = useState(false);
|
||||
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
||||
|
||||
// 🆕 스텝 데이터 페이지네이션 상태
|
||||
const [stepDataPage, setStepDataPage] = useState(1);
|
||||
const [stepDataPageSize] = useState(20);
|
||||
|
||||
// 오딧 로그 상태
|
||||
const [auditLogs, setAuditLogs] = useState<FlowAuditLog[]>([]);
|
||||
const [auditLogsLoading, setAuditLogsLoading] = useState(false);
|
||||
|
|
@ -73,7 +83,6 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
|
|||
// 🆕 플로우 컴포넌트 ID (버튼이 이 플로우를 참조할 때 사용)
|
||||
const flowComponentId = component.id;
|
||||
|
||||
|
||||
// 선택된 스텝의 데이터를 다시 로드하는 함수
|
||||
const refreshStepData = async () => {
|
||||
if (!flowId) return;
|
||||
|
|
@ -82,7 +91,7 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
|
|||
// 스텝 카운트는 항상 업데이트 (선택된 스텝 유무와 관계없이)
|
||||
const countsResponse = await getAllStepCounts(flowId);
|
||||
console.log("📊 스텝 카운트 API 응답:", countsResponse);
|
||||
|
||||
|
||||
if (countsResponse.success && countsResponse.data) {
|
||||
// Record 형태로 변환
|
||||
const countsMap: Record<number, number> = {};
|
||||
|
|
@ -90,10 +99,10 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
|
|||
countsResponse.data.forEach((item: any) => {
|
||||
countsMap[item.stepId] = item.count;
|
||||
});
|
||||
} else if (typeof countsResponse.data === 'object') {
|
||||
} else if (typeof countsResponse.data === "object") {
|
||||
Object.assign(countsMap, countsResponse.data);
|
||||
}
|
||||
|
||||
|
||||
console.log("✅ 스텝 카운트 업데이트:", countsMap);
|
||||
setStepCounts(countsMap);
|
||||
}
|
||||
|
|
@ -101,7 +110,7 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
|
|||
// 선택된 스텝이 있으면 해당 스텝의 데이터도 새로고침
|
||||
if (selectedStepId) {
|
||||
setStepDataLoading(true);
|
||||
|
||||
|
||||
const response = await getStepDataList(flowId, selectedStepId, 1, 100);
|
||||
|
||||
if (!response.success) {
|
||||
|
|
@ -224,6 +233,7 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
|
|||
setStepData([]);
|
||||
setStepDataColumns([]);
|
||||
setSelectedRows(new Set());
|
||||
setStepDataPage(1); // 🆕 페이지 리셋
|
||||
onSelectedDataChange?.([], null);
|
||||
|
||||
console.log("🔄 [FlowWidget] 단계 선택 해제:", { flowComponentId, stepId });
|
||||
|
|
@ -235,6 +245,7 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
|
|||
setSelectedStep(flowComponentId, stepId); // 🆕 전역 상태 업데이트
|
||||
setStepDataLoading(true);
|
||||
setSelectedRows(new Set());
|
||||
setStepDataPage(1); // 🆕 페이지 리셋
|
||||
onSelectedDataChange?.([], stepId);
|
||||
|
||||
console.log("✅ [FlowWidget] 단계 선택:", { flowComponentId, stepId, stepName });
|
||||
|
|
@ -272,7 +283,7 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
|
|||
newSelected.add(rowIndex);
|
||||
}
|
||||
setSelectedRows(newSelected);
|
||||
|
||||
|
||||
// 선택된 데이터를 상위로 전달
|
||||
const selectedData = Array.from(newSelected).map((index) => stepData[index]);
|
||||
console.log("🌊 FlowWidget - 체크박스 토글, 상위로 전달:", {
|
||||
|
|
@ -294,13 +305,12 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
|
|||
newSelected = new Set(stepData.map((_, index) => index));
|
||||
}
|
||||
setSelectedRows(newSelected);
|
||||
|
||||
|
||||
// 선택된 데이터를 상위로 전달
|
||||
const selectedData = Array.from(newSelected).map((index) => stepData[index]);
|
||||
onSelectedDataChange?.(selectedData, selectedStepId);
|
||||
};
|
||||
|
||||
|
||||
// 오딧 로그 로드
|
||||
const loadAuditLogs = async () => {
|
||||
if (!flowId) return;
|
||||
|
|
@ -330,6 +340,10 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
|
|||
const paginatedAuditLogs = auditLogs.slice((auditPage - 1) * auditPageSize, auditPage * auditPageSize);
|
||||
const totalAuditPages = Math.ceil(auditLogs.length / auditPageSize);
|
||||
|
||||
// 🆕 페이지네이션된 스텝 데이터
|
||||
const paginatedStepData = stepData.slice((stepDataPage - 1) * stepDataPageSize, stepDataPage * stepDataPageSize);
|
||||
const totalStepDataPages = Math.ceil(stepData.length / stepDataPageSize);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
|
|
@ -371,9 +385,9 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
|
|||
: "flex flex-col items-center gap-4";
|
||||
|
||||
return (
|
||||
<div className="@container min-h-full w-full p-2 sm:p-4 lg:p-6">
|
||||
<div className="@container flex h-full w-full flex-col p-2 sm:p-4 lg:p-6">
|
||||
{/* 플로우 제목 */}
|
||||
<div className="mb-3 sm:mb-4">
|
||||
<div className="mb-3 flex-shrink-0 sm:mb-4">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<h3 className="text-foreground text-base font-semibold sm:text-lg lg:text-xl">{flowData.name}</h3>
|
||||
|
||||
|
|
@ -566,7 +580,7 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
|
|||
</div>
|
||||
|
||||
{/* 플로우 스텝 목록 */}
|
||||
<div className={containerClass}>
|
||||
<div className={`${containerClass} flex-shrink-0`}>
|
||||
{steps.map((step, index) => (
|
||||
<React.Fragment key={step.id}>
|
||||
{/* 스텝 카드 */}
|
||||
|
|
@ -633,132 +647,212 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
|
|||
|
||||
{/* 선택된 스텝의 데이터 리스트 */}
|
||||
{selectedStepId !== null && (
|
||||
<div className="bg-muted/30 mt-4 w-full rounded-lg p-4 sm:mt-6 sm:rounded-xl sm:p-5 lg:mt-8 lg:p-6">
|
||||
<div className="bg-muted/30 mt-4 flex min-h-0 w-full flex-1 flex-col rounded-lg border sm:mt-6 lg:mt-8">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-4 sm:mb-6">
|
||||
<div className="flex-1">
|
||||
<h4 className="text-foreground text-base font-semibold sm:text-lg">
|
||||
{steps.find((s) => s.id === selectedStepId)?.stepName}
|
||||
</h4>
|
||||
<p className="text-muted-foreground mt-1 text-xs sm:text-sm">
|
||||
총 {stepData.length}건의 데이터
|
||||
{selectedRows.size > 0 && (
|
||||
<span className="text-primary ml-2 font-medium">({selectedRows.size}건 선택됨)</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-background flex-shrink-0 border-b px-4 py-3 sm:px-6 sm:py-4">
|
||||
<h4 className="text-foreground text-base font-semibold sm:text-lg">
|
||||
{steps.find((s) => s.id === selectedStepId)?.stepName}
|
||||
</h4>
|
||||
<p className="text-muted-foreground mt-1 text-xs sm:text-sm">
|
||||
총 {stepData.length}건의 데이터
|
||||
{selectedRows.size > 0 && (
|
||||
<span className="text-primary ml-2 font-medium">({selectedRows.size}건 선택됨)</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 데이터 테이블 */}
|
||||
{stepDataLoading ? (
|
||||
<div className="flex items-center justify-center py-8 sm:py-12">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin sm:h-8 sm:w-8" />
|
||||
<span className="text-muted-foreground ml-2 text-xs sm:ml-3 sm:text-sm">데이터 로딩 중...</span>
|
||||
</div>
|
||||
) : stepData.length === 0 ? (
|
||||
<div className="bg-card flex flex-col items-center justify-center rounded-lg border-2 border-dashed py-8 sm:py-12">
|
||||
<svg
|
||||
className="text-muted-foreground/50 mb-2 h-10 w-10 sm:mb-3 sm:h-12 sm:w-12"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-muted-foreground text-xs sm:text-sm">데이터가 없습니다</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 모바일: 카드 뷰 (컨테이너 640px 미만) */}
|
||||
<div className="space-y-3 @sm:hidden">
|
||||
{stepData.map((row, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`bg-card rounded-lg border p-3 transition-colors ${
|
||||
selectedRows.has(index) ? "border-primary bg-primary/5" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{/* 체크박스 헤더 */}
|
||||
{allowDataMove && (
|
||||
<div className="mb-2 flex items-center justify-between border-b pb-2">
|
||||
<span className="text-muted-foreground text-xs font-medium">선택</span>
|
||||
<Checkbox checked={selectedRows.has(index)} onCheckedChange={() => toggleRowSelection(index)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 데이터 필드들 */}
|
||||
<div className="space-y-2">
|
||||
{stepDataColumns.map((col) => (
|
||||
<div key={col} className="flex justify-between gap-2">
|
||||
<span className="text-muted-foreground text-xs font-medium">{col}:</span>
|
||||
<span className="text-foreground truncate text-xs">
|
||||
{row[col] !== null && row[col] !== undefined ? (
|
||||
String(row[col])
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* 데이터 영역 - 스크롤 가능 */}
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{stepDataLoading ? (
|
||||
<div className="flex h-full items-center justify-center py-12">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin sm:h-8 sm:w-8" />
|
||||
<span className="text-muted-foreground ml-2 text-sm">데이터 로딩 중...</span>
|
||||
</div>
|
||||
|
||||
{/* 데스크톱: 테이블 뷰 (컨테이너 640px 이상) */}
|
||||
<div className="bg-card hidden overflow-x-auto rounded-lg border shadow-sm @sm:block">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow className="hover:bg-transparent">
|
||||
{allowDataMove && (
|
||||
<TableHead className="w-12">
|
||||
<Checkbox
|
||||
checked={selectedRows.size === stepData.length && stepData.length > 0}
|
||||
onCheckedChange={toggleAllRows}
|
||||
/>
|
||||
</TableHead>
|
||||
)}
|
||||
{stepDataColumns.map((col) => (
|
||||
<TableHead key={col} className="text-xs font-semibold whitespace-nowrap sm:text-sm">
|
||||
{col}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{stepData.map((row, index) => (
|
||||
<TableRow
|
||||
key={index}
|
||||
className={`transition-colors ${selectedRows.has(index) ? "bg-primary/5" : "hover:bg-muted/50"}`}
|
||||
) : stepData.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center py-12">
|
||||
<svg
|
||||
className="text-muted-foreground/50 mb-3 h-12 w-12"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-muted-foreground text-sm">데이터가 없습니다</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 모바일: 카드 뷰 */}
|
||||
<div className="space-y-2 p-3 @sm:hidden">
|
||||
{paginatedStepData.map((row, pageIndex) => {
|
||||
const actualIndex = (stepDataPage - 1) * stepDataPageSize + pageIndex;
|
||||
return (
|
||||
<div
|
||||
key={actualIndex}
|
||||
className={`bg-card rounded-md border p-3 transition-colors ${
|
||||
selectedRows.has(actualIndex) ? "bg-primary/5 border-primary/30" : ""
|
||||
}`}
|
||||
>
|
||||
{allowDataMove && (
|
||||
<TableCell className="w-12">
|
||||
<div className="mb-2 flex items-center justify-between border-b pb-2">
|
||||
<span className="text-muted-foreground text-xs font-medium">선택</span>
|
||||
<Checkbox
|
||||
checked={selectedRows.has(index)}
|
||||
onCheckedChange={() => toggleRowSelection(index)}
|
||||
checked={selectedRows.has(actualIndex)}
|
||||
onCheckedChange={() => toggleRowSelection(actualIndex)}
|
||||
/>
|
||||
</TableCell>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
{stepDataColumns.map((col) => (
|
||||
<div key={col} className="flex justify-between gap-2 text-xs">
|
||||
<span className="text-muted-foreground font-medium">{col}:</span>
|
||||
<span className="text-foreground truncate">
|
||||
{row[col] !== null && row[col] !== undefined ? (
|
||||
String(row[col])
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 데스크톱: 테이블 뷰 */}
|
||||
<div className="hidden @sm:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
||||
{allowDataMove && (
|
||||
<TableHead className="bg-muted/50 sticky top-0 left-0 z-20 w-12 border-b px-3 py-2 text-center shadow-sm">
|
||||
<Checkbox
|
||||
checked={selectedRows.size === stepData.length && stepData.length > 0}
|
||||
onCheckedChange={toggleAllRows}
|
||||
/>
|
||||
</TableHead>
|
||||
)}
|
||||
{stepDataColumns.map((col) => (
|
||||
<TableCell key={col} className="font-mono text-xs whitespace-nowrap sm:text-sm">
|
||||
{row[col] !== null && row[col] !== undefined ? (
|
||||
String(row[col])
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableHead
|
||||
key={col}
|
||||
className="bg-muted/50 sticky top-0 z-10 border-b px-3 py-2 text-xs font-semibold whitespace-nowrap sm:text-sm"
|
||||
>
|
||||
{col}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedStepData.map((row, pageIndex) => {
|
||||
const actualIndex = (stepDataPage - 1) * stepDataPageSize + pageIndex;
|
||||
return (
|
||||
<TableRow
|
||||
key={actualIndex}
|
||||
className={`hover:bg-muted/50 ${selectedRows.has(actualIndex) ? "bg-primary/5" : ""}`}
|
||||
>
|
||||
{allowDataMove && (
|
||||
<TableCell className="bg-background sticky left-0 z-10 border-b px-3 py-2 text-center">
|
||||
<Checkbox
|
||||
checked={selectedRows.has(actualIndex)}
|
||||
onCheckedChange={() => toggleRowSelection(actualIndex)}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
{stepDataColumns.map((col) => (
|
||||
<TableCell key={col} className="border-b px-3 py-2 text-xs whitespace-nowrap sm:text-sm">
|
||||
{row[col] !== null && row[col] !== undefined ? (
|
||||
String(row[col])
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 푸터 */}
|
||||
{!stepDataLoading && stepData.length > 0 && totalStepDataPages > 1 && (
|
||||
<div className="bg-background flex-shrink-0 border-t px-4 py-3 sm:px-6">
|
||||
<div className="flex flex-col items-center justify-between gap-3 sm:flex-row">
|
||||
<div className="text-muted-foreground text-xs sm:text-sm">
|
||||
페이지 {stepDataPage} / {totalStepDataPages} (총 {stepData.length}건)
|
||||
</div>
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
onClick={() => setStepDataPage((p) => Math.max(1, p - 1))}
|
||||
className={stepDataPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||
/>
|
||||
</PaginationItem>
|
||||
{totalStepDataPages <= 7 ? (
|
||||
Array.from({ length: totalStepDataPages }, (_, i) => i + 1).map((page) => (
|
||||
<PaginationItem key={page}>
|
||||
<PaginationLink
|
||||
onClick={() => setStepDataPage(page)}
|
||||
isActive={stepDataPage === page}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
{Array.from({ length: totalStepDataPages }, (_, i) => i + 1)
|
||||
.filter((page) => {
|
||||
return (
|
||||
page === 1 ||
|
||||
page === totalStepDataPages ||
|
||||
(page >= stepDataPage - 2 && page <= stepDataPage + 2)
|
||||
);
|
||||
})
|
||||
.map((page, idx, arr) => (
|
||||
<React.Fragment key={page}>
|
||||
{idx > 0 && arr[idx - 1] !== page - 1 && (
|
||||
<PaginationItem>
|
||||
<span className="text-muted-foreground px-2">...</span>
|
||||
</PaginationItem>
|
||||
)}
|
||||
<PaginationItem>
|
||||
<PaginationLink
|
||||
onClick={() => setStepDataPage(page)}
|
||||
isActive={stepDataPage === page}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
onClick={() => setStepDataPage((p) => Math.min(totalStepDataPages, p + 1))}
|
||||
className={
|
||||
stepDataPage === totalStepDataPages ? "pointer-events-none opacity-50" : "cursor-pointer"
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,438 @@
|
|||
/**
|
||||
* 노드 플로우 검증 유틸리티
|
||||
*
|
||||
* 감지 가능한 문제:
|
||||
* 1. 병렬 실행 시 동일 테이블/컬럼 충돌
|
||||
* 2. WHERE 조건 누락 (전체 테이블 삭제/업데이트)
|
||||
* 3. 순환 참조 (무한 루프)
|
||||
* 4. 데이터 소스 타입 불일치
|
||||
*/
|
||||
|
||||
export type ValidationSeverity = "error" | "warning" | "info";
|
||||
|
||||
export interface FlowValidation {
|
||||
nodeId: string;
|
||||
severity: ValidationSeverity;
|
||||
type: string;
|
||||
message: string;
|
||||
affectedNodes?: string[];
|
||||
}
|
||||
|
||||
import type { FlowNode as TypedFlowNode, FlowEdge as TypedFlowEdge } from "@/types/node-editor";
|
||||
|
||||
export type FlowNode = TypedFlowNode;
|
||||
export type FlowEdge = TypedFlowEdge;
|
||||
|
||||
/**
|
||||
* 플로우 전체 검증
|
||||
*/
|
||||
export function validateFlow(
|
||||
nodes: FlowNode[],
|
||||
edges: FlowEdge[]
|
||||
): FlowValidation[] {
|
||||
const validations: FlowValidation[] = [];
|
||||
|
||||
// 1. 병렬 실행 충돌 검증
|
||||
validations.push(...detectParallelConflicts(nodes, edges));
|
||||
|
||||
// 2. WHERE 조건 누락 검증
|
||||
validations.push(...detectMissingWhereConditions(nodes));
|
||||
|
||||
// 3. 순환 참조 검증
|
||||
validations.push(...detectCircularReferences(nodes, edges));
|
||||
|
||||
// 4. 데이터 소스 타입 불일치 검증
|
||||
validations.push(...detectDataSourceMismatch(nodes, edges));
|
||||
|
||||
return validations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 노드에서 도달 가능한 모든 노드 찾기 (DFS)
|
||||
*/
|
||||
function getReachableNodes(
|
||||
startNodeId: string,
|
||||
allNodes: FlowNode[],
|
||||
edges: FlowEdge[]
|
||||
): FlowNode[] {
|
||||
const reachable = new Set<string>();
|
||||
const visited = new Set<string>();
|
||||
|
||||
function dfs(nodeId: string) {
|
||||
if (visited.has(nodeId)) return;
|
||||
visited.add(nodeId);
|
||||
reachable.add(nodeId);
|
||||
|
||||
const outgoingEdges = edges.filter((e) => e.source === nodeId);
|
||||
for (const edge of outgoingEdges) {
|
||||
dfs(edge.target);
|
||||
}
|
||||
}
|
||||
|
||||
dfs(startNodeId);
|
||||
|
||||
return allNodes.filter((node) => reachable.has(node.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 병렬 실행 시 동일 테이블/컬럼 충돌 감지
|
||||
*/
|
||||
function detectParallelConflicts(
|
||||
nodes: FlowNode[],
|
||||
edges: FlowEdge[]
|
||||
): FlowValidation[] {
|
||||
const validations: FlowValidation[] = [];
|
||||
|
||||
// 🆕 연결된 노드만 필터링 (고아 노드 제외)
|
||||
const connectedNodeIds = new Set<string>();
|
||||
for (const edge of edges) {
|
||||
connectedNodeIds.add(edge.source);
|
||||
connectedNodeIds.add(edge.target);
|
||||
}
|
||||
|
||||
// 🆕 소스 노드 찾기
|
||||
const sourceNodes = nodes.filter(
|
||||
(node) =>
|
||||
(node.type === "tableSource" ||
|
||||
node.type === "externalDBSource" ||
|
||||
node.type === "restAPISource") &&
|
||||
connectedNodeIds.has(node.id)
|
||||
);
|
||||
|
||||
// 각 소스 노드에서 시작하는 플로우별로 검증
|
||||
for (const sourceNode of sourceNodes) {
|
||||
// 이 소스에서 도달 가능한 모든 노드 찾기
|
||||
const reachableNodes = getReachableNodes(sourceNode.id, nodes, edges);
|
||||
|
||||
// 레벨별로 그룹화
|
||||
const levels = groupNodesByLevel(
|
||||
reachableNodes,
|
||||
edges.filter(
|
||||
(e) =>
|
||||
reachableNodes.some((n) => n.id === e.source) &&
|
||||
reachableNodes.some((n) => n.id === e.target)
|
||||
)
|
||||
);
|
||||
|
||||
// 각 레벨에서 충돌 검사
|
||||
for (const [levelNum, levelNodes] of levels.entries()) {
|
||||
const updateNodes = levelNodes.filter(
|
||||
(node) => node.type === "updateAction" || node.type === "deleteAction"
|
||||
);
|
||||
|
||||
if (updateNodes.length < 2) continue;
|
||||
|
||||
// 같은 테이블을 수정하는 노드들 찾기
|
||||
const tableMap = new Map<string, FlowNode[]>();
|
||||
|
||||
for (const node of updateNodes) {
|
||||
const tableName =
|
||||
node.data.targetTable || node.data.externalTargetTable;
|
||||
if (tableName) {
|
||||
if (!tableMap.has(tableName)) {
|
||||
tableMap.set(tableName, []);
|
||||
}
|
||||
tableMap.get(tableName)!.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
// 충돌 검사
|
||||
for (const [tableName, conflictNodes] of tableMap.entries()) {
|
||||
if (conflictNodes.length > 1) {
|
||||
// 같은 컬럼을 수정하는지 확인
|
||||
const fieldMap = new Map<string, FlowNode[]>();
|
||||
|
||||
for (const node of conflictNodes) {
|
||||
const fields = node.data.fieldMappings?.map(
|
||||
(m: any) => m.targetField
|
||||
) || [];
|
||||
for (const field of fields) {
|
||||
if (!fieldMap.has(field)) {
|
||||
fieldMap.set(field, []);
|
||||
}
|
||||
fieldMap.get(field)!.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [field, fieldNodes] of fieldMap.entries()) {
|
||||
if (fieldNodes.length > 1) {
|
||||
validations.push({
|
||||
nodeId: fieldNodes[0].id,
|
||||
severity: "warning",
|
||||
type: "parallel-conflict",
|
||||
message: `병렬 실행 중 '${tableName}.${field}' 컬럼에 대한 충돌 가능성이 있습니다. 실행 순서가 보장되지 않아 예상치 못한 결과가 발생할 수 있습니다.`,
|
||||
affectedNodes: fieldNodes.map((n) => n.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 같은 테이블에 대한 일반 경고
|
||||
if (conflictNodes.length > 1 && fieldMap.size === 0) {
|
||||
validations.push({
|
||||
nodeId: conflictNodes[0].id,
|
||||
severity: "info",
|
||||
type: "parallel-table-access",
|
||||
message: `병렬 실행 중 '${tableName}' 테이블을 동시에 수정합니다.`,
|
||||
affectedNodes: conflictNodes.map((n) => n.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return validations;
|
||||
}
|
||||
|
||||
/**
|
||||
* WHERE 조건 누락 감지
|
||||
*/
|
||||
function detectMissingWhereConditions(nodes: FlowNode[]): FlowValidation[] {
|
||||
const validations: FlowValidation[] = [];
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.type === "updateAction" || node.type === "deleteAction") {
|
||||
const whereConditions = node.data.whereConditions;
|
||||
|
||||
if (!whereConditions || whereConditions.length === 0) {
|
||||
validations.push({
|
||||
nodeId: node.id,
|
||||
severity: "error",
|
||||
type: "missing-where",
|
||||
message: `WHERE 조건 없이 전체 테이블을 ${node.type === "deleteAction" ? "삭제" : "수정"}합니다. 매우 위험합니다!`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return validations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 순환 참조 감지 (무한 루프)
|
||||
*/
|
||||
function detectCircularReferences(
|
||||
nodes: FlowNode[],
|
||||
edges: FlowEdge[]
|
||||
): FlowValidation[] {
|
||||
const validations: FlowValidation[] = [];
|
||||
|
||||
// 인접 리스트 생성
|
||||
const adjacencyList = new Map<string, string[]>();
|
||||
for (const node of nodes) {
|
||||
adjacencyList.set(node.id, []);
|
||||
}
|
||||
for (const edge of edges) {
|
||||
adjacencyList.get(edge.source)?.push(edge.target);
|
||||
}
|
||||
|
||||
// DFS로 순환 참조 찾기
|
||||
const visited = new Set<string>();
|
||||
const recursionStack = new Set<string>();
|
||||
const cycles: string[][] = [];
|
||||
|
||||
function dfs(nodeId: string, path: string[]): void {
|
||||
visited.add(nodeId);
|
||||
recursionStack.add(nodeId);
|
||||
path.push(nodeId);
|
||||
|
||||
const neighbors = adjacencyList.get(nodeId) || [];
|
||||
for (const neighbor of neighbors) {
|
||||
if (!visited.has(neighbor)) {
|
||||
dfs(neighbor, [...path]);
|
||||
} else if (recursionStack.has(neighbor)) {
|
||||
// 순환 참조 발견
|
||||
const cycleStart = path.indexOf(neighbor);
|
||||
const cycle = path.slice(cycleStart);
|
||||
cycles.push([...cycle, neighbor]);
|
||||
}
|
||||
}
|
||||
|
||||
recursionStack.delete(nodeId);
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
if (!visited.has(node.id)) {
|
||||
dfs(node.id, []);
|
||||
}
|
||||
}
|
||||
|
||||
// 순환 참조 경고 생성
|
||||
for (const cycle of cycles) {
|
||||
const nodeNames = cycle
|
||||
.map((id) => {
|
||||
const node = nodes.find((n) => n.id === id);
|
||||
return node?.data.displayName || node?.type || id;
|
||||
})
|
||||
.join(" → ");
|
||||
|
||||
validations.push({
|
||||
nodeId: cycle[0],
|
||||
severity: "error",
|
||||
type: "circular-reference",
|
||||
message: `순환 참조가 감지되었습니다: ${nodeNames}`,
|
||||
affectedNodes: cycle,
|
||||
});
|
||||
}
|
||||
|
||||
return validations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 소스 타입 불일치 감지
|
||||
*/
|
||||
function detectDataSourceMismatch(
|
||||
nodes: FlowNode[],
|
||||
edges: FlowEdge[]
|
||||
): FlowValidation[] {
|
||||
const validations: FlowValidation[] = [];
|
||||
|
||||
// 각 노드의 데이터 소스 타입 추적
|
||||
const nodeDataSourceTypes = new Map<string, string>();
|
||||
|
||||
// Source 노드들의 타입 수집
|
||||
for (const node of nodes) {
|
||||
if (
|
||||
node.type === "tableSource" ||
|
||||
node.type === "externalDBSource"
|
||||
) {
|
||||
const dataSourceType = node.data.dataSourceType || "context-data";
|
||||
nodeDataSourceTypes.set(node.id, dataSourceType);
|
||||
}
|
||||
}
|
||||
|
||||
// 각 엣지를 따라 데이터 소스 타입 전파
|
||||
for (const edge of edges) {
|
||||
const sourceType = nodeDataSourceTypes.get(edge.source);
|
||||
if (sourceType) {
|
||||
nodeDataSourceTypes.set(edge.target, sourceType);
|
||||
}
|
||||
}
|
||||
|
||||
// Action 노드들 검사
|
||||
for (const node of nodes) {
|
||||
if (
|
||||
node.type === "updateAction" ||
|
||||
node.type === "deleteAction" ||
|
||||
node.type === "insertAction"
|
||||
) {
|
||||
const dataSourceType = nodeDataSourceTypes.get(node.id);
|
||||
|
||||
// table-all 모드인데 WHERE에 특정 레코드 조건이 있는 경우
|
||||
if (dataSourceType === "table-all") {
|
||||
const whereConditions = node.data.whereConditions || [];
|
||||
const hasPrimaryKeyCondition = whereConditions.some(
|
||||
(cond: any) => cond.field === "id"
|
||||
);
|
||||
|
||||
if (hasPrimaryKeyCondition) {
|
||||
validations.push({
|
||||
nodeId: node.id,
|
||||
severity: "warning",
|
||||
type: "data-source-mismatch",
|
||||
message: `데이터 소스가 'table-all'이지만 WHERE 조건에 Primary Key가 포함되어 있습니다. 의도한 동작인지 확인하세요.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return validations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 레벨별로 노드 그룹화 (위상 정렬)
|
||||
*/
|
||||
function groupNodesByLevel(
|
||||
nodes: FlowNode[],
|
||||
edges: FlowEdge[]
|
||||
): Map<number, FlowNode[]> {
|
||||
const levels = new Map<number, FlowNode[]>();
|
||||
const nodeLevel = new Map<string, number>();
|
||||
const inDegree = new Map<string, number>();
|
||||
const adjacencyList = new Map<string, string[]>();
|
||||
|
||||
// 초기화
|
||||
for (const node of nodes) {
|
||||
inDegree.set(node.id, 0);
|
||||
adjacencyList.set(node.id, []);
|
||||
}
|
||||
|
||||
// 인접 리스트 및 진입 차수 계산
|
||||
for (const edge of edges) {
|
||||
adjacencyList.get(edge.source)?.push(edge.target);
|
||||
inDegree.set(edge.target, (inDegree.get(edge.target) || 0) + 1);
|
||||
}
|
||||
|
||||
// BFS로 레벨 계산
|
||||
const queue: string[] = [];
|
||||
for (const [nodeId, degree] of inDegree.entries()) {
|
||||
if (degree === 0) {
|
||||
queue.push(nodeId);
|
||||
nodeLevel.set(nodeId, 0);
|
||||
}
|
||||
}
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentId = queue.shift()!;
|
||||
const currentLevel = nodeLevel.get(currentId)!;
|
||||
|
||||
const neighbors = adjacencyList.get(currentId) || [];
|
||||
for (const neighbor of neighbors) {
|
||||
const newDegree = (inDegree.get(neighbor) || 0) - 1;
|
||||
inDegree.set(neighbor, newDegree);
|
||||
|
||||
if (newDegree === 0) {
|
||||
queue.push(neighbor);
|
||||
nodeLevel.set(neighbor, currentLevel + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 레벨별로 노드 그룹화
|
||||
for (const node of nodes) {
|
||||
const level = nodeLevel.get(node.id) || 0;
|
||||
if (!levels.has(level)) {
|
||||
levels.set(level, []);
|
||||
}
|
||||
levels.get(level)!.push(node);
|
||||
}
|
||||
|
||||
return levels;
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 결과 요약
|
||||
*/
|
||||
export function summarizeValidations(validations: FlowValidation[]): {
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
infoCount: number;
|
||||
hasBlockingIssues: boolean;
|
||||
} {
|
||||
const errorCount = validations.filter((v) => v.severity === "error").length;
|
||||
const warningCount = validations.filter(
|
||||
(v) => v.severity === "warning"
|
||||
).length;
|
||||
const infoCount = validations.filter((v) => v.severity === "info").length;
|
||||
|
||||
return {
|
||||
errorCount,
|
||||
warningCount,
|
||||
infoCount,
|
||||
hasBlockingIssues: errorCount > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 노드의 검증 결과 가져오기
|
||||
*/
|
||||
export function getNodeValidations(
|
||||
nodeId: string,
|
||||
validations: FlowValidation[]
|
||||
): FlowValidation[] {
|
||||
return validations.filter(
|
||||
(v) => v.nodeId === nodeId || v.affectedNodes?.includes(nodeId)
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue