diff --git a/.cursor/rules/scroll-debugging-guide.mdc b/.cursor/rules/scroll-debugging-guide.mdc new file mode 100644 index 00000000..efbd110d --- /dev/null +++ b/.cursor/rules/scroll-debugging-guide.mdc @@ -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 +// 최상위 컨테이너 (빨간색) +
+ {/* 헤더 (파란색) */} +
+ 헤더 +
+ + {/* 스크롤 영역 (초록색) */} +
+ 콘텐츠 +
+
+``` + +**브라우저에서 확인할 사항:** + +- 🔴 빨간색 테두리가 화면 전체 높이를 차지하는가? +- 🔵 파란색 테두리가 고정된 높이를 유지하는가? +- 🟢 초록색 테두리가 남은 공간을 차지하는가? + +### 단계 2: 부모 체인 추적 + +스크롤이 작동하지 않으면 **부모 컨테이너부터 역순으로 추적**: + +```tsx +// ❌ 문제 예시 +
{/* 높이가 확정되지 않음 */} +
{/* flex-1이 작동하지 않음 */} + {/* 스크롤 실패 */} +
+
+ +// ✅ 해결 +
{/* 높이 확정 */} +
{/* overflow 제한 */} + {/* 스크롤 성공 */} +
+
+``` + +### 단계 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) +
+
+ {/* 헤더 (고정) */} +
+ 헤더 +
+ + {/* 에디터 (flex-1) */} +
+ {" "} + {/* ⚠️ overflow-hidden 필수! */} + +
+
+
+``` + +**핵심 포인트:** + +- `fixed inset-0`: 뷰포트 전체 차지 +- `flex h-full flex-col`: Flex column 레이아웃 +- `flex-1 overflow-hidden`: 자식이 부모를 넘지 못하게 제한 + +### 패턴 B: 중첩된 Flex 컨테이너 + +```tsx +// 컴포넌트 레벨 (예: FlowEditor.tsx) +
+ {/* 좌측 사이드바 */} +
사이드바
+ + {/* 중앙 캔버스 */} +
캔버스
+ + {/* 우측 속성 패널 */} +
+ +
+
+``` + +**핵심 포인트:** + +- 인라인 스타일 `height: '100%'`: Tailwind보다 우선순위 높음 +- `display: "flex"`: Flex 컨테이너 명시 +- `overflow: 'hidden'`: 자식 크기 제한 + +### 패턴 C: 스크롤 가능 영역 + +```tsx +// 스크롤 영역 (예: PropertiesPanel.tsx) + +``` + +**핵심 포인트:** + +- `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가 작동하지 않음 +
+ +// ✅ 인라인 스타일로 강제 +
+``` + +**이유:** + +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 +
+
+ {/* 작동 안 함 */} +
+
+ +// ✅ 부모의 높이 확정 +
+
+ {/* 작동 */} +
+
+``` + +### 실수 2: overflow-hidden 누락 + +```tsx +// ❌ overflow-hidden 없음 +
+ {/* 부모를 밀어냄 */} +
+ +// ✅ overflow-hidden 추가 +
+ {/* 제한됨 */} +
+``` + +### 실수 3: minHeight: 0 누락 + +```tsx +// ❌ minHeight: 0 없음 +
+ {/* 스크롤 안 됨 */} +
+ +// ✅ minHeight: 0 추가 +
+ {/* 스크롤 됨 */} +
+``` + +### 실수 4: display: flex 누락 + +```tsx +// ❌ Flex 컨테이너 미지정 +
+ {/* flex-1이 작동 안 함 */} +
+ +// ✅ Flex 컨테이너 명시 +
+ {/* 작동 */} +
+``` + +## 8. 완전한 예시 + +### 전체 레이아웃 구조 + +```tsx +// 페이지 (dataflow/page.tsx) +
+
+ {/* 헤더 */} +
+ 헤더 +
+ + {/* 에디터 */} +
+ +
+
+
+ +// 에디터 (FlowEditor.tsx) +
+ {/* 사이드바 */} +
+ 사이드바 +
+ + {/* 캔버스 */} +
+ 캔버스 +
+ + {/* 속성 패널 */} +
+ +
+
+ +// 속성 패널 (PropertiesPanel.tsx) +
+ {/* 헤더 */} +
+ 헤더 +
+ + {/* 스크롤 영역 */} +
+ {/* 콘텐츠 */} + +
+
+``` + +## 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]() +- [FlowEditor.tsx](mdc:frontend/components/dataflow/node-editor/FlowEditor.tsx) +- [PropertiesPanel.tsx](mdc:frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx) diff --git a/frontend/app/(main)/admin/dataflow/page.tsx b/frontend/app/(main)/admin/dataflow/page.tsx index f8a77e11..87d937ec 100644 --- a/frontend/app/(main)/admin/dataflow/page.tsx +++ b/frontend/app/(main)/admin/dataflow/page.tsx @@ -68,7 +68,7 @@ export default function DataFlowPage() {
{/* 플로우 에디터 */} -
+
diff --git a/frontend/components/dataflow/node-editor/FlowEditor.tsx b/frontend/components/dataflow/node-editor/FlowEditor.tsx index 4a5c0903..f144a7a8 100644 --- a/frontend/components/dataflow/node-editor/FlowEditor.tsx +++ b/frontend/components/dataflow/node-editor/FlowEditor.tsx @@ -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(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(() => { + 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 ( -
+
{/* 좌측 통합 툴바 */} @@ -273,8 +293,8 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) { {/* 중앙 캔버스 */}
- +
{/* 우측 속성 패널 */} {showPropertiesPanelLocal && selectedNodes.length > 0 && ( -
+
)} + + {/* 검증 알림 (우측 상단 플로팅) */} +
); } diff --git a/frontend/components/dataflow/node-editor/FlowToolbar.tsx b/frontend/components/dataflow/node-editor/FlowToolbar.tsx index 36d60e98..5d0064ea 100644 --- a/frontend/components/dataflow/node-editor/FlowToolbar.tsx +++ b/frontend/components/dataflow/node-editor/FlowToolbar.tsx @@ -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 ( -
- {/* 플로우 이름 */} - setFlowName(e.target.value)} - className="h-8 w-[200px] text-sm" - placeholder="플로우 이름" + <> +
+ {/* 플로우 이름 */} + setFlowName(e.target.value)} + className="h-8 w-[200px] text-sm" + placeholder="플로우 이름" + /> + +
+ + {/* 실행 취소/다시 실행 */} + + + +
+ + {/* 삭제 버튼 */} + + +
+ + {/* 줌 컨트롤 */} + + + + +
+ + {/* 저장 */} + + + {/* 내보내기 */} + +
+ + {/* 저장 확인 다이얼로그 */} + 0 ? validations : validateFlow(nodes, edges)} + onConfirm={performSave} + onCancel={() => setShowSaveDialog(false)} /> - -
- - {/* 실행 취소/다시 실행 */} - - - -
- - {/* 삭제 버튼 */} - - -
- - {/* 줌 컨트롤 */} - - - - -
- - {/* 저장 */} - - - {/* 내보내기 */} - - -
- - {/* 검증 */} - -
+ ); } diff --git a/frontend/components/dataflow/node-editor/ValidationNotification.tsx b/frontend/components/dataflow/node-editor/ValidationNotification.tsx new file mode 100644 index 00000000..54a04625 --- /dev/null +++ b/frontend/components/dataflow/node-editor/ValidationNotification.tsx @@ -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 = { + "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); + + return ( +
+
0 + ? "border-yellow-500" + : "border-blue-500" + )} + > + {/* 헤더 */} +
0 + ? "bg-yellow-50" + : "bg-blue-50" + )} + onClick={() => setIsExpanded(!isExpanded)} + > +
+ {summary.hasBlockingIssues ? ( + + ) : summary.warningCount > 0 ? ( + + ) : ( + + )} + + 플로우 검증 + +
+ {summary.errorCount > 0 && ( + + {summary.errorCount} + + )} + {summary.warningCount > 0 && ( + + {summary.warningCount} + + )} + {summary.infoCount > 0 && ( + + {summary.infoCount} + + )} +
+
+
+ {isExpanded ? ( + + ) : ( + + )} + {onClose && ( + + )} +
+
+ + {/* 확장된 내용 */} + {isExpanded && ( +
+
+ {Object.entries(groupedValidations).map(([type, typeValidations]) => { + const firstValidation = typeValidations[0]; + const Icon = + firstValidation.severity === "error" + ? AlertCircle + : firstValidation.severity === "warning" + ? AlertTriangle + : Info; + + return ( +
+ {/* 타입 헤더 */} +
+ + {getTypeLabel(type)} + + {typeValidations.length}개 + +
+ + {/* 검증 항목들 */} +
+ {typeValidations.map((validation, index) => ( +
onNodeClick?.(validation.nodeId)} + > +

+ {validation.message} +

+ {validation.affectedNodes && validation.affectedNodes.length > 1 && ( +
+ 영향받는 노드: {validation.affectedNodes.length}개 +
+ )} +
+ 클릭하여 노드 보기 → +
+
+ ))} +
+
+ ); + })} +
+
+ )} + + {/* 요약 메시지 (닫혀있을 때) */} + {!isExpanded && ( +
+

+ {summary.hasBlockingIssues + ? "⛔ 오류를 해결해야 저장할 수 있습니다" + : summary.warningCount > 0 + ? "⚠️ 경고 사항을 확인하세요" + : "ℹ️ 정보를 확인하세요"} +

+
+ )} +
+
+ ); + } +); + +ValidationNotification.displayName = "ValidationNotification"; + diff --git a/frontend/components/dataflow/node-editor/dialogs/SaveConfirmDialog.tsx b/frontend/components/dataflow/node-editor/dialogs/SaveConfirmDialog.tsx new file mode 100644 index 00000000..4f172e95 --- /dev/null +++ b/frontend/components/dataflow/node-editor/dialogs/SaveConfirmDialog.tsx @@ -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 ( + + + + + + 저장할 수 없습니다 + + + 오류를 수정한 후 다시 시도하세요 + + + +
+
+ + + 오류 {summary.errorCount} + + {summary.warningCount > 0 && ( + + + 경고 {summary.warningCount} + + )} +
+ + +
+ {validations + .filter((v) => v.severity === "error") + .map((validation, index) => ( +
+
+ {validation.type} +
+
+ {validation.message} +
+
+ ))} +
+
+ +

+ 위의 오류를 먼저 해결해주세요. 경고는 저장 후에도 확인할 수 + 있습니다. +

+
+ + + + +
+
+ ); + } + + // 경고만 있는 경우 - 저장 가능하지만 확인 필요 + return ( + + + + + + 경고가 있습니다 + + + 플로우에 {summary.warningCount + summary.infoCount}개의 경고가 + 발견되었습니다 + + + +
+
+ {summary.warningCount > 0 && ( + + + 경고 {summary.warningCount} + + )} + {summary.infoCount > 0 && ( + + + 정보 {summary.infoCount} + + )} +
+ + +
+ {validations + .filter((v) => v.severity === "warning") + .map((validation, index) => ( +
+
+ {validation.type} +
+
+ {validation.message} +
+
+ ))} + {validations + .filter((v) => v.severity === "info") + .map((validation, index) => ( +
+
+ {validation.type} +
+
+ {validation.message} +
+
+ ))} +
+
+ +
+

+ ⚠️ 이 경고들은 플로우의 동작에 영향을 줄 수 있습니다. +
+ 그래도 저장하시겠습니까? +

+
+
+ + + + + +
+
+ ); + } +); + +SaveConfirmDialog.displayName = "SaveConfirmDialog"; + diff --git a/frontend/components/dataflow/node-editor/nodes/NodeWithValidation.tsx b/frontend/components/dataflow/node-editor/nodes/NodeWithValidation.tsx new file mode 100644 index 00000000..90c1039e --- /dev/null +++ b/frontend/components/dataflow/node-editor/nodes/NodeWithValidation.tsx @@ -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 ( +
+ {children} + + {/* 경고 배지 */} + + + +
+ + {nodeValidations.length > 1 && ( + + {nodeValidations.length} + + )} +
+
+ +
+
+ + + {hasError + ? "오류" + : hasWarning + ? "경고" + : "정보"} ({nodeValidations.length}) + +
+
+ {nodeValidations.map((validation, index) => ( +
+
+ {validation.type} +
+
+ {validation.message} +
+ {validation.affectedNodes && validation.affectedNodes.length > 1 && ( +
+ 영향받는 노드: {validation.affectedNodes.length}개 +
+ )} +
+ ))} +
+
+
+
+
+
+ ); + } +); + +NodeWithValidation.displayName = "NodeWithValidation"; + diff --git a/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx b/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx index 8e4abb39..ada62e8d 100644 --- a/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx +++ b/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx @@ -28,9 +28,23 @@ export function PropertiesPanel() { const selectedNode = selectedNodes.length === 1 ? nodes.find((n) => n.id === selectedNodes[0]) : null; return ( -
+
{/* 헤더 */} -
+

속성

{selectedNode && ( @@ -44,11 +58,11 @@ export function PropertiesPanel() { {/* 내용 - 스크롤 가능 영역 */}
{selectedNodes.length === 0 ? ( diff --git a/frontend/components/dataflow/node-editor/panels/ValidationPanel.tsx b/frontend/components/dataflow/node-editor/panels/ValidationPanel.tsx new file mode 100644 index 00000000..a8a5b149 --- /dev/null +++ b/frontend/components/dataflow/node-editor/panels/ValidationPanel.tsx @@ -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>(new Set()); + + const summary = useMemo( + () => summarizeValidations(validations), + [validations] + ); + + // 타입별로 그룹화 + const groupedValidations = useMemo(() => { + const groups = new Map(); + 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 = { + "parallel-conflict": "병렬 실행 충돌", + "missing-where": "WHERE 조건 누락", + "circular-reference": "순환 참조", + "data-source-mismatch": "데이터 소스 불일치", + "parallel-table-access": "병렬 테이블 접근", + }; + return labels[type] || type; + }; + + if (validations.length === 0) { + return ( +
+
+

검증 결과

+ {onClose && ( + + )} +
+
+
+
+ +
+

문제 없음

+

+ 플로우에 문제가 발견되지 않았습니다 +

+
+
+
+ ); + } + + return ( +
+ {/* 헤더 */} +
+

검증 결과

+ {onClose && ( + + )} +
+ + {/* 요약 */} +
+
+ {summary.errorCount > 0 && ( + + + 오류 {summary.errorCount} + + )} + {summary.warningCount > 0 && ( + + + 경고 {summary.warningCount} + + )} + {summary.infoCount > 0 && ( + + + 정보 {summary.infoCount} + + )} +
+ {summary.hasBlockingIssues && ( +

+ ⛔ 오류를 해결해야 저장할 수 있습니다 +

+ )} +
+ + {/* 검증 결과 목록 */} + +
+ {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 ( +
+ {/* 그룹 헤더 */} + + + {/* 상세 내용 */} + {isExpanded && ( +
+ {typeValidations.map((validation, index) => ( +
onNodeClick?.(validation.nodeId)} + > +
+ {validation.message} +
+ {validation.affectedNodes && validation.affectedNodes.length > 1 && ( +
+ + 영향받는 노드: {validation.affectedNodes.length}개 + +
+ )} +
+ 클릭하여 노드 보기 +
+
+ ))} +
+ )} +
+ ); + })} +
+
+
+ ); + } +); + +ValidationPanel.displayName = "ValidationPanel"; + diff --git a/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx index 3a9fa1a7..87f7f771 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx @@ -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 ( - +
{/* 기본 정보 */}
@@ -420,6 +419,6 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
-
+
); } diff --git a/frontend/components/dataflow/node-editor/panels/properties/DataTransformProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/DataTransformProperties.tsx index f2af6d21..dbe81803 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/DataTransformProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/DataTransformProperties.tsx @@ -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 ( - +
{/* 헤더 */}
@@ -454,6 +453,6 @@ export function DataTransformProperties({ nodeId, data }: DataTransformPropertie
- +
); } diff --git a/frontend/components/dataflow/node-editor/panels/properties/DeleteActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/DeleteActionProperties.tsx index 09011bf5..3c5995d7 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/DeleteActionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/DeleteActionProperties.tsx @@ -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 ( - +
{/* 경고 */}
@@ -714,6 +713,6 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
💡 실행 전 WHERE 조건을 꼭 확인하세요.
- +
); } diff --git a/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx index 6bd7c124..44a3b7e2 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx @@ -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([]); @@ -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 ( - +
{/* 🔥 타겟 타입 선택 */}
@@ -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
)} - {/* 옵션 */} -
-

옵션

- -
-
- - handleBatchSizeChange(e.target.value)} - className="mt-1" - placeholder="한 번에 처리할 레코드 수" - /> -
- -
- handleIgnoreDuplicatesChange(checked as boolean)} - /> - -
- -
- handleIgnoreErrorsChange(checked as boolean)} - /> - -
-
-
- - {/* 저장 버튼 */} - {/* 안내 */}
✅ 테이블과 필드는 실제 데이터베이스에서 조회됩니다. @@ -1292,6 +1187,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP 💡 소스 필드가 없으면 정적 값이 사용됩니다.
- +
); } diff --git a/frontend/components/dataflow/node-editor/panels/properties/ReferenceLookupProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/ReferenceLookupProperties.tsx index 167f7c2a..2f553608 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/ReferenceLookupProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/ReferenceLookupProperties.tsx @@ -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 ( - +
{/* 기본 정보 */}
@@ -633,6 +632,6 @@ export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPrope
-
+
); } diff --git a/frontend/components/dataflow/node-editor/panels/properties/TableSourceProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/TableSourceProperties.tsx index 72c3269d..a342a213 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/TableSourceProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/TableSourceProperties.tsx @@ -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"; diff --git a/frontend/components/dataflow/node-editor/panels/properties/UpdateActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/UpdateActionProperties.tsx index 2e70e28a..d39ed167 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/UpdateActionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/UpdateActionProperties.tsx @@ -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([]); @@ -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 ( - -
+
{/* 기본 정보 */}

기본 정보

@@ -1268,38 +1238,6 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
)} - {/* 옵션 */} -
-

옵션

-
-
- - handleBatchSizeChange(e.target.value)} - className="mt-1" - placeholder="예: 100" - /> -
- -
- handleIgnoreErrorsChange(checked as boolean)} - /> - -
-
-
- -
- +
); } diff --git a/frontend/components/dataflow/node-editor/panels/properties/UpsertActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/UpsertActionProperties.tsx index ee244063..86e8a8a5 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/UpsertActionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/UpsertActionProperties.tsx @@ -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(data.conflictKeys || []); const [conflictKeyLabels, setConflictKeyLabels] = useState(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([]); @@ -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 ( - +
{/* 기본 정보 */}
@@ -1122,38 +1093,7 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP )}
- {/* 옵션 */} -
-

옵션

-
-
- - setBatchSize(e.target.value)} - className="mt-1" - placeholder="예: 100" - /> -
- -
- setUpdateOnConflict(checked as boolean)} - /> - -
-
-
-
- +
); } diff --git a/frontend/components/screen/RealtimePreview.tsx b/frontend/components/screen/RealtimePreview.tsx index e08cae82..7cf02aa0 100644 --- a/frontend/components/screen/RealtimePreview.tsx +++ b/frontend/components/screen/RealtimePreview.tsx @@ -435,7 +435,7 @@ export const RealtimePreviewDynamic: React.FC = ({ {/* 컴포넌트 타입별 렌더링 */}
{/* 영역 타입 */} {type === "area" && renderArea(component, children)} diff --git a/frontend/components/screen/toolbar/LeftUnifiedToolbar.tsx b/frontend/components/screen/toolbar/LeftUnifiedToolbar.tsx index 2c1cb042..dfd00ee9 100644 --- a/frontend/components/screen/toolbar/LeftUnifiedToolbar.tsx +++ b/frontend/components/screen/toolbar/LeftUnifiedToolbar.tsx @@ -17,7 +17,7 @@ export interface ToolbarButton { interface LeftUnifiedToolbarProps { buttons: ToolbarButton[]; - panelStates: Record; + panelStates: Record; onTogglePanel: (panelId: string) => void; } @@ -28,6 +28,7 @@ export const LeftUnifiedToolbar: React.FC = ({ buttons, const renderButton = (button: ToolbarButton) => { const isActive = panelStates[button.id]?.isOpen || false; + const badge = panelStates[button.id]?.badge; return ( diff --git a/frontend/components/screen/widgets/FlowWidget.tsx b/frontend/components/screen/widgets/FlowWidget.tsx index c7a846c1..f08daa88 100644 --- a/frontend/components/screen/widgets/FlowWidget.tsx +++ b/frontend/components/screen/widgets/FlowWidget.tsx @@ -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>(new Set()); + // 🆕 스텝 데이터 페이지네이션 상태 + const [stepDataPage, setStepDataPage] = useState(1); + const [stepDataPageSize] = useState(20); + // 오딧 로그 상태 const [auditLogs, setAuditLogs] = useState([]); 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 = {}; @@ -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 (
@@ -371,9 +385,9 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR : "flex flex-col items-center gap-4"; return ( -
+
{/* 플로우 제목 */} -
+

{flowData.name}

@@ -566,7 +580,7 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
{/* 플로우 스텝 목록 */} -
+
{steps.map((step, index) => ( {/* 스텝 카드 */} @@ -633,132 +647,212 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR {/* 선택된 스텝의 데이터 리스트 */} {selectedStepId !== null && ( -
+
{/* 헤더 */} -
-
-

- {steps.find((s) => s.id === selectedStepId)?.stepName} -

-

- 총 {stepData.length}건의 데이터 - {selectedRows.size > 0 && ( - ({selectedRows.size}건 선택됨) - )} -

-
+
+

+ {steps.find((s) => s.id === selectedStepId)?.stepName} +

+

+ 총 {stepData.length}건의 데이터 + {selectedRows.size > 0 && ( + ({selectedRows.size}건 선택됨) + )} +

- {/* 데이터 테이블 */} - {stepDataLoading ? ( -
- - 데이터 로딩 중... -
- ) : stepData.length === 0 ? ( -
- - - - 데이터가 없습니다 -
- ) : ( - <> - {/* 모바일: 카드 뷰 (컨테이너 640px 미만) */} -
- {stepData.map((row, index) => ( -
- {/* 체크박스 헤더 */} - {allowDataMove && ( -
- 선택 - toggleRowSelection(index)} /> -
- )} - - {/* 데이터 필드들 */} -
- {stepDataColumns.map((col) => ( -
- {col}: - - {row[col] !== null && row[col] !== undefined ? ( - String(row[col]) - ) : ( - - - )} - -
- ))} -
-
- ))} + {/* 데이터 영역 - 스크롤 가능 */} +
+ {stepDataLoading ? ( +
+ + 데이터 로딩 중...
- - {/* 데스크톱: 테이블 뷰 (컨테이너 640px 이상) */} -
- - - - {allowDataMove && ( - - 0} - onCheckedChange={toggleAllRows} - /> - - )} - {stepDataColumns.map((col) => ( - - {col} - - ))} - - - - {stepData.map((row, index) => ( - + + + + 데이터가 없습니다 + + ) : ( + <> + {/* 모바일: 카드 뷰 */} +
+ {paginatedStepData.map((row, pageIndex) => { + const actualIndex = (stepDataPage - 1) * stepDataPageSize + pageIndex; + return ( +
{allowDataMove && ( - +
+ 선택 toggleRowSelection(index)} + checked={selectedRows.has(actualIndex)} + onCheckedChange={() => toggleRowSelection(actualIndex)} /> - +
+ )} +
+ {stepDataColumns.map((col) => ( +
+ {col}: + + {row[col] !== null && row[col] !== undefined ? ( + String(row[col]) + ) : ( + - + )} + +
+ ))} +
+
+ ); + })} +
+ + {/* 데스크톱: 테이블 뷰 */} +
+
+ + + {allowDataMove && ( + + 0} + onCheckedChange={toggleAllRows} + /> + )} {stepDataColumns.map((col) => ( - - {row[col] !== null && row[col] !== undefined ? ( - String(row[col]) - ) : ( - - - )} - + + {col} + ))} - ))} - -
+ + + {paginatedStepData.map((row, pageIndex) => { + const actualIndex = (stepDataPage - 1) * stepDataPageSize + pageIndex; + return ( + + {allowDataMove && ( + + toggleRowSelection(actualIndex)} + /> + + )} + {stepDataColumns.map((col) => ( + + {row[col] !== null && row[col] !== undefined ? ( + String(row[col]) + ) : ( + - + )} + + ))} + + ); + })} + + +
+ + )} +
+ + {/* 페이지네이션 푸터 */} + {!stepDataLoading && stepData.length > 0 && totalStepDataPages > 1 && ( +
+
+
+ 페이지 {stepDataPage} / {totalStepDataPages} (총 {stepData.length}건) +
+ + + + setStepDataPage((p) => Math.max(1, p - 1))} + className={stepDataPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"} + /> + + {totalStepDataPages <= 7 ? ( + Array.from({ length: totalStepDataPages }, (_, i) => i + 1).map((page) => ( + + setStepDataPage(page)} + isActive={stepDataPage === page} + className="cursor-pointer" + > + {page} + + + )) + ) : ( + <> + {Array.from({ length: totalStepDataPages }, (_, i) => i + 1) + .filter((page) => { + return ( + page === 1 || + page === totalStepDataPages || + (page >= stepDataPage - 2 && page <= stepDataPage + 2) + ); + }) + .map((page, idx, arr) => ( + + {idx > 0 && arr[idx - 1] !== page - 1 && ( + + ... + + )} + + setStepDataPage(page)} + isActive={stepDataPage === page} + className="cursor-pointer" + > + {page} + + + + ))} + + )} + + setStepDataPage((p) => Math.min(totalStepDataPages, p + 1))} + className={ + stepDataPage === totalStepDataPages ? "pointer-events-none opacity-50" : "cursor-pointer" + } + /> + + +
- +
)}
)} diff --git a/frontend/lib/utils/flowValidation.ts b/frontend/lib/utils/flowValidation.ts new file mode 100644 index 00000000..97e8b0e5 --- /dev/null +++ b/frontend/lib/utils/flowValidation.ts @@ -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(); + const visited = new Set(); + + 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(); + 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(); + + 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(); + + 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(); + 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(); + const recursionStack = new Set(); + 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(); + + // 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 { + const levels = new Map(); + const nodeLevel = new Map(); + const inDegree = new Map(); + const adjacencyList = new Map(); + + // 초기화 + 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) + ); +} +