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="대상 테이블의 필드명" + /> +
+ +
+ +