phase2 70% 완료
This commit is contained in:
@@ -626,12 +626,17 @@ PUT /api/external-call-configs/:id
|
|||||||
- [x] 노드 간 드래그앤드롭 연결 기능
|
- [x] 노드 간 드래그앤드롭 연결 기능
|
||||||
- [x] 줌, 팬, 미니맵 등 React Flow 기본 기능
|
- [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주)
|
### Phase 3: 고급 연결 타입 (2-3주)
|
||||||
|
|
||||||
@@ -672,23 +677,39 @@ PUT /api/external-call-configs/:id
|
|||||||
**구현된 기능:**
|
**구현된 기능:**
|
||||||
|
|
||||||
- React Flow 12.8.4 기반 시각적 캔버스
|
- React Flow 12.8.4 기반 시각적 캔버스
|
||||||
- 화면 노드 컴포넌트 (필드 정보, 타입별 색상 구분)
|
- 화면 노드 컴포넌트 (필드 정보, 타입별 색상 구분, 노드 리사이징)
|
||||||
- 커스텀 엣지 컴포넌트 (관계 타입별 스타일링)
|
- 커스텀 엣지 컴포넌트 (관계 타입별 스타일링)
|
||||||
- 드래그앤드롭 노드 배치 및 연결
|
- 드래그앤드롭 노드 배치 및 연결
|
||||||
- 줌, 팬, 미니맵 등 고급 시각화 기능
|
- 줌, 팬, 미니맵 등 고급 시각화 기능 (스크롤 충돌 해결)
|
||||||
- 샘플 데이터 생성 및 관리 기능
|
- 실제 화면 데이터 연동 (테이블 관리 API 연결)
|
||||||
|
- 필드-to-필드 연결 시스템 (클릭 기반, 2개 화면 제한)
|
||||||
|
- 연결 설정 모달 (관계 타입, 연결 종류 선택 UI)
|
||||||
- /admin/dataflow 경로 설정
|
- /admin/dataflow 경로 설정
|
||||||
- 메뉴 시스템 연동 준비 완료
|
- 메뉴 시스템 연동 완료
|
||||||
|
- 사용자 경험 개선 (토스트 알림, 선택 순서 보장)
|
||||||
|
|
||||||
**구현된 파일:**
|
**구현된 파일:**
|
||||||
|
|
||||||
- `frontend/components/dataflow/DataFlowDesigner.tsx`
|
- `frontend/components/dataflow/DataFlowDesigner.tsx` - 메인 캔버스 컴포넌트
|
||||||
- `frontend/components/dataflow/ScreenNode.tsx`
|
- `frontend/components/dataflow/ScreenNode.tsx` - 화면 노드 컴포넌트 (NodeResizer 포함)
|
||||||
- `frontend/components/dataflow/CustomEdge.tsx`
|
- `frontend/components/dataflow/CustomEdge.tsx` - 커스텀 엣지 컴포넌트
|
||||||
- `frontend/app/(main)/admin/dataflow/page.tsx`
|
- `frontend/components/dataflow/ConnectionSetupModal.tsx` - 연결 설정 모달
|
||||||
- `docs/add_dataflow_menu.sql` (메뉴 추가 스크립트)
|
- `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 - 실제 연결 생성 및 시각적 표시 기능 구현
|
||||||
|
|
||||||
### 주요 가치
|
### 주요 가치
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,22 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import React from 'react';
|
import React from "react";
|
||||||
import { DataFlowDesigner } from '@/components/dataflow/DataFlowDesigner';
|
import { Toaster } from "react-hot-toast";
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { DataFlowDesigner } from "@/components/dataflow/DataFlowDesigner";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
|
||||||
export default function DataFlowPage() {
|
export default function DataFlowPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
const handleSave = (relationships: any[]) => {
|
const handleSave = (relationships: any[]) => {
|
||||||
console.log('저장된 관계:', relationships);
|
console.log("저장된 관계:", relationships);
|
||||||
// TODO: API 호출로 관계 저장
|
// TODO: API 호출로 관계 저장
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen bg-gray-50">
|
<div className="h-screen bg-gray-50">
|
||||||
<DataFlowDesigner
|
<DataFlowDesigner companyCode={user?.companyCode || "COMP001"} onSave={handleSave} />
|
||||||
companyCode={user?.companyCode || 'COMP001'}
|
<Toaster />
|
||||||
onSave={handleSave}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
320
frontend/components/dataflow/ConnectionSetupModal.tsx
Normal file
320
frontend/components/dataflow/ConnectionSetupModal.tsx
Normal file
@@ -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<string, any>;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConnectionSetupModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
connection: ConnectionInfo | null;
|
||||||
|
onConfirm: (config: ConnectionConfig) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
connection,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}) => {
|
||||||
|
const [relationshipName, setRelationshipName] = useState("");
|
||||||
|
const [relationshipType, setRelationshipType] = useState<ConnectionConfig["relationshipType"]>("one-to-one");
|
||||||
|
const [connectionType, setConnectionType] = useState<ConnectionConfig["connectionType"]>("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 (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onCancel}>
|
||||||
|
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Link className="h-5 w-5" />
|
||||||
|
필드 연결 설정
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 연결 정보 표시 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm">연결 정보</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{connection.selectedFieldsData && connection.orderedScreenIds ? (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* orderedScreenIds 순서대로 표시 */}
|
||||||
|
{connection.orderedScreenIds.map((screenId, index) => {
|
||||||
|
const screenData = connection.selectedFieldsData[screenId];
|
||||||
|
if (!screenData) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={screenId}>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||||
|
<div className="flex-shrink-0 rounded bg-blue-600 px-2 py-1 text-xs font-medium text-white">
|
||||||
|
{screenData.screenName}
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0 text-xs text-gray-500">ID: {screenId}</div>
|
||||||
|
<div className="flex flex-shrink-0 items-center gap-1 text-xs text-gray-500">
|
||||||
|
<Database className="h-3 w-3" />
|
||||||
|
{index === 0 ? connection.fromNode.tableName : connection.toNode.tableName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{screenData.fields.map((field) => (
|
||||||
|
<Badge key={field} variant="outline" className="text-xs">
|
||||||
|
{field}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 첫 번째 화면 다음에 화살표 표시 */}
|
||||||
|
{index === 0 && connection.orderedScreenIds.length > 1 && (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<ArrowRight className="h-5 w-5 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-medium">{connection.fromNode.screenName}</div>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-gray-500">
|
||||||
|
<Database className="h-3 w-3" />
|
||||||
|
{connection.fromNode.tableName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="h-4 w-4 text-gray-400" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-medium">{connection.toNode.screenName}</div>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-gray-500">
|
||||||
|
<Database className="h-3 w-3" />
|
||||||
|
{connection.toNode.tableName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
|
{/* 기본 설정 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold">기본 설정</h3>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="relationshipName">관계명 *</Label>
|
||||||
|
<Input
|
||||||
|
id="relationshipName"
|
||||||
|
value={relationshipName}
|
||||||
|
onChange={(e) => setRelationshipName(e.target.value)}
|
||||||
|
placeholder="관계를 설명하는 이름을 입력하세요"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="fromField">시작 필드 *</Label>
|
||||||
|
<Input
|
||||||
|
id="fromField"
|
||||||
|
value={fromFieldName}
|
||||||
|
onChange={(e) => setFromFieldName(e.target.value)}
|
||||||
|
placeholder="시작 테이블의 필드명"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="toField">대상 필드 *</Label>
|
||||||
|
<Input
|
||||||
|
id="toField"
|
||||||
|
value={toFieldName}
|
||||||
|
onChange={(e) => setToFieldName(e.target.value)}
|
||||||
|
placeholder="대상 테이블의 필드명"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">설명</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="관계에 대한 설명을 입력하세요"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 관계 설정 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold">관계 설정</h3>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>관계 타입</Label>
|
||||||
|
<Select value={relationshipType} onValueChange={(value: any) => setRelationshipType(value)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="관계 타입을 선택하세요" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="one-to-one">1:1 (One to One)</SelectItem>
|
||||||
|
<SelectItem value="one-to-many">1:N (One to Many)</SelectItem>
|
||||||
|
<SelectItem value="many-to-one">N:1 (Many to One)</SelectItem>
|
||||||
|
<SelectItem value="many-to-many">N:N (Many to Many)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-gray-600">{getRelationshipTypeDescription(relationshipType)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>연결 종류</Label>
|
||||||
|
<Select value={connectionType} onValueChange={(value: any) => setConnectionType(value)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="연결 종류를 선택하세요" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="simple-key">단순 키값 연결</SelectItem>
|
||||||
|
<SelectItem value="data-save">데이터 저장</SelectItem>
|
||||||
|
<SelectItem value="external-call">외부 호출</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-gray-600">{getConnectionTypeDescription(connectionType)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* N:N 관계일 때 추가 정보 */}
|
||||||
|
{relationshipType === "many-to-many" && (
|
||||||
|
<Card className="border-yellow-200 bg-yellow-50">
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<div className="text-sm text-yellow-800">
|
||||||
|
<strong>N:N 관계 안내:</strong>
|
||||||
|
<ul className="mt-2 space-y-1 text-xs">
|
||||||
|
<li>• 중계 테이블이 자동으로 생성됩니다</li>
|
||||||
|
<li>
|
||||||
|
• 테이블명: {connection.fromNode.tableName}_{connection.toNode.tableName}
|
||||||
|
</li>
|
||||||
|
<li>• 양쪽 테이블의 키를 참조하는 컬럼이 생성됩니다</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={onCancel} variant="outline">
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleConfirm}>연결 생성</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useCallback } from "react";
|
import React, { useState, useCallback, useEffect, useRef } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
import {
|
import {
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
Node,
|
Node,
|
||||||
@@ -16,6 +17,9 @@ import {
|
|||||||
import "@xyflow/react/dist/style.css";
|
import "@xyflow/react/dist/style.css";
|
||||||
import { ScreenNode } from "./ScreenNode";
|
import { ScreenNode } from "./ScreenNode";
|
||||||
import { CustomEdge } from "./CustomEdge";
|
import { CustomEdge } from "./CustomEdge";
|
||||||
|
import { ScreenSelector } from "./ScreenSelector";
|
||||||
|
import { ConnectionSetupModal } from "./ConnectionSetupModal";
|
||||||
|
import { DataFlowAPI, ScreenDefinition, ColumnInfo, ScreenWithFields } from "@/lib/api/dataflow";
|
||||||
|
|
||||||
// 노드 및 엣지 타입 정의
|
// 노드 및 엣지 타입 정의
|
||||||
const nodeTypes = {
|
const nodeTypes = {
|
||||||
@@ -34,45 +38,222 @@ interface DataFlowDesignerProps {
|
|||||||
export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({ companyCode, onSave }) => {
|
export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({ companyCode, onSave }) => {
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
||||||
const [selectedField, setSelectedField] = useState<{
|
const [selectedFields, setSelectedFields] = useState<{
|
||||||
screenId: string;
|
[screenId: string]: string[];
|
||||||
fieldName: string;
|
}>({});
|
||||||
} | null>(null);
|
const [selectionOrder, setSelectionOrder] = useState<string[]>([]);
|
||||||
|
const [loadingScreens, setLoadingScreens] = useState<Set<number>>(new Set());
|
||||||
// 노드 연결 처리
|
const [pendingConnection, setPendingConnection] = useState<{
|
||||||
const onConnect = useCallback(
|
fromNode: { id: string; screenName: string; tableName: string };
|
||||||
(params: Connection) => {
|
toNode: { id: string; screenName: string; tableName: string };
|
||||||
const newEdge = {
|
fromField?: string;
|
||||||
...params,
|
toField?: string;
|
||||||
type: "customEdge",
|
selectedFieldsData?: {
|
||||||
data: {
|
[screenId: string]: {
|
||||||
relationshipType: "one-to-one",
|
screenName: string;
|
||||||
connectionType: "simple-key",
|
fields: string[];
|
||||||
label: "1:1 연결",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
setEdges((eds) => addEdge(newEdge, eds));
|
};
|
||||||
},
|
} | null>(null);
|
||||||
[setEdges],
|
const [isOverNodeScrollArea, setIsOverNodeScrollArea] = useState(false);
|
||||||
);
|
const toastShownRef = useRef(false);
|
||||||
|
|
||||||
// 필드 클릭 처리
|
// 빈 onConnect 함수 (드래그 연결 비활성화)
|
||||||
const handleFieldClick = useCallback((screenId: string, fieldName: string) => {
|
const onConnect = useCallback(() => {
|
||||||
setSelectedField({ screenId, fieldName });
|
// 드래그로 연결하는 것을 방지
|
||||||
|
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 addSampleNode = useCallback(() => {
|
||||||
const newNode: Node = {
|
const newNode: Node = {
|
||||||
id: `screen-${Date.now()}`,
|
id: `sample-${Date.now()}`,
|
||||||
type: "screenNode",
|
type: "screenNode",
|
||||||
position: { x: Math.random() * 300, y: Math.random() * 200 },
|
position: { x: Math.random() * 300, y: Math.random() * 200 },
|
||||||
data: {
|
data: {
|
||||||
screen: {
|
screen: {
|
||||||
screenId: `screen-${Date.now()}`,
|
screenId: `sample-${Date.now()}`,
|
||||||
screenName: `샘플 화면 ${nodes.length + 1}`,
|
screenName: `샘플 화면 ${nodes.length + 1}`,
|
||||||
screenCode: `SCREEN${nodes.length + 1}`,
|
screenCode: `SAMPLE${nodes.length + 1}`,
|
||||||
tableName: `table_${nodes.length + 1}`,
|
tableName: `sample_table_${nodes.length + 1}`,
|
||||||
fields: [
|
fields: [
|
||||||
{ name: "id", type: "INTEGER", description: "고유 식별자" },
|
{ name: "id", type: "INTEGER", description: "고유 식별자" },
|
||||||
{ name: "name", type: "VARCHAR(100)", description: "이름" },
|
{ name: "name", type: "VARCHAR(100)", description: "이름" },
|
||||||
@@ -93,6 +274,45 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({ companyCode,
|
|||||||
setEdges([]);
|
setEdges([]);
|
||||||
}, [setNodes, 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 (
|
return (
|
||||||
<div className="data-flow-designer h-screen bg-gray-100">
|
<div className="data-flow-designer h-screen bg-gray-100">
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
@@ -107,13 +327,20 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({ companyCode,
|
|||||||
<div className="text-lg font-bold text-blue-800">{companyCode}</div>
|
<div className="text-lg font-bold text-blue-800">{companyCode}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 화면 선택기 */}
|
||||||
|
<ScreenSelector
|
||||||
|
companyCode={companyCode}
|
||||||
|
onScreenAdd={addScreenNode}
|
||||||
|
selectedScreens={getSelectedScreenIds()}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 컨트롤 버튼들 */}
|
{/* 컨트롤 버튼들 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<button
|
<button
|
||||||
onClick={addSampleNode}
|
onClick={addSampleNode}
|
||||||
className="w-full rounded-lg bg-blue-500 p-3 font-medium text-white transition-colors hover:bg-blue-600"
|
className="w-full rounded-lg bg-gray-500 p-3 font-medium text-white transition-colors hover:bg-gray-600"
|
||||||
>
|
>
|
||||||
+ 샘플 화면 추가
|
+ 샘플 화면 추가 (개발용)
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -147,19 +374,72 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({ companyCode,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 선택된 필드 정보 */}
|
{/* 선택된 필드 정보 */}
|
||||||
{selectedField && (
|
{Object.keys(selectedFields).length > 0 && (
|
||||||
<div className="mt-6 rounded-lg border border-yellow-200 bg-yellow-50 p-4">
|
<div className="mt-6 space-y-4">
|
||||||
<div className="mb-2 text-sm font-semibold text-yellow-800">선택된 필드</div>
|
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||||
<div className="text-sm text-yellow-700">
|
<div className="mb-3 text-sm font-semibold text-blue-800">선택된 필드</div>
|
||||||
<div>화면: {selectedField.screenId}</div>
|
<div className="space-y-3">
|
||||||
<div>필드: {selectedField.fieldName}</div>
|
{[...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 (
|
||||||
|
<div key={`selected-${screenId}-${index}`}>
|
||||||
|
<div className="w-full min-w-0 rounded-lg border border-blue-300 bg-white p-3">
|
||||||
|
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||||
|
<div className="flex-shrink-0 rounded bg-blue-600 px-2 py-1 text-xs font-medium text-white">
|
||||||
|
{screenName}
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0 text-xs text-gray-500">ID: {screenId}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full min-w-0 flex-wrap gap-1">
|
||||||
|
{fields.map((field, fieldIndex) => (
|
||||||
|
<div
|
||||||
|
key={`${screenId}-${field}-${fieldIndex}`}
|
||||||
|
className="max-w-full truncate rounded-full border border-blue-200 bg-blue-100 px-2 py-1 text-xs text-blue-800"
|
||||||
|
title={field}
|
||||||
|
>
|
||||||
|
{field}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 첫 번째 화면 다음에 화살표 표시 */}
|
||||||
|
{index === 0 && filteredOrder.length > 1 && (
|
||||||
|
<div className="flex justify-center py-2">
|
||||||
|
<div className="text-gray-400">↓</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={openConnectionModal}
|
||||||
|
disabled={!canCreateConnection()}
|
||||||
|
className={`rounded px-3 py-1 text-xs font-medium transition-colors ${
|
||||||
|
canCreateConnection()
|
||||||
|
? "bg-blue-600 text-white hover:bg-blue-700"
|
||||||
|
: "cursor-not-allowed bg-gray-300 text-gray-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
필드 연결 설정
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedFields({});
|
||||||
|
setSelectionOrder([]);
|
||||||
|
}}
|
||||||
|
className="rounded bg-gray-200 px-3 py-1 text-xs font-medium text-gray-600 hover:bg-gray-300"
|
||||||
|
>
|
||||||
|
선택 초기화
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={() => setSelectedField(null)}
|
|
||||||
className="mt-2 text-xs text-yellow-600 hover:text-yellow-800"
|
|
||||||
>
|
|
||||||
선택 해제
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -177,6 +457,10 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({ companyCode,
|
|||||||
edgeTypes={edgeTypes}
|
edgeTypes={edgeTypes}
|
||||||
fitView
|
fitView
|
||||||
attributionPosition="bottom-left"
|
attributionPosition="bottom-left"
|
||||||
|
panOnScroll={false}
|
||||||
|
zoomOnScroll={true}
|
||||||
|
zoomOnPinch={true}
|
||||||
|
panOnDrag={true}
|
||||||
>
|
>
|
||||||
<Controls />
|
<Controls />
|
||||||
<MiniMap
|
<MiniMap
|
||||||
@@ -198,12 +482,20 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({ companyCode,
|
|||||||
<div className="text-center text-gray-500">
|
<div className="text-center text-gray-500">
|
||||||
<div className="mb-2 text-2xl">📊</div>
|
<div className="mb-2 text-2xl">📊</div>
|
||||||
<div className="mb-1 text-lg font-medium">데이터 흐름 설계를 시작하세요</div>
|
<div className="mb-1 text-lg font-medium">데이터 흐름 설계를 시작하세요</div>
|
||||||
<div className="text-sm">왼쪽 사이드바에서 "샘플 화면 추가" 버튼을 클릭하세요</div>
|
<div className="text-sm">왼쪽 사이드바에서 화면을 선택하여 추가하세요</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 연결 설정 모달 */}
|
||||||
|
<ConnectionSetupModal
|
||||||
|
isOpen={!!pendingConnection}
|
||||||
|
connection={pendingConnection}
|
||||||
|
onConfirm={handleConfirmConnection}
|
||||||
|
onCancel={handleCancelConnection}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Handle, Position } from "@xyflow/react";
|
import { Handle, Position, NodeResizer } from "@xyflow/react";
|
||||||
|
|
||||||
interface ScreenField {
|
interface ScreenField {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -20,22 +20,31 @@ interface Screen {
|
|||||||
interface ScreenNodeData {
|
interface ScreenNodeData {
|
||||||
screen: Screen;
|
screen: Screen;
|
||||||
onFieldClick: (screenId: string, fieldName: string) => void;
|
onFieldClick: (screenId: string, fieldName: string) => void;
|
||||||
|
onScrollAreaEnter?: () => void;
|
||||||
|
onScrollAreaLeave?: () => void;
|
||||||
|
selected?: boolean;
|
||||||
|
selectedFields?: string[]; // 선택된 필드 목록
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
|
export const ScreenNode: React.FC<{ data: ScreenNodeData; selected?: boolean }> = ({ data, selected }) => {
|
||||||
const { screen, onFieldClick } = data;
|
const { screen, onFieldClick, onScrollAreaEnter, onScrollAreaLeave, selectedFields = [] } = data;
|
||||||
|
|
||||||
// 필드 타입에 따른 색상 반환
|
// 필드 타입에 따른 색상 반환
|
||||||
const getFieldTypeColor = (type: string) => {
|
const getFieldTypeColor = (type: string) => {
|
||||||
if (type.includes("INTEGER") || type.includes("NUMERIC")) return "text-blue-600 bg-blue-50";
|
if (!type || typeof type !== "string") return "text-gray-600 bg-gray-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";
|
const upperType = type.toUpperCase();
|
||||||
if (type.includes("BOOLEAN")) return "text-orange-600 bg-orange-50";
|
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 "text-gray-600 bg-gray-50";
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-96 min-w-80 rounded-lg border-2 border-gray-300 bg-white shadow-lg transition-shadow hover:shadow-xl">
|
<div className="flex h-full min-h-52 w-full min-w-80 flex-col rounded-lg border-2 border-gray-300 bg-white shadow-lg transition-shadow hover:shadow-xl">
|
||||||
|
{/* NodeResizer - 선택된 경우에만 표시 */}
|
||||||
|
{selected && <NodeResizer color="#3B82F6" isVisible={selected} minWidth={320} minHeight={200} />}
|
||||||
{/* 노드 헤더 */}
|
{/* 노드 헤더 */}
|
||||||
<div className="rounded-t-lg bg-gradient-to-r from-blue-500 to-blue-600 p-4 text-white">
|
<div className="rounded-t-lg bg-gradient-to-r from-blue-500 to-blue-600 p-4 text-white">
|
||||||
<div className="mb-1 text-base font-bold">{screen.screenName}</div>
|
<div className="mb-1 text-base font-bold">{screen.screenName}</div>
|
||||||
@@ -47,63 +56,46 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 필드 목록 */}
|
{/* 필드 목록 */}
|
||||||
<div className="p-4">
|
<div className="flex flex-1 flex-col overflow-hidden p-4">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<div className="text-sm font-semibold text-gray-700">필드 목록</div>
|
<div className="text-sm font-semibold text-gray-700">필드 목록</div>
|
||||||
<div className="rounded-full bg-gray-100 px-2 py-1 text-xs text-gray-500">{screen.fields.length}개</div>
|
<div className="rounded-full bg-gray-100 px-2 py-1 text-xs text-gray-500">{screen.fields.length}개</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-h-64 space-y-2 overflow-y-auto">
|
<div
|
||||||
{screen.fields.map((field, index) => (
|
className="flex-1 space-y-2 overflow-y-auto"
|
||||||
<div
|
onMouseEnter={onScrollAreaEnter}
|
||||||
key={field.name}
|
onMouseLeave={onScrollAreaLeave}
|
||||||
className="flex cursor-pointer items-center justify-between rounded-lg border border-transparent p-3 transition-colors hover:border-gray-200 hover:bg-gray-50"
|
>
|
||||||
onClick={() => onFieldClick(screen.screenId, field.name)}
|
{screen.fields.map((field, index) => {
|
||||||
>
|
const isSelected = selectedFields.includes(field.name);
|
||||||
<div className="min-w-0 flex-1">
|
return (
|
||||||
<div className="mb-1 flex items-center">
|
<div
|
||||||
<div className="truncate text-sm font-medium text-gray-900">{field.name}</div>
|
key={field.name}
|
||||||
{index === 0 && (
|
className={`relative flex cursor-pointer items-center justify-between rounded-lg border p-3 transition-colors ${
|
||||||
<span className="ml-2 rounded bg-yellow-100 px-1.5 py-0.5 text-xs text-yellow-800">PK</span>
|
isSelected
|
||||||
)}
|
? "border-blue-500 bg-blue-50 hover:bg-blue-100"
|
||||||
|
: "border-transparent hover:border-gray-200 hover:bg-gray-50"
|
||||||
|
}`}
|
||||||
|
onClick={() => onFieldClick(screen.screenId, field.name)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="mb-1 flex items-center">
|
||||||
|
<div className="truncate text-sm font-medium text-gray-900">{field.name}</div>
|
||||||
|
{index === 0 && (
|
||||||
|
<span className="ml-2 rounded bg-yellow-100 px-1.5 py-0.5 text-xs text-yellow-800">PK</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-xs text-gray-500">{field.description}</div>
|
||||||
|
</div>
|
||||||
|
<div className={`ml-2 rounded px-2 py-1 font-mono text-xs ${getFieldTypeColor(field.type)}`}>
|
||||||
|
{field.type}
|
||||||
</div>
|
</div>
|
||||||
<div className="truncate text-xs text-gray-500">{field.description}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={`ml-2 rounded px-2 py-1 font-mono text-xs ${getFieldTypeColor(field.type)}`}>
|
);
|
||||||
{field.type}
|
})}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* React Flow 핸들 */}
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Right}
|
|
||||||
className="h-4 w-4 border-2 border-white bg-blue-500 shadow-md transition-colors hover:bg-blue-600"
|
|
||||||
title="연결 시작점"
|
|
||||||
/>
|
|
||||||
<Handle
|
|
||||||
type="target"
|
|
||||||
position={Position.Left}
|
|
||||||
className="h-4 w-4 border-2 border-white bg-green-500 shadow-md transition-colors hover:bg-green-600"
|
|
||||||
title="연결 도착점"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 추가 핸들들 (상하) */}
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Bottom}
|
|
||||||
className="h-4 w-4 border-2 border-white bg-blue-500 shadow-md transition-colors hover:bg-blue-600"
|
|
||||||
id="bottom-source"
|
|
||||||
/>
|
|
||||||
<Handle
|
|
||||||
type="target"
|
|
||||||
position={Position.Top}
|
|
||||||
className="h-4 w-4 border-2 border-white bg-green-500 shadow-md transition-colors hover:bg-green-600"
|
|
||||||
id="top-target"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
174
frontend/components/dataflow/ScreenSelector.tsx
Normal file
174
frontend/components/dataflow/ScreenSelector.tsx
Normal file
@@ -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<ScreenSelectorProps> = ({ companyCode, onScreenAdd, selectedScreens = [] }) => {
|
||||||
|
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
|
||||||
|
const [filteredScreens, setFilteredScreens] = useState<ScreenDefinition[]>([]);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800">화면 선택</h3>
|
||||||
|
<Button onClick={loadScreens} variant="outline" size="sm" disabled={loading}>
|
||||||
|
{loading ? "로딩중..." : "새로고침"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 입력 */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
placeholder="화면명, 코드, 테이블명으로 검색..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 오류 메시지 */}
|
||||||
|
{error && <div className="rounded-lg bg-red-50 p-4 text-sm text-red-600">{error}</div>}
|
||||||
|
|
||||||
|
{/* 화면 목록 */}
|
||||||
|
<div className="max-h-96 space-y-2 overflow-y-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="text-sm text-gray-500">화면 목록을 불러오는 중...</div>
|
||||||
|
</div>
|
||||||
|
) : filteredScreens.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="text-center text-sm text-gray-500">
|
||||||
|
{searchTerm ? "검색 결과가 없습니다." : "등록된 화면이 없습니다."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredScreens.map((screen) => (
|
||||||
|
<Card
|
||||||
|
key={screen.screenId}
|
||||||
|
className={`transition-all hover:shadow-md ${
|
||||||
|
isScreenSelected(screen.screenId) ? "border-blue-500 bg-blue-50" : "hover:border-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-sm font-medium">{screen.screenName}</CardTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant={screen.isActive === "Y" ? "default" : "secondary"} className="text-xs">
|
||||||
|
{screen.isActive === "Y" ? "활성" : "비활성"}
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleAddScreen(screen)}
|
||||||
|
disabled={isScreenSelected(screen.screenId)}
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
{isScreenSelected(screen.screenId) ? "추가됨" : "추가"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-600">
|
||||||
|
<span className="rounded bg-gray-100 px-1.5 py-0.5 font-mono">{screen.screenCode}</span>
|
||||||
|
<Database className="h-3 w-3" />
|
||||||
|
<span className="font-mono">{screen.tableName}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{screen.description && <p className="line-clamp-2 text-xs text-gray-500">{screen.description}</p>}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-400">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<User className="h-3 w-3" />
|
||||||
|
<span>{screen.createdBy || "시스템"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
<span>{formatDate(screen.createdDate)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 통계 정보 */}
|
||||||
|
<div className="rounded-lg bg-gray-50 p-3 text-xs text-gray-600">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>전체 화면: {screens.length}개</span>
|
||||||
|
<span>{searchTerm ? `검색 결과: ${filteredScreens.length}개` : ""}</span>
|
||||||
|
</div>
|
||||||
|
{selectedScreens.length > 0 && <div className="mt-1">선택된 화면: {selectedScreens.length}개</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
202
frontend/lib/api/dataflow.ts
Normal file
202
frontend/lib/api/dataflow.ts
Normal file
@@ -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<string, any>;
|
||||||
|
companyCode: string;
|
||||||
|
isActive?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터 흐름 관리 API 클래스
|
||||||
|
export class DataFlowAPI {
|
||||||
|
/**
|
||||||
|
* 회사별 화면 목록 조회
|
||||||
|
*/
|
||||||
|
static async getScreensByCompany(companyCode: string): Promise<ScreenDefinition[]> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<ApiResponse<ScreenDefinition[]>>("/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<ColumnInfo[]> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<ApiResponse<ColumnInfo[]>>(`/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<ScreenWithFields | null> {
|
||||||
|
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<ScreenRelationship, "relationshipId">,
|
||||||
|
): Promise<ScreenRelationship> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post<ApiResponse<ScreenRelationship>>("/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<ScreenRelationship[]> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<ApiResponse<ScreenRelationship[]>>("/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<ScreenRelationship>,
|
||||||
|
): Promise<ScreenRelationship> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.put<ApiResponse<ScreenRelationship>>(
|
||||||
|
`/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<void> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.delete<ApiResponse<null>>(`/dataflow/relationships/${relationshipId}`);
|
||||||
|
|
||||||
|
if (!response.data.success) {
|
||||||
|
throw new Error(response.data.message || "관계 삭제에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("관계 삭제 오류:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
frontend/package-lock.json
generated
28
frontend/package-lock.json
generated
@@ -39,6 +39,7 @@
|
|||||||
"react-day-picker": "^9.9.0",
|
"react-day-picker": "^9.9.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-hook-form": "^7.62.0",
|
"react-hook-form": "^7.62.0",
|
||||||
|
"react-hot-toast": "^2.6.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"zod": "^4.1.5"
|
"zod": "^4.1.5"
|
||||||
@@ -3889,7 +3890,6 @@
|
|||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/d3-color": {
|
"node_modules/d3-color": {
|
||||||
@@ -5317,6 +5317,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/gopd": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
@@ -7224,6 +7233,23 @@
|
|||||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
"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": {
|
"node_modules/react-is": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
"react-day-picker": "^9.9.0",
|
"react-day-picker": "^9.9.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-hook-form": "^7.62.0",
|
"react-hook-form": "^7.62.0",
|
||||||
|
"react-hot-toast": "^2.6.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"zod": "^4.1.5"
|
"zod": "^4.1.5"
|
||||||
|
|||||||
Reference in New Issue
Block a user