dev #46
|
|
@ -67,12 +67,14 @@ export class TableManagementService {
|
|||
SELECT
|
||||
c.column_name as "columnName",
|
||||
COALESCE(cl.column_label, c.column_name) as "displayName",
|
||||
c.data_type as "dataType",
|
||||
c.data_type as "dbType",
|
||||
COALESCE(cl.web_type, 'text') as "webType",
|
||||
COALESCE(cl.input_type, 'direct') as "inputType",
|
||||
COALESCE(cl.detail_settings, '') as "detailSettings",
|
||||
COALESCE(cl.description, '') as "description",
|
||||
c.is_nullable as "isNullable",
|
||||
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey",
|
||||
c.column_default as "defaultValue",
|
||||
c.character_maximum_length as "maxLength",
|
||||
c.numeric_precision as "numericPrecision",
|
||||
|
|
@ -85,6 +87,15 @@ export class TableManagementService {
|
|||
cl.is_visible as "isVisible"
|
||||
FROM information_schema.columns c
|
||||
LEFT JOIN column_labels cl ON c.table_name = cl.table_name AND c.column_name = cl.column_name
|
||||
LEFT JOIN (
|
||||
SELECT kcu.column_name, kcu.table_name
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_name = kcu.constraint_name
|
||||
AND tc.table_schema = kcu.table_schema
|
||||
WHERE tc.constraint_type = 'PRIMARY KEY'
|
||||
AND tc.table_name = ${tableName}
|
||||
) pk ON c.column_name = pk.column_name AND c.table_name = pk.table_name
|
||||
WHERE c.table_name = ${tableName}
|
||||
ORDER BY c.ordinal_position
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -10,12 +10,14 @@ export interface TableInfo {
|
|||
export interface ColumnTypeInfo {
|
||||
columnName: string;
|
||||
displayName: string;
|
||||
dataType: string; // 추가: 데이터 타입 (dbType과 동일하지만 별도 필드)
|
||||
dbType: string;
|
||||
webType: string;
|
||||
inputType?: "direct" | "auto";
|
||||
detailSettings: string;
|
||||
description: string;
|
||||
isNullable: string;
|
||||
isPrimaryKey: boolean; // 추가: 기본키 여부
|
||||
defaultValue?: string;
|
||||
maxLength?: number;
|
||||
numericPrecision?: number;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# 데이터 흐름 관리 시스템 설계
|
||||
# 테이블 간 데이터 관계 설정 시스템 설계
|
||||
|
||||
## 📋 목차
|
||||
|
||||
|
|
@ -12,15 +12,15 @@
|
|||
|
||||
## 🎯 시스템 개요
|
||||
|
||||
### 데이터 흐름 관리 시스템이란?
|
||||
### 테이블 간 데이터 관계 설정 시스템이란?
|
||||
|
||||
데이터 흐름 관리 시스템은 회사별로 화면들 간의 데이터 흐름과 관계를 시각적으로 설계하고 관리할 수 있는 시스템입니다. React Flow 라이브러리를 활용하여 직관적인 노드 기반 인터페이스로 1:1, 1:N, N:1, N:N 관계를 지원하며, 다양한 연결 방식과 종류로 복합적인 데이터 흐름을 설계할 수 있습니다.
|
||||
테이블 간 데이터 관계 설정 시스템은 회사별로 데이터베이스 테이블들 간의 데이터 관계를 시각적으로 설계하고 관리할 수 있는 시스템입니다. React Flow 라이브러리를 활용하여 직관적인 노드 기반 인터페이스로 1:1, 1:N, N:1, N:N 관계를 지원하며, 다양한 연결 방식과 종류로 복합적인 데이터 관계를 설계할 수 있습니다.
|
||||
|
||||
### 주요 특징
|
||||
|
||||
- **React Flow 기반 인터페이스**: 직관적인 노드와 엣지 기반 시각적 설계
|
||||
- **회사별 관계 관리**: 사용자 회사 코드에 따른 화면 관계 접근 제어
|
||||
- **시각적 관계 설계**: 드래그앤드롭으로 화면 노드 배치 및 필드 간 연결
|
||||
- **회사별 관계 관리**: 사용자 회사 코드에 따른 테이블 관계 접근 제어
|
||||
- **시각적 관계 설계**: 드래그앤드롭으로 테이블 노드 배치 및 컬럼 간 연결
|
||||
- **다양한 관계 타입**: 1:1, 1:N, N:1, N:N 관계 지원
|
||||
- **연결 종류별 세부 설정**: 단순 키값, 데이터 저장, 외부 호출
|
||||
- **실시간 시뮬레이션**: 설계한 관계의 데이터 흐름 시뮬레이션
|
||||
|
|
@ -29,31 +29,31 @@
|
|||
|
||||
### 지원하는 관계 타입
|
||||
|
||||
- **1:1 (One to One)**: 한 화면의 필드와 다른 화면의 필드가 1:1로 연결
|
||||
- **1:N (One to Many)**: 한 화면의 필드가 여러 화면의 필드와 연결
|
||||
- **N:1 (Many to One)**: 여러 화면의 필드가 한 화면의 필드와 연결
|
||||
- **N:N (Many to Many)**: 여러 화면의 필드가 여러 화면의 필드와 연결
|
||||
- **1:1 (One to One)**: 한 테이블의 컬럼과 다른 테이블의 컬럼이 1:1로 연결
|
||||
- **1:N (One to Many)**: 한 테이블의 컬럼이 여러 테이블의 컬럼과 연결
|
||||
- **N:1 (Many to One)**: 여러 테이블의 컬럼이 한 테이블의 컬럼과 연결
|
||||
- **N:N (Many to Many)**: 여러 테이블의 컬럼이 여러 테이블의 컬럼과 연결
|
||||
|
||||
### 지원하는 연결 종류
|
||||
|
||||
- **단순 키값 연결**: 중계 테이블을 통한 참조 관계
|
||||
- **데이터 저장**: 필드 매핑을 통한 데이터 저장
|
||||
- **데이터 저장**: 컬럼 매핑을 통한 데이터 저장
|
||||
- **외부 호출**: API, 이메일, 웹훅 등을 통한 외부 시스템 연동
|
||||
|
||||
## 🚀 핵심 기능
|
||||
|
||||
### 1. React Flow 기반 화면 노드 관리
|
||||
### 1. React Flow 기반 테이블 노드 관리
|
||||
|
||||
- **화면 추가**: 회사별 화면 목록에서 관계를 설정할 화면들을 React Flow 캔버스에 추가
|
||||
- **화면 배치**: 드래그앤드롭으로 화면 노드를 원하는 위치에 배치
|
||||
- **화면 이동**: React Flow의 내장 기능으로 화면 노드 자유롭게 이동
|
||||
- **테이블 추가**: 데이터베이스 테이블 목록에서 관계를 설정할 테이블들을 React Flow 캔버스에 추가
|
||||
- **테이블 배치**: 드래그앤드롭으로 테이블 노드를 원하는 위치에 배치
|
||||
- **테이블 이동**: React Flow의 내장 기능으로 테이블 노드 자유롭게 이동
|
||||
- **노드 선택**: 단일 또는 다중 노드 선택 지원
|
||||
- **자동 정렬**: React Flow의 레이아웃 알고리즘을 활용한 자동 정렬
|
||||
|
||||
### 2. React Flow 기반 필드 간 연결 설정
|
||||
### 2. React Flow 기반 컬럼 간 연결 설정
|
||||
|
||||
- **필드 선택**: 첫 번째 화면의 필드를 클릭하여 연결 시작
|
||||
- **대상 필드 선택**: 두 번째 화면의 필드를 클릭하여 연결 대상 지정
|
||||
- **컬럼 선택**: 첫 번째 테이블의 컬럼을 클릭하여 연결 시작
|
||||
- **대상 컬럼 선택**: 두 번째 테이블의 컬럼을 클릭하여 연결 대상 지정
|
||||
- **드래그 연결**: React Flow의 핸들(Handle)을 드래그하여 시각적 연결
|
||||
- **관계 타입 선택**: 1:1, 1:N, N:1, N:N 중 선택
|
||||
- **연결 종류 선택**: 단순 키값, 데이터 저장, 외부 호출 중 선택
|
||||
|
|
@ -69,9 +69,9 @@
|
|||
|
||||
#### 데이터 저장
|
||||
|
||||
- **필드 매핑**: 소스 필드와 대상 필드 매핑 설정
|
||||
- **컬럼 매핑**: 소스 컬럼과 대상 컬럼 매핑 설정
|
||||
- **저장 조건**: 데이터 저장 조건 정의
|
||||
- **데이터 변환**: 필드 값 변환 규칙
|
||||
- **데이터 변환**: 컬럼 값 변환 규칙
|
||||
|
||||
#### 외부 호출
|
||||
|
||||
|
|
@ -83,7 +83,7 @@
|
|||
|
||||
### 4. React Flow 기반 시각적 관계 관리
|
||||
|
||||
- **엣지 렌더링**: React Flow의 커스텀 엣지로 화면 간 관계를 시각적으로 표현
|
||||
- **엣지 렌더링**: React Flow의 커스텀 엣지로 테이블 간 관계를 시각적으로 표현
|
||||
- **관계 타입별 스타일링**: 연결 종류에 따른 색상, 선 스타일, 라벨 구분
|
||||
- **인터랙티브 캔버스**: 줌, 팬, 미니맵을 통한 대규모 다이어그램 탐색
|
||||
- **실시간 시뮬레이션**: 데이터 흐름 애니메이션 및 시뮬레이션
|
||||
|
|
@ -99,17 +99,17 @@
|
|||
|
||||
## 🗄️ 데이터베이스 설계
|
||||
|
||||
### 1. 화면 관계 테이블
|
||||
### 1. 테이블 관계 테이블
|
||||
|
||||
```sql
|
||||
-- 화면 간 관계 정의
|
||||
CREATE TABLE screen_relationships (
|
||||
-- 테이블 간 관계 정의
|
||||
CREATE TABLE table_relationships (
|
||||
relationship_id SERIAL PRIMARY KEY,
|
||||
relationship_name VARCHAR(200) NOT NULL,
|
||||
from_screen_id INTEGER NOT NULL,
|
||||
from_field_name VARCHAR(100) NOT NULL,
|
||||
to_screen_id INTEGER NOT NULL,
|
||||
to_field_name VARCHAR(100) NOT NULL,
|
||||
from_table_name VARCHAR(100) NOT NULL,
|
||||
from_column_name VARCHAR(100) NOT NULL,
|
||||
to_table_name VARCHAR(100) NOT NULL,
|
||||
to_column_name VARCHAR(100) NOT NULL,
|
||||
relationship_type VARCHAR(20) NOT NULL, -- 'one-to-one', 'one-to-many', 'many-to-one', 'many-to-many'
|
||||
connection_type VARCHAR(20) NOT NULL, -- 'simple-key', 'data-save', 'external-call'
|
||||
company_code VARCHAR(50) NOT NULL,
|
||||
|
|
@ -118,17 +118,14 @@ CREATE TABLE screen_relationships (
|
|||
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by VARCHAR(50),
|
||||
updated_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_by VARCHAR(50),
|
||||
|
||||
-- 외래키 제약조건
|
||||
CONSTRAINT fk_screen_relationships_from_screen
|
||||
FOREIGN KEY (from_screen_id) REFERENCES screen_definitions(screen_id),
|
||||
CONSTRAINT fk_screen_relationships_to_screen
|
||||
FOREIGN KEY (to_screen_id) REFERENCES screen_definitions(screen_id)
|
||||
updated_by VARCHAR(50)
|
||||
);
|
||||
|
||||
-- 회사 코드 인덱스
|
||||
CREATE INDEX idx_screen_relationships_company_code ON screen_relationships(company_code);
|
||||
CREATE INDEX idx_table_relationships_company_code ON table_relationships(company_code);
|
||||
-- 테이블명 인덱스
|
||||
CREATE INDEX idx_table_relationships_from_table ON table_relationships(from_table_name);
|
||||
CREATE INDEX idx_table_relationships_to_table ON table_relationships(to_table_name);
|
||||
```
|
||||
|
||||
### 2. 중계 테이블 관리
|
||||
|
|
@ -148,7 +145,7 @@ CREATE TABLE bridge_tables (
|
|||
|
||||
-- 외래키 제약조건
|
||||
CONSTRAINT fk_bridge_tables_relationship
|
||||
FOREIGN KEY (relationship_id) REFERENCES screen_relationships(relationship_id)
|
||||
FOREIGN KEY (relationship_id) REFERENCES table_relationships(relationship_id)
|
||||
);
|
||||
|
||||
-- 회사 코드 인덱스
|
||||
|
|
@ -170,16 +167,14 @@ CREATE TABLE external_call_configs (
|
|||
|
||||
-- 외래키 제약조건
|
||||
CONSTRAINT fk_external_call_configs_relationship
|
||||
FOREIGN KEY (relationship_id) REFERENCES screen_relationships(relationship_id)
|
||||
FOREIGN KEY (relationship_id) REFERENCES table_relationships(relationship_id)
|
||||
);
|
||||
```
|
||||
|
||||
### 4. 테이블 간 연계 관계
|
||||
|
||||
```
|
||||
screen_definitions (화면 정의)
|
||||
↓ (1:N)
|
||||
screen_relationships (화면 관계)
|
||||
table_relationships (테이블 관계)
|
||||
↓ (1:N)
|
||||
bridge_tables (중계 테이블)
|
||||
↓ (1:N)
|
||||
|
|
@ -209,7 +204,7 @@ import "reactflow/dist/style.css";
|
|||
|
||||
interface DataFlowDesignerProps {
|
||||
companyCode: string;
|
||||
onSave?: (relationships: ScreenRelationship[]) => void;
|
||||
onSave?: (relationships: TableRelationship[]) => void;
|
||||
}
|
||||
|
||||
export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
|
||||
|
|
@ -236,12 +231,12 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
|
|||
<div className="flex h-full">
|
||||
{/* 사이드바 */}
|
||||
<div className="w-80 bg-gray-50 border-r">
|
||||
<ScreenSelector
|
||||
<TableSelector
|
||||
companyCode={companyCode}
|
||||
onScreenAdd={handleScreenAdd}
|
||||
onTableAdd={handleTableAdd}
|
||||
/>
|
||||
<ConnectionStatus
|
||||
selectedField={selectedField}
|
||||
selectedColumn={selectedColumn}
|
||||
onCancel={handleCancelConnection}
|
||||
/>
|
||||
<ConnectionStats relationships={relationships} />
|
||||
|
|
@ -279,47 +274,49 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
|
|||
};
|
||||
```
|
||||
|
||||
### 2. React Flow 화면 노드 컴포넌트
|
||||
### 2. React Flow 테이블 노드 컴포넌트
|
||||
|
||||
```typescript
|
||||
// ScreenNode.tsx
|
||||
// TableNode.tsx
|
||||
import { Handle, Position } from "reactflow";
|
||||
|
||||
interface ScreenNodeData {
|
||||
screen: ScreenDefinition;
|
||||
onFieldClick: (screenId: string, fieldName: string) => void;
|
||||
interface TableNodeData {
|
||||
table: TableDefinition;
|
||||
onColumnClick: (tableName: string, columnName: string) => void;
|
||||
}
|
||||
|
||||
export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
|
||||
const { screen, onFieldClick } = data;
|
||||
export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
||||
const { table, onColumnClick } = data;
|
||||
|
||||
return (
|
||||
<div className="bg-white border-2 border-gray-300 rounded-lg shadow-lg min-w-80">
|
||||
{/* 노드 헤더 */}
|
||||
<div className="bg-blue-500 text-white p-3 rounded-t-lg">
|
||||
<div className="font-bold text-sm">{screen.screenName}</div>
|
||||
<div className="text-xs opacity-90">{screen.screenCode}</div>
|
||||
<div className="text-xs opacity-75">테이블: {screen.tableName}</div>
|
||||
<div className="font-bold text-sm">{table.tableName}</div>
|
||||
<div className="text-xs opacity-90">테이블</div>
|
||||
<div className="text-xs opacity-75">컬럼: {table.columns.length}개</div>
|
||||
</div>
|
||||
|
||||
{/* 필드 목록 */}
|
||||
{/* 컬럼 목록 */}
|
||||
<div className="p-3">
|
||||
<div className="text-xs font-semibold text-gray-600 mb-2">
|
||||
필드 목록 ({screen.fields.length}개)
|
||||
컬럼 목록 ({table.columns.length}개)
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{screen.fields.map((field) => (
|
||||
{table.columns.map((column) => (
|
||||
<div
|
||||
key={field.name}
|
||||
key={column.name}
|
||||
className="flex items-center justify-between p-2 hover:bg-gray-50 rounded cursor-pointer"
|
||||
onClick={() => onFieldClick(screen.screenId, field.name)}
|
||||
onClick={() => onColumnClick(table.tableName, column.name)}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium">{field.name}</div>
|
||||
<div className="text-xs text-gray-500">{field.description}</div>
|
||||
<div className="text-sm font-medium">{column.name}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{column.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-blue-600 font-mono">
|
||||
{field.type}
|
||||
{column.type}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -343,7 +340,7 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
|
|||
|
||||
// 노드 타입 정의
|
||||
export const nodeTypes = {
|
||||
screenNode: ScreenNode,
|
||||
tableNode: TableNode,
|
||||
};
|
||||
```
|
||||
|
||||
|
|
@ -521,33 +518,33 @@ export const edgeTypes = {
|
|||
|
||||
## 🌐 API 설계
|
||||
|
||||
### 1. 화면 관계 관리 API
|
||||
### 1. 테이블 관계 관리 API
|
||||
|
||||
```typescript
|
||||
// 화면 관계 생성
|
||||
POST /api/screen-relationships
|
||||
// 테이블 관계 생성
|
||||
POST /api/table-relationships
|
||||
Body: {
|
||||
relationshipName: string;
|
||||
fromScreenId: number;
|
||||
fromFieldName: string;
|
||||
toScreenId: number;
|
||||
toFieldName: string;
|
||||
fromTableName: string;
|
||||
fromColumnName: string;
|
||||
toTableName: string;
|
||||
toColumnName: string;
|
||||
relationshipType: 'one-to-one' | 'one-to-many' | 'many-to-one' | 'many-to-many';
|
||||
connectionType: 'simple-key' | 'data-save' | 'external-call';
|
||||
settings: ConnectionSettings;
|
||||
}
|
||||
|
||||
// 화면 관계 목록 조회 (회사별)
|
||||
GET /api/screen-relationships?companyCode=COMP001
|
||||
// 테이블 관계 목록 조회 (회사별)
|
||||
GET /api/table-relationships?companyCode=COMP001
|
||||
|
||||
// 화면 관계 수정
|
||||
PUT /api/screen-relationships/:id
|
||||
// 테이블 관계 수정
|
||||
PUT /api/table-relationships/:id
|
||||
|
||||
// 화면 관계 삭제
|
||||
DELETE /api/screen-relationships/:id
|
||||
// 테이블 관계 삭제
|
||||
DELETE /api/table-relationships/:id
|
||||
|
||||
// 관계 시뮬레이션
|
||||
POST /api/screen-relationships/:id/simulate
|
||||
POST /api/table-relationships/:id/simulate
|
||||
```
|
||||
|
||||
### 2. 중계 테이블 관리 API
|
||||
|
|
@ -591,15 +588,15 @@ PUT /api/external-call-configs/:id
|
|||
|
||||
### 1. 기본 관계 설정
|
||||
|
||||
1. **화면 추가**: 회사별 화면 목록에서 관계를 설정할 화면들을 캔버스에 추가
|
||||
2. **필드 선택**: 첫 번째 화면의 필드를 클릭하여 연결 시작
|
||||
3. **대상 필드 선택**: 두 번째 화면의 필드를 클릭하여 연결 대상 지정
|
||||
1. **테이블 추가**: 데이터베이스 테이블 목록에서 관계를 설정할 테이블들을 캔버스에 추가
|
||||
2. **컬럼 선택**: 첫 번째 테이블의 컬럼을 클릭하여 연결 시작
|
||||
3. **대상 컬럼 선택**: 두 번째 테이블의 컬럼을 클릭하여 연결 대상 지정
|
||||
4. **관계 설정**: 관계 타입과 연결 종류를 선택하고 세부 설정 구성
|
||||
5. **연결 생성**: 설정 완료 후 연결 생성
|
||||
|
||||
### 2. 복합 데이터 흐름 설계
|
||||
|
||||
1. **다중 화면 배치**: 관련된 여러 화면을 캔버스에 배치
|
||||
1. **다중 테이블 배치**: 관련된 여러 테이블을 캔버스에 배치
|
||||
2. **다양한 연결 타입**: 단순 키값, 데이터 저장, 외부 호출을 조합
|
||||
3. **중계 테이블 활용**: N:N 관계에서 중계 테이블 자동 생성
|
||||
4. **시각적 검증**: 연결선과 색상으로 관계 유형 구분
|
||||
|
|
@ -617,7 +614,7 @@ PUT /api/external-call-configs/:id
|
|||
|
||||
- [x] React Flow 라이브러리 설치 및 설정 (@xyflow/react 12.8.4)
|
||||
- [x] 기본 노드와 엣지 컴포넌트 구현
|
||||
- [x] 화면 노드 컴포넌트 구현 (ScreenNode.tsx)
|
||||
- [x] 테이블 노드 컴포넌트 구현 (TableNode.tsx)
|
||||
- [x] 기본 연결선 그리기 (CustomEdge.tsx)
|
||||
- [x] 메인 데이터 흐름 관리 컴포넌트 구현 (DataFlowDesigner.tsx)
|
||||
- [x] /admin/dataflow 페이지 생성
|
||||
|
|
@ -631,8 +628,8 @@ PUT /api/external-call-configs/:id
|
|||
- [x] 연결 설정 모달 UI 구현
|
||||
- [x] 1:1, 1:N, N:1, N:N 관계 타입 선택 UI
|
||||
- [x] 단순 키값, 데이터 저장, 외부 호출 연결 종류 UI
|
||||
- [x] 필드-to-필드 연결 시스템 (클릭 기반)
|
||||
- [x] 선택된 필드 정보 표시 및 순서 보장
|
||||
- [x] 컬럼-to-컬럼 연결 시스템 (클릭 기반)
|
||||
- [x] 선택된 컬럼 정보 표시 및 순서 보장
|
||||
- [ ] 연결 생성 로직 구현 (모달에서 실제 엣지 생성)
|
||||
- [ ] 생성된 연결의 시각적 표시 (React Flow 엣지)
|
||||
- [ ] 연결 데이터 백엔드 저장 API 연동
|
||||
|
|
@ -668,7 +665,7 @@ PUT /api/external-call-configs/:id
|
|||
|
||||
## 🎯 결론
|
||||
|
||||
**데이터 흐름 관리 시스템**을 통해 ERP 시스템의 화면들 간 데이터 흐름을 시각적으로 설계하고 관리할 수 있습니다. React Flow 라이브러리를 활용한 직관적인 노드 기반 인터페이스와 회사별 권한 관리, 기존 화면관리 시스템과의 완벽한 연동을 통해 체계적인 데이터 관계 관리가 가능합니다.
|
||||
**테이블 간 데이터 관계 설정 시스템**을 통해 ERP 시스템의 테이블들 간 데이터 관계를 시각적으로 설계하고 관리할 수 있습니다. React Flow 라이브러리를 활용한 직관적인 노드 기반 인터페이스와 회사별 권한 관리, 기존 테이블관리 시스템과의 완벽한 연동을 통해 체계적인 데이터 관계 관리가 가능합니다.
|
||||
|
||||
## 📊 구현 현황
|
||||
|
||||
|
|
@ -677,12 +674,12 @@ PUT /api/external-call-configs/:id
|
|||
**구현된 기능:**
|
||||
|
||||
- React Flow 12.8.4 기반 시각적 캔버스
|
||||
- 화면 노드 컴포넌트 (필드 정보, 타입별 색상 구분, 노드 리사이징)
|
||||
- 테이블 노드 컴포넌트 (컬럼 정보, 타입별 색상 구분, 노드 리사이징)
|
||||
- 커스텀 엣지 컴포넌트 (관계 타입별 스타일링)
|
||||
- 드래그앤드롭 노드 배치 및 연결
|
||||
- 줌, 팬, 미니맵 등 고급 시각화 기능 (스크롤 충돌 해결)
|
||||
- 실제 화면 데이터 연동 (테이블 관리 API 연결)
|
||||
- 필드-to-필드 연결 시스템 (클릭 기반, 2개 화면 제한)
|
||||
- 실제 테이블 데이터 연동 (테이블 관리 API 연결)
|
||||
- 컬럼-to-컬럼 연결 시스템 (클릭 기반, 2개 테이블 제한)
|
||||
- 연결 설정 모달 (관계 타입, 연결 종류 선택 UI)
|
||||
- /admin/dataflow 경로 설정
|
||||
- 메뉴 시스템 연동 완료
|
||||
|
|
@ -691,7 +688,7 @@ PUT /api/external-call-configs/:id
|
|||
**구현된 파일:**
|
||||
|
||||
- `frontend/components/dataflow/DataFlowDesigner.tsx` - 메인 캔버스 컴포넌트
|
||||
- `frontend/components/dataflow/ScreenNode.tsx` - 화면 노드 컴포넌트 (NodeResizer 포함)
|
||||
- `frontend/components/dataflow/TableNode.tsx` - 테이블 노드 컴포넌트 (NodeResizer 포함)
|
||||
- `frontend/components/dataflow/CustomEdge.tsx` - 커스텀 엣지 컴포넌트
|
||||
- `frontend/components/dataflow/ConnectionSetupModal.tsx` - 연결 설정 모달
|
||||
- `frontend/app/(main)/admin/dataflow/page.tsx` - 데이터 흐름 관리 페이지
|
||||
|
|
@ -702,10 +699,10 @@ PUT /api/external-call-configs/:id
|
|||
|
||||
1. **스크롤 충돌 해결**: 노드 내부 스크롤과 React Flow 줌/팬 기능 분리
|
||||
2. **노드 리사이징**: NodeResizer를 통한 노드 크기 조정 및 내용 반영
|
||||
3. **필드-to-필드 연결**: 드래그앤드롭 대신 클릭 기반 필드 선택 방식
|
||||
4. **2개 화면 제한**: 최대 2개 화면에서만 필드 선택 가능
|
||||
5. **선택 순서 보장**: 사이드바와 모달에서 필드 선택 순서 정확히 반영
|
||||
6. **실제 데이터 연동**: 테이블 관리 시스템의 실제 화면/필드 데이터 사용
|
||||
3. **컬럼-to-컬럼 연결**: 드래그앤드롭 대신 클릭 기반 컬럼 선택 방식
|
||||
4. **2개 테이블 제한**: 최대 2개 테이블에서만 컬럼 선택 가능
|
||||
5. **선택 순서 보장**: 사이드바와 모달에서 컬럼 선택 순서 정확히 반영
|
||||
6. **실제 데이터 연동**: 테이블 관리 시스템의 실제 테이블/컬럼 데이터 사용
|
||||
7. **사용자 경험**: react-hot-toast를 통한 친화적인 알림 시스템
|
||||
8. **React 안정성**: 렌더링 중 상태 변경 문제 해결
|
||||
|
||||
|
|
@ -713,9 +710,9 @@ PUT /api/external-call-configs/:id
|
|||
|
||||
### 주요 가치
|
||||
|
||||
- **React Flow 기반 시각적 설계**: 복잡한 데이터 관계를 직관적인 노드와 엣지로 설계
|
||||
- **React Flow 기반 시각적 설계**: 복잡한 테이블 관계를 직관적인 노드와 엣지로 설계
|
||||
- **인터랙티브 캔버스**: 줌, 팬, 미니맵 등 고급 시각화 기능 제공
|
||||
- **회사별 관리**: 각 회사별로 독립적인 관계 관리
|
||||
- **회사별 관리**: 각 회사별로 독립적인 테이블 관계 관리
|
||||
- **다양한 연결 타입**: 업무 요구사항에 맞는 다양한 연결 방식
|
||||
- **자동화**: 중계 테이블 자동 생성 및 외부 시스템 연동
|
||||
- **확장성**: 새로운 연결 타입과 관계 유형 쉽게 추가
|
||||
|
|
|
|||
|
|
@ -15,23 +15,22 @@ import { ArrowRight, Database, Link } from "lucide-react";
|
|||
interface ConnectionInfo {
|
||||
fromNode: {
|
||||
id: string;
|
||||
screenName: string;
|
||||
tableName: string;
|
||||
displayName: string;
|
||||
};
|
||||
toNode: {
|
||||
id: string;
|
||||
screenName: string;
|
||||
tableName: string;
|
||||
displayName: string;
|
||||
};
|
||||
fromField?: string;
|
||||
toField?: string;
|
||||
selectedFieldsData?: {
|
||||
[screenId: string]: {
|
||||
screenName: string;
|
||||
fields: string[];
|
||||
fromColumn?: string;
|
||||
toColumn?: string;
|
||||
selectedColumnsData?: {
|
||||
[tableName: string]: {
|
||||
displayName: string;
|
||||
columns: string[];
|
||||
};
|
||||
};
|
||||
orderedScreenIds?: string[]; // 선택 순서 정보
|
||||
}
|
||||
|
||||
// 연결 설정 타입
|
||||
|
|
@ -39,8 +38,8 @@ 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;
|
||||
fromColumnName: string;
|
||||
toColumnName: string;
|
||||
settings?: Record<string, any>;
|
||||
description?: string;
|
||||
}
|
||||
|
|
@ -58,261 +57,221 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
|||
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("");
|
||||
const [config, setConfig] = useState<ConnectionConfig>({
|
||||
relationshipName: "",
|
||||
relationshipType: "one-to-one",
|
||||
connectionType: "simple-key",
|
||||
fromColumnName: "",
|
||||
toColumnName: "",
|
||||
description: "",
|
||||
});
|
||||
|
||||
// 모달이 열릴 때마다 초기화
|
||||
// 모달이 열릴 때 기본값 설정
|
||||
useEffect(() => {
|
||||
if (isOpen && connection) {
|
||||
// 기본 관계명 생성
|
||||
const defaultName = `${connection.fromNode.screenName}_${connection.toNode.screenName}`;
|
||||
setRelationshipName(defaultName);
|
||||
setRelationshipType("one-to-one");
|
||||
setConnectionType("simple-key");
|
||||
// 시작/대상 필드는 비워둠 (다음 기능에서 사용)
|
||||
setFromFieldName("");
|
||||
setToFieldName("");
|
||||
setDescription("");
|
||||
const fromTableName = connection.fromNode.displayName;
|
||||
const toTableName = connection.toNode.displayName;
|
||||
|
||||
setConfig({
|
||||
relationshipName: `${fromTableName} → ${toTableName}`,
|
||||
relationshipType: "one-to-one",
|
||||
connectionType: "simple-key",
|
||||
fromColumnName: "",
|
||||
toColumnName: "",
|
||||
description: `${fromTableName}과 ${toTableName} 간의 데이터 관계`,
|
||||
});
|
||||
}
|
||||
}, [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 "";
|
||||
if (config.relationshipName && config.fromColumnName && config.toColumnName) {
|
||||
onConfirm(config);
|
||||
handleCancel(); // 모달 닫기
|
||||
}
|
||||
};
|
||||
|
||||
const getConnectionTypeDescription = (type: string) => {
|
||||
switch (type) {
|
||||
case "simple-key":
|
||||
return "단순 키값 연결 - 기본 참조 관계";
|
||||
case "data-save":
|
||||
return "데이터 저장 - 필드 매핑을 통한 데이터 저장";
|
||||
case "external-call":
|
||||
return "외부 호출 - API, 이메일, 웹훅 등을 통한 외부 시스템 연동";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
const handleCancel = () => {
|
||||
setConfig({
|
||||
relationshipName: "",
|
||||
relationshipType: "one-to-one",
|
||||
connectionType: "simple-key",
|
||||
fromColumnName: "",
|
||||
toColumnName: "",
|
||||
description: "",
|
||||
});
|
||||
onCancel();
|
||||
};
|
||||
|
||||
if (!connection) return null;
|
||||
|
||||
// 선택된 컬럼 데이터 가져오기
|
||||
const selectedColumnsData = connection.selectedColumnsData || {};
|
||||
const tableNames = Object.keys(selectedColumnsData);
|
||||
const fromTable = tableNames[0];
|
||||
const toTable = tableNames[1];
|
||||
|
||||
const fromTableData = selectedColumnsData[fromTable];
|
||||
const toTableData = selectedColumnsData[toTable];
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onCancel}>
|
||||
<Dialog open={isOpen} onOpenChange={handleCancel}>
|
||||
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<DialogTitle className="flex items-center gap-2 text-xl">
|
||||
<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 className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{/* 시작 테이블 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Database className="h-4 w-4" />
|
||||
시작 테이블
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium">{fromTableData?.displayName || fromTable}</div>
|
||||
<div className="text-xs text-gray-500">{fromTable}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{fromTableData?.columns.map((column, index) => (
|
||||
<Badge key={`${fromTable}-${column}-${index}`} variant="outline" className="text-xs">
|
||||
{column}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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 className="flex items-center justify-center md:hidden">
|
||||
<ArrowRight className="h-6 w-6 text-gray-400" />
|
||||
</div>
|
||||
|
||||
{/* 관계 설정 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">관계 설정</h3>
|
||||
{/* 대상 테이블 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Database className="h-4 w-4" />
|
||||
대상 테이블
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium">{toTableData?.displayName || toTable}</div>
|
||||
<div className="text-xs text-gray-500">{toTable}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{toTableData?.columns.map((column, index) => (
|
||||
<Badge key={`${toTable}-${column}-${index}`} variant="outline" className="text-xs">
|
||||
{column}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<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 className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="relationshipName">관계명</Label>
|
||||
<Input
|
||||
id="relationshipName"
|
||||
value={config.relationshipName}
|
||||
onChange={(e) => setConfig({ ...config, relationshipName: e.target.value })}
|
||||
placeholder="관계명을 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>연결 종류</Label>
|
||||
<Select value={connectionType} onValueChange={(value: any) => setConnectionType(value)}>
|
||||
<div>
|
||||
<Label htmlFor="relationshipType">관계 유형</Label>
|
||||
<Select
|
||||
value={config.relationshipType}
|
||||
onValueChange={(value: any) => setConfig({ ...config, relationshipType: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="연결 종류를 선택하세요" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="simple-key">단순 키값 연결</SelectItem>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="connectionType">연결 방식</Label>
|
||||
<Select
|
||||
value={config.connectionType}
|
||||
onValueChange={(value: any) => setConfig({ ...config, connectionType: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="fromColumnName">시작 컬럼</Label>
|
||||
<Input
|
||||
id="fromColumnName"
|
||||
value={config.fromColumnName}
|
||||
onChange={(e) => setConfig({ ...config, fromColumnName: e.target.value })}
|
||||
placeholder="시작 컬럼명을 입력하세요"
|
||||
/>
|
||||
</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>
|
||||
<Label htmlFor="toColumnName">대상 컬럼</Label>
|
||||
<Input
|
||||
id="toColumnName"
|
||||
value={config.toColumnName}
|
||||
onChange={(e) => setConfig({ ...config, toColumnName: e.target.value })}
|
||||
placeholder="대상 컬럼명을 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={config.description}
|
||||
onChange={(e) => setConfig({ ...config, description: e.target.value })}
|
||||
placeholder="연결에 대한 설명을 입력하세요"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={onCancel} variant="outline">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleConfirm}>연결 생성</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={!config.relationshipName || !config.fromColumnName || !config.toColumnName}
|
||||
>
|
||||
연결 생성
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -11,51 +11,75 @@ import {
|
|||
MiniMap,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
addEdge,
|
||||
Connection,
|
||||
BackgroundVariant,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { ScreenNode } from "./ScreenNode";
|
||||
import { CustomEdge } from "./CustomEdge";
|
||||
import { ScreenSelector } from "./ScreenSelector";
|
||||
import { TableNode } from "./TableNode";
|
||||
import { TableSelector } from "./TableSelector";
|
||||
import { ConnectionSetupModal } from "./ConnectionSetupModal";
|
||||
import { DataFlowAPI, ScreenDefinition, ColumnInfo, ScreenWithFields } from "@/lib/api/dataflow";
|
||||
import { TableDefinition } from "@/lib/api/dataflow";
|
||||
|
||||
// 테이블 노드 데이터 타입 정의
|
||||
interface TableNodeData extends Record<string, unknown> {
|
||||
table: {
|
||||
tableName: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
columns: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
description: string;
|
||||
}>;
|
||||
};
|
||||
onColumnClick: (tableName: string, columnName: string) => void;
|
||||
selectedColumns: string[];
|
||||
}
|
||||
|
||||
// 노드 및 엣지 타입 정의
|
||||
const nodeTypes = {
|
||||
screenNode: ScreenNode,
|
||||
tableNode: TableNode,
|
||||
};
|
||||
|
||||
const edgeTypes = {
|
||||
customEdge: CustomEdge,
|
||||
};
|
||||
const edgeTypes = {};
|
||||
|
||||
interface DataFlowDesignerProps {
|
||||
companyCode: string;
|
||||
onSave?: (relationships: any[]) => void;
|
||||
onSave?: (relationships: TableRelationship[]) => void;
|
||||
}
|
||||
|
||||
interface TableRelationship {
|
||||
relationshipId?: number;
|
||||
relationshipName: string;
|
||||
fromTableName: string;
|
||||
fromColumnName: string;
|
||||
toTableName: string;
|
||||
toColumnName: string;
|
||||
relationshipType: "one-to-one" | "one-to-many" | "many-to-one" | "many-to-many";
|
||||
connectionType: "simple-key" | "data-save" | "external-call";
|
||||
settings?: Record<string, unknown>;
|
||||
companyCode: string;
|
||||
isActive?: string;
|
||||
}
|
||||
|
||||
export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({ companyCode, onSave }) => {
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
||||
const [selectedFields, setSelectedFields] = useState<{
|
||||
[screenId: string]: string[];
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node<TableNodeData>>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
const [selectedColumns, setSelectedColumns] = useState<{
|
||||
[tableName: string]: string[];
|
||||
}>({});
|
||||
const [selectionOrder, setSelectionOrder] = useState<string[]>([]);
|
||||
const [loadingScreens, setLoadingScreens] = useState<Set<number>>(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[];
|
||||
fromNode: { id: string; tableName: string; displayName: string };
|
||||
toNode: { id: string; tableName: string; displayName: string };
|
||||
fromColumn?: string;
|
||||
toColumn?: string;
|
||||
selectedColumnsData?: {
|
||||
[tableName: string]: {
|
||||
displayName: string;
|
||||
columns: string[];
|
||||
};
|
||||
};
|
||||
} | null>(null);
|
||||
const [isOverNodeScrollArea, setIsOverNodeScrollArea] = useState(false);
|
||||
const toastShownRef = useRef(false);
|
||||
|
||||
// 빈 onConnect 함수 (드래그 연결 비활성화)
|
||||
|
|
@ -64,33 +88,34 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({ companyCode,
|
|||
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);
|
||||
// 컬럼 클릭 처리 (토글 방식, 최대 2개 테이블만 허용)
|
||||
const handleColumnClick = useCallback((tableName: string, columnName: string) => {
|
||||
setSelectedColumns((prev) => {
|
||||
const currentColumns = prev[tableName] || [];
|
||||
const isSelected = currentColumns.includes(columnName);
|
||||
const selectedTables = Object.keys(prev).filter((name) => prev[name] && prev[name].length > 0);
|
||||
|
||||
if (isSelected) {
|
||||
// 선택 해제
|
||||
const newFields = currentFields.filter((field) => field !== fieldName);
|
||||
if (newFields.length === 0) {
|
||||
const { [screenId]: _, ...rest } = prev;
|
||||
const newColumns = currentColumns.filter((column) => column !== columnName);
|
||||
if (newColumns.length === 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { [tableName]: removed, ...rest } = prev;
|
||||
// 선택 순서에서도 제거 (다음 렌더링에서)
|
||||
setTimeout(() => {
|
||||
setSelectionOrder((order) => order.filter((id) => id !== screenId));
|
||||
setSelectionOrder((order) => order.filter((name) => name !== tableName));
|
||||
}, 0);
|
||||
return rest;
|
||||
}
|
||||
return { ...prev, [screenId]: newFields };
|
||||
return { ...prev, [tableName]: newColumns };
|
||||
} else {
|
||||
// 선택 추가 - 새로운 화면이고 이미 2개 화면이 선택되어 있으면 거부
|
||||
if (!prev[screenId] && selectedScreens.length >= 2) {
|
||||
// 선택 추가 - 새로운 테이블이고 이미 2개 테이블이 선택되어 있으면 거부
|
||||
if (!prev[tableName] && selectedTables.length >= 2) {
|
||||
// 토스트 중복 방지를 위한 ref 사용
|
||||
if (!toastShownRef.current) {
|
||||
toastShownRef.current = true;
|
||||
setTimeout(() => {
|
||||
toast.error("최대 2개의 화면에서만 필드를 선택할 수 있습니다.", {
|
||||
toast.error("최대 2개의 테이블에서만 컬럼을 선택할 수 있습니다.", {
|
||||
duration: 3000,
|
||||
position: "top-center",
|
||||
});
|
||||
|
|
@ -103,195 +128,179 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({ companyCode,
|
|||
return prev;
|
||||
}
|
||||
|
||||
// 새로운 화면이면 선택 순서에 추가, 기존 화면이면 맨 뒤로 이동 (다음 렌더링에서)
|
||||
// 새로운 테이블이면 선택 순서에 추가, 기존 테이블이면 맨 뒤로 이동 (다음 렌더링에서)
|
||||
setTimeout(() => {
|
||||
setSelectionOrder((order) => {
|
||||
// 기존에 있던 화면이면 제거 후 맨 뒤에 추가 (순서 갱신)
|
||||
const filteredOrder = order.filter((id) => id !== screenId);
|
||||
return [...filteredOrder, screenId];
|
||||
// 기존에 있던 테이블이면 제거 후 맨 뒤에 추가 (순서 갱신)
|
||||
const filteredOrder = order.filter((name) => name !== tableName);
|
||||
return [...filteredOrder, tableName];
|
||||
});
|
||||
}, 0);
|
||||
|
||||
return { ...prev, [screenId]: [...currentFields, fieldName] };
|
||||
return { ...prev, [tableName]: [...currentColumns, columnName] };
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 선택된 필드가 변경될 때마다 기존 노드들 업데이트 및 selectionOrder 정리
|
||||
// 선택된 컬럼이 변경될 때마다 기존 노드들 업데이트 및 selectionOrder 정리
|
||||
useEffect(() => {
|
||||
setNodes((prevNodes) =>
|
||||
prevNodes.map((node) => ({
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
selectedFields: selectedFields[node.data.screen.screenId] || [],
|
||||
selectedColumns: selectedColumns[node.data.table.tableName] || [],
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
// selectionOrder에서 선택되지 않은 화면들 제거
|
||||
const activeScreens = Object.keys(selectedFields).filter(
|
||||
(screenId) => selectedFields[screenId] && selectedFields[screenId].length > 0,
|
||||
// selectionOrder에서 선택되지 않은 테이블들 제거
|
||||
const activeTables = Object.keys(selectedColumns).filter(
|
||||
(tableName) => selectedColumns[tableName] && selectedColumns[tableName].length > 0,
|
||||
);
|
||||
setSelectionOrder((prev) => prev.filter((screenId) => activeScreens.includes(screenId)));
|
||||
}, [selectedFields, setNodes]);
|
||||
setSelectionOrder((prev) => prev.filter((tableName) => activeTables.includes(tableName)));
|
||||
}, [selectedColumns, setNodes]);
|
||||
|
||||
// 연결 가능한 상태인지 확인
|
||||
const canCreateConnection = () => {
|
||||
const selectedScreens = Object.keys(selectedFields).filter(
|
||||
(screenId) => selectedFields[screenId] && selectedFields[screenId].length > 0,
|
||||
const selectedTables = Object.keys(selectedColumns).filter(
|
||||
(tableName) => selectedColumns[tableName] && selectedColumns[tableName].length > 0,
|
||||
);
|
||||
|
||||
// 최소 2개의 서로 다른 테이블에서 필드가 선택되어야 함
|
||||
return selectedScreens.length >= 2;
|
||||
// 최소 2개의 서로 다른 테이블에서 컬럼이 선택되어야 함
|
||||
return selectedTables.length >= 2;
|
||||
};
|
||||
|
||||
// 필드 연결 설정 모달 열기
|
||||
// 컬럼 연결 설정 모달 열기
|
||||
const openConnectionModal = () => {
|
||||
const selectedScreens = Object.keys(selectedFields).filter(
|
||||
(screenId) => selectedFields[screenId] && selectedFields[screenId].length > 0,
|
||||
const selectedTables = Object.keys(selectedColumns).filter(
|
||||
(tableName) => selectedColumns[tableName] && selectedColumns[tableName].length > 0,
|
||||
);
|
||||
|
||||
if (selectedScreens.length < 2) return;
|
||||
if (selectedTables.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);
|
||||
// 선택 순서에 따라 첫 번째와 두 번째 테이블 설정
|
||||
const orderedTables = selectionOrder.filter((name) => selectedTables.includes(name));
|
||||
const firstTableName = orderedTables[0];
|
||||
const secondTableName = orderedTables[1];
|
||||
const firstNode = nodes.find((node) => node.data.table.tableName === firstTableName);
|
||||
const secondNode = nodes.find((node) => node.data.table.tableName === secondTableName);
|
||||
|
||||
if (!firstNode || !secondNode) return;
|
||||
|
||||
setPendingConnection({
|
||||
fromNode: {
|
||||
id: firstNode.id,
|
||||
screenName: firstNode.data.screen.screenName,
|
||||
tableName: firstNode.data.screen.tableName,
|
||||
tableName: firstNode.data.table.tableName,
|
||||
displayName: firstNode.data.table.displayName,
|
||||
},
|
||||
toNode: {
|
||||
id: secondNode.id,
|
||||
screenName: secondNode.data.screen.screenName,
|
||||
tableName: secondNode.data.screen.tableName,
|
||||
tableName: secondNode.data.table.tableName,
|
||||
displayName: secondNode.data.table.displayName,
|
||||
},
|
||||
// 선택된 모든 필드 정보를 선택 순서대로 전달
|
||||
selectedFieldsData: (() => {
|
||||
const orderedData: { [key: string]: { screenName: string; fields: string[] } } = {};
|
||||
// 선택된 모든 컬럼 정보를 선택 순서대로 전달
|
||||
selectedColumnsData: (() => {
|
||||
const orderedData: { [key: string]: { displayName: string; columns: 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],
|
||||
orderedTables.forEach((tableName) => {
|
||||
const node = nodes.find((n) => n.data.table.tableName === tableName);
|
||||
if (node && selectedColumns[tableName]) {
|
||||
orderedData[tableName] = {
|
||||
displayName: node.data.table.displayName,
|
||||
columns: selectedColumns[tableName],
|
||||
};
|
||||
}
|
||||
});
|
||||
return orderedData;
|
||||
})(),
|
||||
// 명시적인 순서 정보 전달
|
||||
orderedScreenIds: orderedScreens,
|
||||
});
|
||||
};
|
||||
|
||||
// 실제 화면 노드 추가
|
||||
const addScreenNode = useCallback(
|
||||
async (screen: ScreenDefinition) => {
|
||||
// 실제 테이블 노드 추가
|
||||
const addTableNode = useCallback(
|
||||
async (table: TableDefinition) => {
|
||||
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",
|
||||
const newNode: Node<TableNodeData> = {
|
||||
id: `table-${table.tableName}`,
|
||||
type: "tableNode",
|
||||
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) => ({
|
||||
table: {
|
||||
tableName: table.tableName,
|
||||
displayName: table.displayName || table.tableName,
|
||||
description: table.description || "",
|
||||
columns: table.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] || [],
|
||||
onColumnClick: handleColumnClick,
|
||||
selectedColumns: selectedColumns[table.tableName] || [],
|
||||
},
|
||||
};
|
||||
|
||||
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;
|
||||
});
|
||||
console.error("테이블 노드 추가 실패:", error);
|
||||
toast.error("테이블 정보를 불러오는데 실패했습니다.");
|
||||
}
|
||||
},
|
||||
[handleFieldClick, setNodes],
|
||||
[handleColumnClick, selectedColumns, setNodes],
|
||||
);
|
||||
|
||||
// 샘플 화면 노드 추가 (개발용)
|
||||
// 샘플 테이블 노드 추가 (개발용)
|
||||
const addSampleNode = useCallback(() => {
|
||||
const newNode: Node = {
|
||||
const tableName = `sample_table_${nodes.length + 1}`;
|
||||
const newNode: Node<TableNodeData> = {
|
||||
id: `sample-${Date.now()}`,
|
||||
type: "screenNode",
|
||||
type: "tableNode",
|
||||
position: { x: Math.random() * 300, y: Math.random() * 200 },
|
||||
data: {
|
||||
screen: {
|
||||
screenId: `sample-${Date.now()}`,
|
||||
screenName: `샘플 화면 ${nodes.length + 1}`,
|
||||
screenCode: `SAMPLE${nodes.length + 1}`,
|
||||
tableName: `sample_table_${nodes.length + 1}`,
|
||||
fields: [
|
||||
table: {
|
||||
tableName,
|
||||
displayName: `샘플 테이블 ${nodes.length + 1}`,
|
||||
description: `샘플 테이블 설명 ${nodes.length + 1}`,
|
||||
columns: [
|
||||
{ name: "id", type: "INTEGER", description: "고유 식별자" },
|
||||
{ name: "name", type: "VARCHAR(100)", description: "이름" },
|
||||
{ name: "code", type: "VARCHAR(50)", description: "코드" },
|
||||
{ name: "created_date", type: "TIMESTAMP", description: "생성일시" },
|
||||
],
|
||||
},
|
||||
onFieldClick: handleFieldClick,
|
||||
onColumnClick: handleColumnClick,
|
||||
selectedColumns: selectedColumns[tableName] || [],
|
||||
},
|
||||
};
|
||||
|
||||
setNodes((nds) => nds.concat(newNode));
|
||||
}, [nodes.length, handleFieldClick, setNodes]);
|
||||
}, [nodes.length, handleColumnClick, selectedColumns, setNodes]);
|
||||
|
||||
// 노드 전체 삭제
|
||||
const clearNodes = useCallback(() => {
|
||||
setNodes([]);
|
||||
setEdges([]);
|
||||
setSelectedColumns({});
|
||||
setSelectionOrder([]);
|
||||
}, [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));
|
||||
// 현재 추가된 테이블명 목록 가져오기
|
||||
const getSelectedTableNames = useCallback(() => {
|
||||
return nodes.filter((node) => node.id.startsWith("table-")).map((node) => node.data.table.tableName);
|
||||
}, [nodes]);
|
||||
|
||||
// 연결 설정 확인
|
||||
const handleConfirmConnection = useCallback(
|
||||
(config: any) => {
|
||||
(config: { relationshipType: string; connectionType: string; relationshipName: string }) => {
|
||||
if (!pendingConnection) return;
|
||||
|
||||
const newEdge = {
|
||||
id: `edge-${Date.now()}`,
|
||||
source: pendingConnection.fromNode.id,
|
||||
target: pendingConnection.toNode.id,
|
||||
type: "customEdge",
|
||||
type: "default",
|
||||
data: {
|
||||
relationshipType: config.relationshipType,
|
||||
connectionType: config.connectionType,
|
||||
|
|
@ -319,19 +328,13 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({ companyCode,
|
|||
{/* 사이드바 */}
|
||||
<div className="w-80 border-r border-gray-200 bg-white shadow-lg">
|
||||
<div className="p-6">
|
||||
<h2 className="mb-6 text-xl font-bold text-gray-800">데이터 흐름 관리</h2>
|
||||
<h2 className="mb-6 text-xl font-bold text-gray-800">테이블 간 데이터 관계 설정</h2>
|
||||
|
||||
{/* 회사 정보 */}
|
||||
<div className="mb-6 rounded-lg bg-blue-50 p-4">
|
||||
<div className="text-sm font-medium text-blue-600">회사 코드</div>
|
||||
<div className="text-lg font-bold text-blue-800">{companyCode}</div>
|
||||
</div>
|
||||
|
||||
{/* 화면 선택기 */}
|
||||
<ScreenSelector
|
||||
{/* 테이블 선택기 */}
|
||||
<TableSelector
|
||||
companyCode={companyCode}
|
||||
onScreenAdd={addScreenNode}
|
||||
selectedScreens={getSelectedScreenIds()}
|
||||
onTableAdd={addTableNode}
|
||||
selectedTables={getSelectedTableNames()}
|
||||
/>
|
||||
|
||||
{/* 컨트롤 버튼들 */}
|
||||
|
|
@ -340,7 +343,7 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({ companyCode,
|
|||
onClick={addSampleNode}
|
||||
className="w-full rounded-lg bg-gray-500 p-3 font-medium text-white transition-colors hover:bg-gray-600"
|
||||
>
|
||||
+ 샘플 화면 추가 (개발용)
|
||||
+ 샘플 테이블 추가 (개발용)
|
||||
</button>
|
||||
|
||||
<button
|
||||
|
|
@ -363,7 +366,7 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({ companyCode,
|
|||
<div className="mb-2 text-sm font-semibold text-gray-700">통계</div>
|
||||
<div className="space-y-1 text-sm text-gray-600">
|
||||
<div className="flex justify-between">
|
||||
<span>화면 노드:</span>
|
||||
<span>테이블 노드:</span>
|
||||
<span className="font-medium">{nodes.length}개</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
|
|
@ -373,41 +376,41 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({ companyCode,
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 선택된 필드 정보 */}
|
||||
{Object.keys(selectedFields).length > 0 && (
|
||||
{/* 선택된 컬럼 정보 */}
|
||||
{Object.keys(selectedColumns).length > 0 && (
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||
<div className="mb-3 text-sm font-semibold text-blue-800">선택된 필드</div>
|
||||
<div className="mb-3 text-sm font-semibold text-blue-800">선택된 컬럼</div>
|
||||
<div className="space-y-3">
|
||||
{[...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;
|
||||
.filter((tableName) => selectedColumns[tableName] && selectedColumns[tableName].length > 0)
|
||||
.map((tableName, index, filteredOrder) => {
|
||||
const columns = selectedColumns[tableName];
|
||||
const node = nodes.find((n) => n.data.table.tableName === tableName);
|
||||
const displayName = node?.data.table.displayName || tableName;
|
||||
|
||||
return (
|
||||
<div key={`selected-${screenId}-${index}`}>
|
||||
<div key={`selected-${tableName}-${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}
|
||||
{displayName}
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-xs text-gray-500">ID: {screenId}</div>
|
||||
<div className="flex-shrink-0 text-xs text-gray-500">{tableName}</div>
|
||||
</div>
|
||||
<div className="flex w-full min-w-0 flex-wrap gap-1">
|
||||
{fields.map((field, fieldIndex) => (
|
||||
{columns.map((column, columnIndex) => (
|
||||
<div
|
||||
key={`${screenId}-${field}-${fieldIndex}`}
|
||||
key={`${tableName}-${column}-${columnIndex}`}
|
||||
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}
|
||||
title={column}
|
||||
>
|
||||
{field}
|
||||
{column}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* 첫 번째 화면 다음에 화살표 표시 */}
|
||||
{/* 첫 번째 테이블 다음에 화살표 표시 */}
|
||||
{index === 0 && filteredOrder.length > 1 && (
|
||||
<div className="flex justify-center py-2">
|
||||
<div className="text-gray-400">↓</div>
|
||||
|
|
@ -427,11 +430,11 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({ companyCode,
|
|||
: "cursor-not-allowed bg-gray-300 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
필드 연결 설정
|
||||
컬럼 연결 설정
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedFields({});
|
||||
setSelectedColumns({});
|
||||
setSelectionOrder([]);
|
||||
}}
|
||||
className="rounded bg-gray-200 px-3 py-1 text-xs font-medium text-gray-600 hover:bg-gray-300"
|
||||
|
|
@ -466,14 +469,14 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({ companyCode,
|
|||
<MiniMap
|
||||
nodeColor={(node) => {
|
||||
switch (node.type) {
|
||||
case "screenNode":
|
||||
case "tableNode":
|
||||
return "#3B82F6";
|
||||
default:
|
||||
return "#6B7280";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Background variant="dots" gap={20} size={1} color="#E5E7EB" />
|
||||
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#E5E7EB" />
|
||||
</ReactFlow>
|
||||
|
||||
{/* 안내 메시지 */}
|
||||
|
|
@ -481,8 +484,8 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({ companyCode,
|
|||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<div className="mb-2 text-2xl">📊</div>
|
||||
<div className="mb-1 text-lg font-medium">데이터 흐름 설계를 시작하세요</div>
|
||||
<div className="text-sm">왼쪽 사이드바에서 화면을 선택하여 추가하세요</div>
|
||||
<div className="mb-1 text-lg font-medium">테이블 간 데이터 관계 설정을 시작하세요</div>
|
||||
<div className="text-sm">왼쪽 사이드바에서 테이블을 선택하여 추가하세요</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,101 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Handle, Position, NodeResizer } from "@xyflow/react";
|
||||
|
||||
interface ScreenField {
|
||||
name: string;
|
||||
type: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface Screen {
|
||||
screenId: string;
|
||||
screenName: string;
|
||||
screenCode: string;
|
||||
tableName: string;
|
||||
fields: ScreenField[];
|
||||
}
|
||||
|
||||
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; selected?: boolean }> = ({ data, selected }) => {
|
||||
const { screen, onFieldClick, onScrollAreaEnter, onScrollAreaLeave, selectedFields = [] } = data;
|
||||
|
||||
// 필드 타입에 따른 색상 반환
|
||||
const getFieldTypeColor = (type: string) => {
|
||||
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 (
|
||||
<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="mb-1 text-base font-bold">{screen.screenName}</div>
|
||||
<div className="mb-1 text-sm opacity-90">ID: {screen.screenCode}</div>
|
||||
<div className="flex items-center text-xs opacity-75">
|
||||
<span className="mr-1">🗃️</span>
|
||||
테이블: {screen.tableName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필드 목록 */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<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>
|
||||
|
||||
<div
|
||||
className="flex-1 space-y-2 overflow-y-auto"
|
||||
onMouseEnter={onScrollAreaEnter}
|
||||
onMouseLeave={onScrollAreaLeave}
|
||||
>
|
||||
{screen.fields.map((field, index) => {
|
||||
const isSelected = selectedFields.includes(field.name);
|
||||
return (
|
||||
<div
|
||||
key={field.name}
|
||||
className={`relative flex cursor-pointer items-center justify-between rounded-lg border p-3 transition-colors ${
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
"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>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Handle, Position, NodeResizer } from "@xyflow/react";
|
||||
|
||||
interface TableColumn {
|
||||
name: string;
|
||||
type: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface Table {
|
||||
tableName: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
columns: TableColumn[];
|
||||
}
|
||||
|
||||
interface TableNodeData {
|
||||
table: Table;
|
||||
onColumnClick: (tableName: string, columnName: string) => void;
|
||||
onScrollAreaEnter?: () => void;
|
||||
onScrollAreaLeave?: () => void;
|
||||
selected?: boolean;
|
||||
selectedColumns?: string[]; // 선택된 컬럼 목록
|
||||
}
|
||||
|
||||
export const TableNode: React.FC<{ data: TableNodeData; selected?: boolean }> = ({ data, selected }) => {
|
||||
const { table, onColumnClick, onScrollAreaEnter, onScrollAreaLeave, selectedColumns = [] } = data;
|
||||
|
||||
return (
|
||||
<div className="relative min-h-[200px] min-w-[280px] rounded-lg border-2 border-gray-300 bg-white shadow-lg">
|
||||
{/* NodeResizer for resizing functionality */}
|
||||
<NodeResizer
|
||||
color="#ff0071"
|
||||
isVisible={selected}
|
||||
minWidth={280}
|
||||
minHeight={200}
|
||||
handleStyle={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
backgroundColor: "#ff0071",
|
||||
border: "1px solid white",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 테이블 헤더 */}
|
||||
<div className="rounded-t-lg bg-blue-600 p-3 text-white">
|
||||
<h3 className="truncate text-sm font-semibold">{table.displayName}</h3>
|
||||
<p className="truncate text-xs opacity-90">{table.tableName}</p>
|
||||
{table.description && <p className="mt-1 truncate text-xs opacity-75">{table.description}</p>}
|
||||
</div>
|
||||
|
||||
{/* 컬럼 목록 */}
|
||||
<div
|
||||
className="max-h-[300px] overflow-y-auto p-2"
|
||||
onMouseEnter={onScrollAreaEnter}
|
||||
onMouseLeave={onScrollAreaLeave}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{table.columns.map((column) => {
|
||||
const isSelected = selectedColumns.includes(column.name);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={column.name}
|
||||
onClick={() => onColumnClick(table.tableName, column.name)}
|
||||
className={`cursor-pointer rounded px-2 py-1 text-xs transition-colors ${
|
||||
isSelected ? "bg-blue-100 text-blue-800 ring-2 ring-blue-500" : "text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono font-medium">{column.name}</span>
|
||||
<span className="text-gray-500">{column.type}</span>
|
||||
</div>
|
||||
{column.description && <div className="mt-0.5 text-gray-500">{column.description}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* React Flow Handles */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="h-3 w-3 border-2 border-gray-400 bg-white"
|
||||
isConnectable={false}
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="h-3 w-3 border-2 border-gray-400 bg-white"
|
||||
isConnectable={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
"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 } from "lucide-react";
|
||||
import { DataFlowAPI, TableDefinition, TableInfo } from "@/lib/api/dataflow";
|
||||
|
||||
interface TableSelectorProps {
|
||||
companyCode: string;
|
||||
onTableAdd: (table: TableDefinition) => void;
|
||||
selectedTables?: string[]; // 이미 추가된 테이블들의 이름
|
||||
}
|
||||
|
||||
export const TableSelector: React.FC<TableSelectorProps> = ({ companyCode, onTableAdd, selectedTables = [] }) => {
|
||||
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||
const [filteredTables, setFilteredTables] = useState<TableInfo[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
loadTables();
|
||||
}, []);
|
||||
|
||||
// 검색 필터링
|
||||
useEffect(() => {
|
||||
if (searchTerm.trim() === "") {
|
||||
setFilteredTables(tables);
|
||||
} else {
|
||||
const filtered = tables.filter(
|
||||
(table) =>
|
||||
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
table.displayName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(table.description && table.description.toLowerCase().includes(searchTerm.toLowerCase())),
|
||||
);
|
||||
setFilteredTables(filtered);
|
||||
}
|
||||
}, [tables, searchTerm]);
|
||||
|
||||
const loadTables = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const tableList = await DataFlowAPI.getTables();
|
||||
setTables(tableList);
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로드 실패:", error);
|
||||
setError("테이블 목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddTable = async (tableInfo: TableInfo) => {
|
||||
try {
|
||||
// 테이블 상세 정보 (컬럼 포함) 조회
|
||||
const tableWithColumns = await DataFlowAPI.getTableWithColumns(tableInfo.tableName);
|
||||
if (tableWithColumns) {
|
||||
onTableAdd(tableWithColumns);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 추가 실패:", error);
|
||||
setError("테이블을 추가하는데 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const isTableSelected = (tableName: string) => {
|
||||
return selectedTables.includes(tableName);
|
||||
};
|
||||
|
||||
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={loadTables} 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"
|
||||
disabled={loading}
|
||||
/>
|
||||
</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>
|
||||
) : filteredTables.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
{searchTerm ? "검색 결과가 없습니다." : "등록된 테이블이 없습니다."}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
filteredTables.map((table) => (
|
||||
<Card
|
||||
key={table.tableName}
|
||||
className={`transition-all hover:shadow-md ${
|
||||
isTableSelected(table.tableName) ? "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">{table.displayName}</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="default" className="text-xs">
|
||||
{table.columnCount}개 컬럼
|
||||
</Badge>
|
||||
<Button
|
||||
onClick={() => handleAddTable(table)}
|
||||
disabled={isTableSelected(table.tableName)}
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
{isTableSelected(table.tableName) ? "추가됨" : "추가"}
|
||||
</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">
|
||||
<Database className="h-3 w-3" />
|
||||
<span className="font-mono">{table.tableName}</span>
|
||||
</div>
|
||||
|
||||
{table.description && <p className="line-clamp-2 text-xs text-gray-500">{table.description}</p>}
|
||||
</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>전체 테이블: {tables.length}개</span>
|
||||
{searchTerm && <span>검색 결과: {filteredTables.length}개</span>}
|
||||
</div>
|
||||
{selectedTables.length > 0 && <div className="mt-1">캔버스에 추가된 테이블: {selectedTables.length}개</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,19 +1,6 @@
|
|||
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;
|
||||
|
|
@ -36,17 +23,27 @@ export interface ColumnInfo {
|
|||
description?: string;
|
||||
}
|
||||
|
||||
export interface ScreenWithFields extends ScreenDefinition {
|
||||
fields: ColumnInfo[];
|
||||
export interface TableDefinition {
|
||||
tableName: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
columns: ColumnInfo[];
|
||||
}
|
||||
|
||||
export interface ScreenRelationship {
|
||||
export interface TableInfo {
|
||||
tableName: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
columnCount: number;
|
||||
}
|
||||
|
||||
export interface TableRelationship {
|
||||
relationshipId?: number;
|
||||
relationshipName: string;
|
||||
fromScreenId: number;
|
||||
fromFieldName: string;
|
||||
toScreenId: number;
|
||||
toFieldName: string;
|
||||
fromTableName: string;
|
||||
fromColumnName: string;
|
||||
toTableName: string;
|
||||
toColumnName: 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>;
|
||||
|
|
@ -54,24 +51,22 @@ export interface ScreenRelationship {
|
|||
isActive?: string;
|
||||
}
|
||||
|
||||
// 데이터 흐름 관리 API 클래스
|
||||
// 테이블 간 데이터 관계 설정 API 클래스
|
||||
export class DataFlowAPI {
|
||||
/**
|
||||
* 회사별 화면 목록 조회
|
||||
* 테이블 목록 조회
|
||||
*/
|
||||
static async getScreensByCompany(companyCode: string): Promise<ScreenDefinition[]> {
|
||||
static async getTables(): Promise<TableInfo[]> {
|
||||
try {
|
||||
const response = await apiClient.get<ApiResponse<ScreenDefinition[]>>("/screen-management/screens", {
|
||||
params: { companyCode },
|
||||
});
|
||||
const response = await apiClient.get<ApiResponse<TableInfo[]>>("/table-management/tables");
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "화면 목록 조회에 실패했습니다.");
|
||||
throw new Error(response.data.message || "테이블 목록 조회에 실패했습니다.");
|
||||
}
|
||||
|
||||
return response.data.data || [];
|
||||
} catch (error) {
|
||||
console.error("화면 목록 조회 오류:", error);
|
||||
console.error("테이블 목록 조회 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -95,39 +90,30 @@ export class DataFlowAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* 화면과 필드 정보를 함께 조회
|
||||
* 테이블 정보와 컬럼 정보를 함께 조회
|
||||
*/
|
||||
static async getScreenWithFields(screenId: number, tableName: string): Promise<ScreenWithFields | null> {
|
||||
static async getTableWithColumns(tableName: string): Promise<TableDefinition | 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;
|
||||
}
|
||||
const columns = await this.getTableColumns(tableName);
|
||||
|
||||
return {
|
||||
...screen,
|
||||
fields: columnsResponse,
|
||||
tableName,
|
||||
displayName: tableName,
|
||||
description: `${tableName} 테이블`,
|
||||
columns,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("화면 및 필드 정보 조회 오류:", error);
|
||||
console.error("테이블 및 컬럼 정보 조회 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 관계 생성
|
||||
* 테이블 관계 생성
|
||||
*/
|
||||
static async createRelationship(
|
||||
relationship: Omit<ScreenRelationship, "relationshipId">,
|
||||
): Promise<ScreenRelationship> {
|
||||
static async createRelationship(relationship: Omit<TableRelationship, "relationshipId">): Promise<TableRelationship> {
|
||||
try {
|
||||
const response = await apiClient.post<ApiResponse<ScreenRelationship>>("/dataflow/relationships", relationship);
|
||||
const response = await apiClient.post<ApiResponse<TableRelationship>>("/table-relationships", relationship);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "관계 생성에 실패했습니다.");
|
||||
|
|
@ -141,11 +127,11 @@ export class DataFlowAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* 회사별 화면 관계 목록 조회
|
||||
* 회사별 테이블 관계 목록 조회
|
||||
*/
|
||||
static async getRelationshipsByCompany(companyCode: string): Promise<ScreenRelationship[]> {
|
||||
static async getRelationshipsByCompany(companyCode: string): Promise<TableRelationship[]> {
|
||||
try {
|
||||
const response = await apiClient.get<ApiResponse<ScreenRelationship[]>>("/dataflow/relationships", {
|
||||
const response = await apiClient.get<ApiResponse<TableRelationship[]>>("/table-relationships", {
|
||||
params: { companyCode },
|
||||
});
|
||||
|
||||
|
|
@ -161,15 +147,15 @@ export class DataFlowAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* 화면 관계 수정
|
||||
* 테이블 관계 수정
|
||||
*/
|
||||
static async updateRelationship(
|
||||
relationshipId: number,
|
||||
relationship: Partial<ScreenRelationship>,
|
||||
): Promise<ScreenRelationship> {
|
||||
relationship: Partial<TableRelationship>,
|
||||
): Promise<TableRelationship> {
|
||||
try {
|
||||
const response = await apiClient.put<ApiResponse<ScreenRelationship>>(
|
||||
`/dataflow/relationships/${relationshipId}`,
|
||||
const response = await apiClient.put<ApiResponse<TableRelationship>>(
|
||||
`/table-relationships/${relationshipId}`,
|
||||
relationship,
|
||||
);
|
||||
|
||||
|
|
@ -185,11 +171,11 @@ export class DataFlowAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* 화면 관계 삭제
|
||||
* 테이블 관계 삭제
|
||||
*/
|
||||
static async deleteRelationship(relationshipId: number): Promise<void> {
|
||||
try {
|
||||
const response = await apiClient.delete<ApiResponse<null>>(`/dataflow/relationships/${relationshipId}`);
|
||||
const response = await apiClient.delete<ApiResponse<null>>(`/table-relationships/${relationshipId}`);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "관계 삭제에 실패했습니다.");
|
||||
|
|
|
|||
Loading…
Reference in New Issue