diff --git a/docs/화면간_데이터_관계_설정_시스템_설계.md b/docs/화면간_데이터_관계_설정_시스템_설계.md
index 75f4bfdf..3b7180da 100644
--- a/docs/화면간_데이터_관계_설정_시스템_설계.md
+++ b/docs/화면간_데이터_관계_설정_시스템_설계.md
@@ -626,12 +626,17 @@ PUT /api/external-call-configs/:id
- [x] 노드 간 드래그앤드롭 연결 기능
- [x] 줌, 팬, 미니맵 등 React Flow 기본 기능
-### Phase 2: 관계 설정 기능 (2주)
+### Phase 2: 관계 설정 기능 (2주) - 🚧 **진행 중 (70% 완료)**
-- [ ] 1:1, 1:N 관계 설정
-- [ ] 단순 키값 연결
-- [ ] 연결 설정 모달
-- [ ] 노드 간 드래그앤드롭 연결
+- [x] 연결 설정 모달 UI 구현
+- [x] 1:1, 1:N, N:1, N:N 관계 타입 선택 UI
+- [x] 단순 키값, 데이터 저장, 외부 호출 연결 종류 UI
+- [x] 필드-to-필드 연결 시스템 (클릭 기반)
+- [x] 선택된 필드 정보 표시 및 순서 보장
+- [ ] 연결 생성 로직 구현 (모달에서 실제 엣지 생성)
+- [ ] 생성된 연결의 시각적 표시 (React Flow 엣지)
+- [ ] 연결 데이터 백엔드 저장 API 연동
+- [ ] 기존 연결 수정/삭제 기능
### Phase 3: 고급 연결 타입 (2-3주)
@@ -672,23 +677,39 @@ PUT /api/external-call-configs/:id
**구현된 기능:**
- React Flow 12.8.4 기반 시각적 캔버스
-- 화면 노드 컴포넌트 (필드 정보, 타입별 색상 구분)
+- 화면 노드 컴포넌트 (필드 정보, 타입별 색상 구분, 노드 리사이징)
- 커스텀 엣지 컴포넌트 (관계 타입별 스타일링)
- 드래그앤드롭 노드 배치 및 연결
-- 줌, 팬, 미니맵 등 고급 시각화 기능
-- 샘플 데이터 생성 및 관리 기능
+- 줌, 팬, 미니맵 등 고급 시각화 기능 (스크롤 충돌 해결)
+- 실제 화면 데이터 연동 (테이블 관리 API 연결)
+- 필드-to-필드 연결 시스템 (클릭 기반, 2개 화면 제한)
+- 연결 설정 모달 (관계 타입, 연결 종류 선택 UI)
- /admin/dataflow 경로 설정
-- 메뉴 시스템 연동 준비 완료
+- 메뉴 시스템 연동 완료
+- 사용자 경험 개선 (토스트 알림, 선택 순서 보장)
**구현된 파일:**
-- `frontend/components/dataflow/DataFlowDesigner.tsx`
-- `frontend/components/dataflow/ScreenNode.tsx`
-- `frontend/components/dataflow/CustomEdge.tsx`
-- `frontend/app/(main)/admin/dataflow/page.tsx`
-- `docs/add_dataflow_menu.sql` (메뉴 추가 스크립트)
+- `frontend/components/dataflow/DataFlowDesigner.tsx` - 메인 캔버스 컴포넌트
+- `frontend/components/dataflow/ScreenNode.tsx` - 화면 노드 컴포넌트 (NodeResizer 포함)
+- `frontend/components/dataflow/CustomEdge.tsx` - 커스텀 엣지 컴포넌트
+- `frontend/components/dataflow/ConnectionSetupModal.tsx` - 연결 설정 모달
+- `frontend/app/(main)/admin/dataflow/page.tsx` - 데이터 흐름 관리 페이지
+- `frontend/lib/api/dataflow.ts` - 데이터 흐름 API 클라이언트
+- `docs/add_dataflow_menu.sql` - 메뉴 추가 스크립트
-**다음 단계:** Phase 2 - 관계 설정 기능 구현
+**주요 개선사항:**
+
+1. **스크롤 충돌 해결**: 노드 내부 스크롤과 React Flow 줌/팬 기능 분리
+2. **노드 리사이징**: NodeResizer를 통한 노드 크기 조정 및 내용 반영
+3. **필드-to-필드 연결**: 드래그앤드롭 대신 클릭 기반 필드 선택 방식
+4. **2개 화면 제한**: 최대 2개 화면에서만 필드 선택 가능
+5. **선택 순서 보장**: 사이드바와 모달에서 필드 선택 순서 정확히 반영
+6. **실제 데이터 연동**: 테이블 관리 시스템의 실제 화면/필드 데이터 사용
+7. **사용자 경험**: react-hot-toast를 통한 친화적인 알림 시스템
+8. **React 안정성**: 렌더링 중 상태 변경 문제 해결
+
+**다음 단계:** Phase 2 - 실제 연결 생성 및 시각적 표시 기능 구현
### 주요 가치
diff --git a/frontend/app/(main)/admin/dataflow/page.tsx b/frontend/app/(main)/admin/dataflow/page.tsx
index 72eb4f8b..d7a7e316 100644
--- a/frontend/app/(main)/admin/dataflow/page.tsx
+++ b/frontend/app/(main)/admin/dataflow/page.tsx
@@ -1,23 +1,22 @@
-'use client';
+"use client";
-import React from 'react';
-import { DataFlowDesigner } from '@/components/dataflow/DataFlowDesigner';
-import { useAuth } from '@/hooks/useAuth';
+import React from "react";
+import { Toaster } from "react-hot-toast";
+import { DataFlowDesigner } from "@/components/dataflow/DataFlowDesigner";
+import { useAuth } from "@/hooks/useAuth";
export default function DataFlowPage() {
const { user } = useAuth();
const handleSave = (relationships: any[]) => {
- console.log('저장된 관계:', relationships);
+ console.log("저장된 관계:", relationships);
// TODO: API 호출로 관계 저장
};
return (
-
+
+
);
}
diff --git a/frontend/components/dataflow/ConnectionSetupModal.tsx b/frontend/components/dataflow/ConnectionSetupModal.tsx
new file mode 100644
index 00000000..94c9cc7d
--- /dev/null
+++ b/frontend/components/dataflow/ConnectionSetupModal.tsx
@@ -0,0 +1,320 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Textarea } from "@/components/ui/textarea";
+import { ArrowRight, Database, Link } from "lucide-react";
+
+// 연결 정보 타입
+interface ConnectionInfo {
+ fromNode: {
+ id: string;
+ screenName: string;
+ tableName: string;
+ };
+ toNode: {
+ id: string;
+ screenName: string;
+ tableName: string;
+ };
+ fromField?: string;
+ toField?: string;
+ selectedFieldsData?: {
+ [screenId: string]: {
+ screenName: string;
+ fields: string[];
+ };
+ };
+ orderedScreenIds?: string[]; // 선택 순서 정보
+}
+
+// 연결 설정 타입
+interface ConnectionConfig {
+ relationshipName: string;
+ relationshipType: "one-to-one" | "one-to-many" | "many-to-one" | "many-to-many";
+ connectionType: "simple-key" | "data-save" | "external-call";
+ fromFieldName: string;
+ toFieldName: string;
+ settings?: Record;
+ description?: string;
+}
+
+interface ConnectionSetupModalProps {
+ isOpen: boolean;
+ connection: ConnectionInfo | null;
+ onConfirm: (config: ConnectionConfig) => void;
+ onCancel: () => void;
+}
+
+export const ConnectionSetupModal: React.FC = ({
+ isOpen,
+ connection,
+ onConfirm,
+ onCancel,
+}) => {
+ const [relationshipName, setRelationshipName] = useState("");
+ const [relationshipType, setRelationshipType] = useState("one-to-one");
+ const [connectionType, setConnectionType] = useState("simple-key");
+ const [fromFieldName, setFromFieldName] = useState("");
+ const [toFieldName, setToFieldName] = useState("");
+ const [description, setDescription] = useState("");
+
+ // 모달이 열릴 때마다 초기화
+ useEffect(() => {
+ if (isOpen && connection) {
+ // 기본 관계명 생성
+ const defaultName = `${connection.fromNode.screenName}_${connection.toNode.screenName}`;
+ setRelationshipName(defaultName);
+ setRelationshipType("one-to-one");
+ setConnectionType("simple-key");
+ // 시작/대상 필드는 비워둠 (다음 기능에서 사용)
+ setFromFieldName("");
+ setToFieldName("");
+ setDescription("");
+ }
+ }, [isOpen, connection]);
+
+ const handleConfirm = () => {
+ if (!relationshipName.trim()) {
+ alert("관계명을 입력해주세요.");
+ return;
+ }
+
+ const config: ConnectionConfig = {
+ relationshipName: relationshipName.trim(),
+ relationshipType,
+ connectionType,
+ fromFieldName,
+ toFieldName,
+ description: description.trim() || undefined,
+ };
+
+ onConfirm(config);
+ };
+
+ const getRelationshipTypeDescription = (type: string) => {
+ switch (type) {
+ case "one-to-one":
+ return "1:1 - 한 레코드가 다른 테이블의 한 레코드와 연결";
+ case "one-to-many":
+ return "1:N - 한 레코드가 다른 테이블의 여러 레코드와 연결";
+ case "many-to-one":
+ return "N:1 - 여러 레코드가 다른 테이블의 한 레코드와 연결";
+ case "many-to-many":
+ return "N:N - 여러 레코드가 다른 테이블의 여러 레코드와 연결 (중계 테이블 생성)";
+ default:
+ return "";
+ }
+ };
+
+ const getConnectionTypeDescription = (type: string) => {
+ switch (type) {
+ case "simple-key":
+ return "단순 키값 연결 - 기본 참조 관계";
+ case "data-save":
+ return "데이터 저장 - 필드 매핑을 통한 데이터 저장";
+ case "external-call":
+ return "외부 호출 - API, 이메일, 웹훅 등을 통한 외부 시스템 연동";
+ default:
+ return "";
+ }
+ };
+
+ if (!connection) return null;
+
+ return (
+
+
+
+
+
+ 필드 연결 설정
+
+
+
+
+ {/* 연결 정보 표시 */}
+
+
+ 연결 정보
+
+
+ {connection.selectedFieldsData && connection.orderedScreenIds ? (
+
+ {/* orderedScreenIds 순서대로 표시 */}
+ {connection.orderedScreenIds.map((screenId, index) => {
+ const screenData = connection.selectedFieldsData[screenId];
+ if (!screenData) return null;
+
+ return (
+
+
+
+
+ {screenData.screenName}
+
+
ID: {screenId}
+
+
+ {index === 0 ? connection.fromNode.tableName : connection.toNode.tableName}
+
+
+
+ {screenData.fields.map((field) => (
+
+ {field}
+
+ ))}
+
+
+ {/* 첫 번째 화면 다음에 화살표 표시 */}
+ {index === 0 && connection.orderedScreenIds.length > 1 && (
+
+ )}
+
+ );
+ })}
+
+ ) : (
+
+
+
{connection.fromNode.screenName}
+
+
+ {connection.fromNode.tableName}
+
+
+
+
+
{connection.toNode.screenName}
+
+
+ {connection.toNode.tableName}
+
+
+
+ )}
+
+
+
+
+ {/* 기본 설정 */}
+
+
기본 설정
+
+
+ 관계명 *
+ setRelationshipName(e.target.value)}
+ placeholder="관계를 설명하는 이름을 입력하세요"
+ />
+
+
+
+ 시작 필드 *
+ setFromFieldName(e.target.value)}
+ placeholder="시작 테이블의 필드명"
+ />
+
+
+
+ 대상 필드 *
+ setToFieldName(e.target.value)}
+ placeholder="대상 테이블의 필드명"
+ />
+
+
+
+ 설명
+
+
+
+ {/* 관계 설정 */}
+
+
관계 설정
+
+
+
관계 타입
+
setRelationshipType(value)}>
+
+
+
+
+ 1:1 (One to One)
+ 1:N (One to Many)
+ N:1 (Many to One)
+ N:N (Many to Many)
+
+
+
{getRelationshipTypeDescription(relationshipType)}
+
+
+
+
연결 종류
+
setConnectionType(value)}>
+
+
+
+
+ 단순 키값 연결
+ 데이터 저장
+ 외부 호출
+
+
+
{getConnectionTypeDescription(connectionType)}
+
+
+ {/* N:N 관계일 때 추가 정보 */}
+ {relationshipType === "many-to-many" && (
+
+
+
+
N:N 관계 안내:
+
+ • 중계 테이블이 자동으로 생성됩니다
+
+ • 테이블명: {connection.fromNode.tableName}_{connection.toNode.tableName}
+
+ • 양쪽 테이블의 키를 참조하는 컬럼이 생성됩니다
+
+
+
+
+ )}
+
+
+
+
+
+
+ 취소
+
+ 연결 생성
+
+
+
+ );
+};
diff --git a/frontend/components/dataflow/DataFlowDesigner.tsx b/frontend/components/dataflow/DataFlowDesigner.tsx
index 8d70c2b6..22720648 100644
--- a/frontend/components/dataflow/DataFlowDesigner.tsx
+++ b/frontend/components/dataflow/DataFlowDesigner.tsx
@@ -1,6 +1,7 @@
"use client";
-import React, { useState, useCallback } from "react";
+import React, { useState, useCallback, useEffect, useRef } from "react";
+import toast from "react-hot-toast";
import {
ReactFlow,
Node,
@@ -16,6 +17,9 @@ import {
import "@xyflow/react/dist/style.css";
import { ScreenNode } from "./ScreenNode";
import { CustomEdge } from "./CustomEdge";
+import { ScreenSelector } from "./ScreenSelector";
+import { ConnectionSetupModal } from "./ConnectionSetupModal";
+import { DataFlowAPI, ScreenDefinition, ColumnInfo, ScreenWithFields } from "@/lib/api/dataflow";
// 노드 및 엣지 타입 정의
const nodeTypes = {
@@ -34,45 +38,222 @@ interface DataFlowDesignerProps {
export const DataFlowDesigner: React.FC = ({ companyCode, onSave }) => {
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
- const [selectedField, setSelectedField] = useState<{
- screenId: string;
- fieldName: string;
- } | null>(null);
-
- // 노드 연결 처리
- const onConnect = useCallback(
- (params: Connection) => {
- const newEdge = {
- ...params,
- type: "customEdge",
- data: {
- relationshipType: "one-to-one",
- connectionType: "simple-key",
- label: "1:1 연결",
- },
+ const [selectedFields, setSelectedFields] = useState<{
+ [screenId: string]: string[];
+ }>({});
+ const [selectionOrder, setSelectionOrder] = useState([]);
+ const [loadingScreens, setLoadingScreens] = useState>(new Set());
+ const [pendingConnection, setPendingConnection] = useState<{
+ fromNode: { id: string; screenName: string; tableName: string };
+ toNode: { id: string; screenName: string; tableName: string };
+ fromField?: string;
+ toField?: string;
+ selectedFieldsData?: {
+ [screenId: string]: {
+ screenName: string;
+ fields: string[];
};
- setEdges((eds) => addEdge(newEdge, eds));
- },
- [setEdges],
- );
+ };
+ } | null>(null);
+ const [isOverNodeScrollArea, setIsOverNodeScrollArea] = useState(false);
+ const toastShownRef = useRef(false);
- // 필드 클릭 처리
- const handleFieldClick = useCallback((screenId: string, fieldName: string) => {
- setSelectedField({ screenId, fieldName });
+ // 빈 onConnect 함수 (드래그 연결 비활성화)
+ const onConnect = useCallback(() => {
+ // 드래그로 연결하는 것을 방지
+ return;
}, []);
- // 샘플 화면 노드 추가
+ // 필드 클릭 처리 (토글 방식, 최대 2개 화면만 허용)
+ const handleFieldClick = useCallback((screenId: string, fieldName: string) => {
+ setSelectedFields((prev) => {
+ const currentFields = prev[screenId] || [];
+ const isSelected = currentFields.includes(fieldName);
+ const selectedScreens = Object.keys(prev).filter((id) => prev[id] && prev[id].length > 0);
+
+ if (isSelected) {
+ // 선택 해제
+ const newFields = currentFields.filter((field) => field !== fieldName);
+ if (newFields.length === 0) {
+ const { [screenId]: _, ...rest } = prev;
+ // 선택 순서에서도 제거 (다음 렌더링에서)
+ setTimeout(() => {
+ setSelectionOrder((order) => order.filter((id) => id !== screenId));
+ }, 0);
+ return rest;
+ }
+ return { ...prev, [screenId]: newFields };
+ } else {
+ // 선택 추가 - 새로운 화면이고 이미 2개 화면이 선택되어 있으면 거부
+ if (!prev[screenId] && selectedScreens.length >= 2) {
+ // 토스트 중복 방지를 위한 ref 사용
+ if (!toastShownRef.current) {
+ toastShownRef.current = true;
+ setTimeout(() => {
+ toast.error("최대 2개의 화면에서만 필드를 선택할 수 있습니다.", {
+ duration: 3000,
+ position: "top-center",
+ });
+ // 3초 후 플래그 리셋
+ setTimeout(() => {
+ toastShownRef.current = false;
+ }, 3000);
+ }, 0);
+ }
+ return prev;
+ }
+
+ // 새로운 화면이면 선택 순서에 추가, 기존 화면이면 맨 뒤로 이동 (다음 렌더링에서)
+ setTimeout(() => {
+ setSelectionOrder((order) => {
+ // 기존에 있던 화면이면 제거 후 맨 뒤에 추가 (순서 갱신)
+ const filteredOrder = order.filter((id) => id !== screenId);
+ return [...filteredOrder, screenId];
+ });
+ }, 0);
+
+ return { ...prev, [screenId]: [...currentFields, fieldName] };
+ }
+ });
+ }, []);
+
+ // 선택된 필드가 변경될 때마다 기존 노드들 업데이트 및 selectionOrder 정리
+ useEffect(() => {
+ setNodes((prevNodes) =>
+ prevNodes.map((node) => ({
+ ...node,
+ data: {
+ ...node.data,
+ selectedFields: selectedFields[node.data.screen.screenId] || [],
+ },
+ })),
+ );
+
+ // selectionOrder에서 선택되지 않은 화면들 제거
+ const activeScreens = Object.keys(selectedFields).filter(
+ (screenId) => selectedFields[screenId] && selectedFields[screenId].length > 0,
+ );
+ setSelectionOrder((prev) => prev.filter((screenId) => activeScreens.includes(screenId)));
+ }, [selectedFields, setNodes]);
+
+ // 연결 가능한 상태인지 확인
+ const canCreateConnection = () => {
+ const selectedScreens = Object.keys(selectedFields).filter(
+ (screenId) => selectedFields[screenId] && selectedFields[screenId].length > 0,
+ );
+
+ // 최소 2개의 서로 다른 테이블에서 필드가 선택되어야 함
+ return selectedScreens.length >= 2;
+ };
+
+ // 필드 연결 설정 모달 열기
+ const openConnectionModal = () => {
+ const selectedScreens = Object.keys(selectedFields).filter(
+ (screenId) => selectedFields[screenId] && selectedFields[screenId].length > 0,
+ );
+
+ if (selectedScreens.length < 2) return;
+
+ // 선택 순서에 따라 첫 번째와 두 번째 화면 설정
+ const orderedScreens = selectionOrder.filter((id) => selectedScreens.includes(id));
+ const firstScreenId = orderedScreens[0];
+ const secondScreenId = orderedScreens[1];
+ const firstNode = nodes.find((node) => node.data.screen.screenId === firstScreenId);
+ const secondNode = nodes.find((node) => node.data.screen.screenId === secondScreenId);
+
+ if (!firstNode || !secondNode) return;
+
+ setPendingConnection({
+ fromNode: {
+ id: firstNode.id,
+ screenName: firstNode.data.screen.screenName,
+ tableName: firstNode.data.screen.tableName,
+ },
+ toNode: {
+ id: secondNode.id,
+ screenName: secondNode.data.screen.screenName,
+ tableName: secondNode.data.screen.tableName,
+ },
+ // 선택된 모든 필드 정보를 선택 순서대로 전달
+ selectedFieldsData: (() => {
+ const orderedData: { [key: string]: { screenName: string; fields: string[] } } = {};
+ // selectionOrder 순서대로 데이터 구성 (첫 번째 선택이 먼저)
+ orderedScreens.forEach((screenId) => {
+ const node = nodes.find((n) => n.data.screen.screenId === screenId);
+ if (node && selectedFields[screenId]) {
+ orderedData[screenId] = {
+ screenName: node.data.screen.screenName,
+ fields: selectedFields[screenId],
+ };
+ }
+ });
+ return orderedData;
+ })(),
+ // 명시적인 순서 정보 전달
+ orderedScreenIds: orderedScreens,
+ });
+ };
+
+ // 실제 화면 노드 추가
+ const addScreenNode = useCallback(
+ async (screen: ScreenDefinition) => {
+ try {
+ setLoadingScreens((prev) => new Set(prev).add(screen.screenId));
+
+ // 테이블 컬럼 정보 조회
+ const columns = await DataFlowAPI.getTableColumns(screen.tableName);
+
+ const newNode: Node = {
+ id: `screen-${screen.screenId}`,
+ type: "screenNode",
+ position: { x: Math.random() * 300, y: Math.random() * 200 },
+ data: {
+ screen: {
+ screenId: screen.screenId.toString(),
+ screenName: screen.screenName,
+ screenCode: screen.screenCode,
+ tableName: screen.tableName,
+ fields: columns.map((col) => ({
+ name: col.columnName || "unknown",
+ type: col.dataType || col.dbType || "UNKNOWN",
+ description:
+ col.columnLabel || col.displayName || col.description || col.columnName || "No description",
+ })),
+ },
+ onFieldClick: handleFieldClick,
+ onScrollAreaEnter: () => setIsOverNodeScrollArea(true),
+ onScrollAreaLeave: () => setIsOverNodeScrollArea(false),
+ selectedFields: selectedFields[screen.screenId] || [],
+ },
+ };
+
+ setNodes((nds) => nds.concat(newNode));
+ } catch (error) {
+ console.error("화면 노드 추가 실패:", error);
+ alert("화면 정보를 불러오는데 실패했습니다.");
+ } finally {
+ setLoadingScreens((prev) => {
+ const newSet = new Set(prev);
+ newSet.delete(screen.screenId);
+ return newSet;
+ });
+ }
+ },
+ [handleFieldClick, setNodes],
+ );
+
+ // 샘플 화면 노드 추가 (개발용)
const addSampleNode = useCallback(() => {
const newNode: Node = {
- id: `screen-${Date.now()}`,
+ id: `sample-${Date.now()}`,
type: "screenNode",
position: { x: Math.random() * 300, y: Math.random() * 200 },
data: {
screen: {
- screenId: `screen-${Date.now()}`,
+ screenId: `sample-${Date.now()}`,
screenName: `샘플 화면 ${nodes.length + 1}`,
- screenCode: `SCREEN${nodes.length + 1}`,
- tableName: `table_${nodes.length + 1}`,
+ screenCode: `SAMPLE${nodes.length + 1}`,
+ tableName: `sample_table_${nodes.length + 1}`,
fields: [
{ name: "id", type: "INTEGER", description: "고유 식별자" },
{ name: "name", type: "VARCHAR(100)", description: "이름" },
@@ -93,6 +274,45 @@ export const DataFlowDesigner: React.FC = ({ companyCode,
setEdges([]);
}, [setNodes, setEdges]);
+ // 현재 추가된 화면 ID 목록 가져오기
+ const getSelectedScreenIds = useCallback(() => {
+ return nodes
+ .filter((node) => node.id.startsWith("screen-"))
+ .map((node) => parseInt(node.id.replace("screen-", "")))
+ .filter((id) => !isNaN(id));
+ }, [nodes]);
+
+ // 연결 설정 확인
+ const handleConfirmConnection = useCallback(
+ (config: any) => {
+ if (!pendingConnection) return;
+
+ const newEdge = {
+ id: `edge-${Date.now()}`,
+ source: pendingConnection.fromNode.id,
+ target: pendingConnection.toNode.id,
+ type: "customEdge",
+ data: {
+ relationshipType: config.relationshipType,
+ connectionType: config.connectionType,
+ label: config.relationshipName,
+ },
+ };
+
+ setEdges((eds) => [...eds, newEdge]);
+ setPendingConnection(null);
+
+ // TODO: 백엔드 API 호출하여 관계 저장
+ console.log("연결 설정:", config);
+ },
+ [pendingConnection, setEdges],
+ );
+
+ // 연결 설정 취소
+ const handleCancelConnection = useCallback(() => {
+ setPendingConnection(null);
+ }, []);
+
return (
@@ -107,13 +327,20 @@ export const DataFlowDesigner: React.FC
= ({ companyCode,
{companyCode}
+ {/* 화면 선택기 */}
+
+
{/* 컨트롤 버튼들 */}
- + 샘플 화면 추가
+ + 샘플 화면 추가 (개발용)
= ({ companyCode,
{/* 선택된 필드 정보 */}
- {selectedField && (
-
-
선택된 필드
-
-
화면: {selectedField.screenId}
-
필드: {selectedField.fieldName}
+ {Object.keys(selectedFields).length > 0 && (
+
+
+
선택된 필드
+
+ {[...new Set(selectionOrder)]
+ .filter((screenId) => selectedFields[screenId] && selectedFields[screenId].length > 0)
+ .map((screenId, index, filteredOrder) => {
+ const fields = selectedFields[screenId];
+ const node = nodes.find((n) => n.data.screen.screenId === screenId);
+ const screenName = node?.data.screen.screenName || screenId;
+
+ return (
+
+
+
+
+ {screenName}
+
+
ID: {screenId}
+
+
+ {fields.map((field, fieldIndex) => (
+
+ {field}
+
+ ))}
+
+
+ {/* 첫 번째 화면 다음에 화살표 표시 */}
+ {index === 0 && filteredOrder.length > 1 && (
+
+ )}
+
+ );
+ })}
+
+
+
+ 필드 연결 설정
+
+ {
+ setSelectedFields({});
+ setSelectionOrder([]);
+ }}
+ className="rounded bg-gray-200 px-3 py-1 text-xs font-medium text-gray-600 hover:bg-gray-300"
+ >
+ 선택 초기화
+
+
-
setSelectedField(null)}
- className="mt-2 text-xs text-yellow-600 hover:text-yellow-800"
- >
- 선택 해제
-
)}
@@ -177,6 +457,10 @@ export const DataFlowDesigner: React.FC
= ({ companyCode,
edgeTypes={edgeTypes}
fitView
attributionPosition="bottom-left"
+ panOnScroll={false}
+ zoomOnScroll={true}
+ zoomOnPinch={true}
+ panOnDrag={true}
>
= ({ companyCode,
📊
데이터 흐름 설계를 시작하세요
-
왼쪽 사이드바에서 "샘플 화면 추가" 버튼을 클릭하세요
+
왼쪽 사이드바에서 화면을 선택하여 추가하세요
)}
+
+ {/* 연결 설정 모달 */}
+
);
};
diff --git a/frontend/components/dataflow/ScreenNode.tsx b/frontend/components/dataflow/ScreenNode.tsx
index 00838449..a4fd83ca 100644
--- a/frontend/components/dataflow/ScreenNode.tsx
+++ b/frontend/components/dataflow/ScreenNode.tsx
@@ -1,7 +1,7 @@
"use client";
import React from "react";
-import { Handle, Position } from "@xyflow/react";
+import { Handle, Position, NodeResizer } from "@xyflow/react";
interface ScreenField {
name: string;
@@ -20,22 +20,31 @@ interface Screen {
interface ScreenNodeData {
screen: Screen;
onFieldClick: (screenId: string, fieldName: string) => void;
+ onScrollAreaEnter?: () => void;
+ onScrollAreaLeave?: () => void;
+ selected?: boolean;
+ selectedFields?: string[]; // 선택된 필드 목록
}
-export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
- const { screen, onFieldClick } = data;
+export const ScreenNode: React.FC<{ data: ScreenNodeData; selected?: boolean }> = ({ data, selected }) => {
+ const { screen, onFieldClick, onScrollAreaEnter, onScrollAreaLeave, selectedFields = [] } = data;
// 필드 타입에 따른 색상 반환
const getFieldTypeColor = (type: string) => {
- if (type.includes("INTEGER") || type.includes("NUMERIC")) return "text-blue-600 bg-blue-50";
- if (type.includes("VARCHAR") || type.includes("TEXT")) return "text-green-600 bg-green-50";
- if (type.includes("TIMESTAMP") || type.includes("DATE")) return "text-purple-600 bg-purple-50";
- if (type.includes("BOOLEAN")) return "text-orange-600 bg-orange-50";
+ if (!type || typeof type !== "string") return "text-gray-600 bg-gray-50";
+
+ const upperType = type.toUpperCase();
+ if (upperType.includes("INTEGER") || upperType.includes("NUMERIC")) return "text-blue-600 bg-blue-50";
+ if (upperType.includes("VARCHAR") || upperType.includes("TEXT")) return "text-green-600 bg-green-50";
+ if (upperType.includes("TIMESTAMP") || upperType.includes("DATE")) return "text-purple-600 bg-purple-50";
+ if (upperType.includes("BOOLEAN")) return "text-orange-600 bg-orange-50";
return "text-gray-600 bg-gray-50";
};
return (
-
+
+ {/* NodeResizer - 선택된 경우에만 표시 */}
+ {selected &&
}
{/* 노드 헤더 */}
{screen.screenName}
@@ -47,63 +56,46 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
{/* 필드 목록 */}
-
+
필드 목록
{screen.fields.length}개
-
- {screen.fields.map((field, index) => (
-
onFieldClick(screen.screenId, field.name)}
- >
-
-
-
{field.name}
- {index === 0 && (
-
PK
- )}
+
+ {screen.fields.map((field, index) => {
+ const isSelected = selectedFields.includes(field.name);
+ return (
+
onFieldClick(screen.screenId, field.name)}
+ >
+
+
+
{field.name}
+ {index === 0 && (
+
PK
+ )}
+
+
{field.description}
+
+
+ {field.type}
-
{field.description}
-
- {field.type}
-
-
- ))}
+ );
+ })}
-
- {/* React Flow 핸들 */}
-
-
-
- {/* 추가 핸들들 (상하) */}
-
-
);
};
diff --git a/frontend/components/dataflow/ScreenSelector.tsx b/frontend/components/dataflow/ScreenSelector.tsx
new file mode 100644
index 00000000..a3b71392
--- /dev/null
+++ b/frontend/components/dataflow/ScreenSelector.tsx
@@ -0,0 +1,174 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Search, Plus, Database, Calendar, User } from "lucide-react";
+import { DataFlowAPI, ScreenDefinition } from "@/lib/api/dataflow";
+
+interface ScreenSelectorProps {
+ companyCode: string;
+ onScreenAdd: (screen: ScreenDefinition) => void;
+ selectedScreens?: number[]; // 이미 추가된 화면들의 ID
+}
+
+export const ScreenSelector: React.FC
= ({ companyCode, onScreenAdd, selectedScreens = [] }) => {
+ const [screens, setScreens] = useState([]);
+ const [filteredScreens, setFilteredScreens] = useState([]);
+ const [searchTerm, setSearchTerm] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ // 화면 목록 로드
+ useEffect(() => {
+ loadScreens();
+ }, [companyCode]);
+
+ // 검색 필터링
+ useEffect(() => {
+ if (searchTerm.trim() === "") {
+ setFilteredScreens(screens);
+ } else {
+ const filtered = screens.filter(
+ (screen) =>
+ screen.screenName.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ screen.screenCode.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ screen.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ (screen.description && screen.description.toLowerCase().includes(searchTerm.toLowerCase())),
+ );
+ setFilteredScreens(filtered);
+ }
+ }, [screens, searchTerm]);
+
+ const loadScreens = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const screenList = await DataFlowAPI.getScreensByCompany(companyCode);
+ setScreens(screenList);
+ } catch (error) {
+ console.error("화면 목록 로드 실패:", error);
+ setError("화면 목록을 불러오는데 실패했습니다. 로그인 상태를 확인해주세요.");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleAddScreen = (screen: ScreenDefinition) => {
+ onScreenAdd(screen);
+ };
+
+ const isScreenSelected = (screenId: number) => {
+ return selectedScreens.includes(screenId);
+ };
+
+ const formatDate = (dateString: string) => {
+ return new Date(dateString).toLocaleDateString("ko-KR", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ });
+ };
+
+ return (
+
+
+
화면 선택
+
+ {loading ? "로딩중..." : "새로고침"}
+
+
+
+ {/* 검색 입력 */}
+
+
+ setSearchTerm(e.target.value)}
+ className="pl-10"
+ />
+
+
+ {/* 오류 메시지 */}
+ {error &&
{error}
}
+
+ {/* 화면 목록 */}
+
+ {loading ? (
+
+ ) : filteredScreens.length === 0 ? (
+
+
+ {searchTerm ? "검색 결과가 없습니다." : "등록된 화면이 없습니다."}
+
+
+ ) : (
+ filteredScreens.map((screen) => (
+
+
+
+
{screen.screenName}
+
+
+ {screen.isActive === "Y" ? "활성" : "비활성"}
+
+
handleAddScreen(screen)}
+ disabled={isScreenSelected(screen.screenId)}
+ size="sm"
+ className="h-7 px-2"
+ >
+
+ {isScreenSelected(screen.screenId) ? "추가됨" : "추가"}
+
+
+
+
+
+
+
+ {screen.screenCode}
+
+ {screen.tableName}
+
+
+ {screen.description &&
{screen.description}
}
+
+
+
+
+ {screen.createdBy || "시스템"}
+
+
+
+ {formatDate(screen.createdDate)}
+
+
+
+
+
+ ))
+ )}
+
+
+ {/* 통계 정보 */}
+
+
+ 전체 화면: {screens.length}개
+ {searchTerm ? `검색 결과: ${filteredScreens.length}개` : ""}
+
+ {selectedScreens.length > 0 &&
선택된 화면: {selectedScreens.length}개
}
+
+
+ );
+};
diff --git a/frontend/lib/api/dataflow.ts b/frontend/lib/api/dataflow.ts
new file mode 100644
index 00000000..39b4dd26
--- /dev/null
+++ b/frontend/lib/api/dataflow.ts
@@ -0,0 +1,202 @@
+import { apiClient, ApiResponse } from "./client";
+
+// 데이터 흐름 관리 관련 타입 정의
+export interface ScreenDefinition {
+ screenId: number;
+ screenName: string;
+ screenCode: string;
+ tableName: string;
+ companyCode: string;
+ description?: string;
+ isActive: string;
+ createdDate: string;
+ createdBy?: string;
+ updatedDate: string;
+ updatedBy?: string;
+}
+
+export interface ColumnInfo {
+ columnName: string;
+ columnLabel?: string;
+ displayName?: string;
+ dataType?: string;
+ dbType?: string;
+ webType?: string;
+ isNullable?: string;
+ columnDefault?: string;
+ characterMaximumLength?: number;
+ numericPrecision?: number;
+ numericScale?: number;
+ detailSettings?: string;
+ codeCategory?: string;
+ referenceTable?: string;
+ referenceColumn?: string;
+ isVisible?: string;
+ displayOrder?: number;
+ description?: string;
+}
+
+export interface ScreenWithFields extends ScreenDefinition {
+ fields: ColumnInfo[];
+}
+
+export interface ScreenRelationship {
+ relationshipId?: number;
+ relationshipName: string;
+ fromScreenId: number;
+ fromFieldName: string;
+ toScreenId: number;
+ toFieldName: string;
+ relationshipType: "one-to-one" | "one-to-many" | "many-to-one" | "many-to-many";
+ connectionType: "simple-key" | "data-save" | "external-call";
+ settings?: Record;
+ companyCode: string;
+ isActive?: string;
+}
+
+// 데이터 흐름 관리 API 클래스
+export class DataFlowAPI {
+ /**
+ * 회사별 화면 목록 조회
+ */
+ static async getScreensByCompany(companyCode: string): Promise {
+ try {
+ const response = await apiClient.get>("/screen-management/screens", {
+ params: { companyCode },
+ });
+
+ if (!response.data.success) {
+ throw new Error(response.data.message || "화면 목록 조회에 실패했습니다.");
+ }
+
+ return response.data.data || [];
+ } catch (error) {
+ console.error("화면 목록 조회 오류:", error);
+ throw error;
+ }
+ }
+
+ /**
+ * 테이블 컬럼 정보 조회
+ */
+ static async getTableColumns(tableName: string): Promise {
+ try {
+ const response = await apiClient.get>(`/table-management/tables/${tableName}/columns`);
+
+ if (!response.data.success) {
+ throw new Error(response.data.message || "컬럼 정보 조회에 실패했습니다.");
+ }
+
+ return response.data.data || [];
+ } catch (error) {
+ console.error("컬럼 정보 조회 오류:", error);
+ throw error;
+ }
+ }
+
+ /**
+ * 화면과 필드 정보를 함께 조회
+ */
+ static async getScreenWithFields(screenId: number, tableName: string): Promise {
+ try {
+ // 화면 정보와 컬럼 정보를 병렬로 조회
+ const [screensResponse, columnsResponse] = await Promise.all([
+ this.getScreensByCompany("*"), // 전체 화면 목록에서 해당 화면 찾기
+ this.getTableColumns(tableName),
+ ]);
+
+ const screen = screensResponse.find((s) => s.screenId === screenId);
+ if (!screen) {
+ return null;
+ }
+
+ return {
+ ...screen,
+ fields: columnsResponse,
+ };
+ } catch (error) {
+ console.error("화면 및 필드 정보 조회 오류:", error);
+ throw error;
+ }
+ }
+
+ /**
+ * 화면 관계 생성
+ */
+ static async createRelationship(
+ relationship: Omit,
+ ): Promise {
+ try {
+ const response = await apiClient.post>("/dataflow/relationships", relationship);
+
+ if (!response.data.success) {
+ throw new Error(response.data.message || "관계 생성에 실패했습니다.");
+ }
+
+ return response.data.data!;
+ } catch (error) {
+ console.error("관계 생성 오류:", error);
+ throw error;
+ }
+ }
+
+ /**
+ * 회사별 화면 관계 목록 조회
+ */
+ static async getRelationshipsByCompany(companyCode: string): Promise {
+ try {
+ const response = await apiClient.get>("/dataflow/relationships", {
+ params: { companyCode },
+ });
+
+ if (!response.data.success) {
+ throw new Error(response.data.message || "관계 목록 조회에 실패했습니다.");
+ }
+
+ return response.data.data || [];
+ } catch (error) {
+ console.error("관계 목록 조회 오류:", error);
+ throw error;
+ }
+ }
+
+ /**
+ * 화면 관계 수정
+ */
+ static async updateRelationship(
+ relationshipId: number,
+ relationship: Partial,
+ ): Promise {
+ try {
+ const response = await apiClient.put>(
+ `/dataflow/relationships/${relationshipId}`,
+ relationship,
+ );
+
+ if (!response.data.success) {
+ throw new Error(response.data.message || "관계 수정에 실패했습니다.");
+ }
+
+ return response.data.data!;
+ } catch (error) {
+ console.error("관계 수정 오류:", error);
+ throw error;
+ }
+ }
+
+ /**
+ * 화면 관계 삭제
+ */
+ static async deleteRelationship(relationshipId: number): Promise {
+ try {
+ const response = await apiClient.delete>(`/dataflow/relationships/${relationshipId}`);
+
+ if (!response.data.success) {
+ throw new Error(response.data.message || "관계 삭제에 실패했습니다.");
+ }
+ } catch (error) {
+ console.error("관계 삭제 오류:", error);
+ throw error;
+ }
+ }
+}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 50d96947..4146f31a 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -39,6 +39,7 @@
"react-day-picker": "^9.9.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.62.0",
+ "react-hot-toast": "^2.6.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"zod": "^4.1.5"
@@ -3889,7 +3890,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
- "devOptional": true,
"license": "MIT"
},
"node_modules/d3-color": {
@@ -5317,6 +5317,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/goober": {
+ "version": "2.1.16",
+ "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz",
+ "integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==",
+ "license": "MIT",
+ "peerDependencies": {
+ "csstype": "^3.0.10"
+ }
+ },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -7224,6 +7233,23 @@
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
+ "node_modules/react-hot-toast": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
+ "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.1.3",
+ "goober": "^2.1.16"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "react": ">=16",
+ "react-dom": ">=16"
+ }
+ },
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index d299c50e..0b02eb78 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -44,6 +44,7 @@
"react-day-picker": "^9.9.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.62.0",
+ "react-hot-toast": "^2.6.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"zod": "^4.1.5"