feat: 저장 테이블 정보 및 애니메이션 기능 추가
- 화면 서브 테이블에서 저장 테이블 정보를 추출하는 쿼리 추가 - 저장 테이블 정보 구조를 TableNodeData 인터페이스에 통합 - 저장 테이블의 시각적 표현을 위한 애니메이션 효과 추가 - 필터링 및 참조 관계 뱃지 레이아웃 개선 - 테이블 높이 부드러운 애니메이션 및 스크롤 기능 구현
This commit is contained in:
@@ -1184,6 +1184,12 @@ export const getScreenSubTables = async (req: Request, res: Response) => {
|
||||
relationType: string; // 'join' | 'lookup' | 'source' | 'reference'
|
||||
fieldMappings?: Array<{ sourceField: string; targetField: string; sourceDisplayName?: string; targetDisplayName?: string }>;
|
||||
}>;
|
||||
saveTables?: Array<{
|
||||
tableName: string;
|
||||
saveType: 'save' | 'edit' | 'delete' | 'transferData';
|
||||
componentType: string;
|
||||
isMainTable: boolean;
|
||||
}>;
|
||||
}> = {};
|
||||
|
||||
// 1. 기존 방식: componentConfig에서 tableName, sourceTable, fieldMappings 추출
|
||||
@@ -1892,13 +1898,74 @@ export const getScreenSubTables = async (req: Request, res: Response) => {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 저장 테이블 정보 추출
|
||||
// ============================================================
|
||||
const saveTableQuery = `
|
||||
SELECT DISTINCT
|
||||
sd.screen_id,
|
||||
sd.screen_name,
|
||||
sd.table_name as main_table,
|
||||
sl.properties->'componentConfig'->'action'->>'type' as action_type,
|
||||
sl.properties->>'componentType' as component_type,
|
||||
sl.properties->'componentConfig'->>'targetTable' as target_table,
|
||||
sl.properties->'componentConfig'->'action'->'dataTransfer'->>'targetTable' as transfer_target_table
|
||||
FROM screen_definitions sd
|
||||
JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
|
||||
WHERE sd.screen_id = ANY($1)
|
||||
AND sl.properties->'componentConfig'->'action'->>'type' = 'save'
|
||||
AND sl.properties->'componentConfig'->'action'->>'targetScreenId' IS NULL
|
||||
ORDER BY sd.screen_id
|
||||
`;
|
||||
|
||||
const saveTableResult = await pool.query(saveTableQuery, [screenIds]);
|
||||
|
||||
saveTableResult.rows.forEach((row: any) => {
|
||||
const screenId = row.screen_id;
|
||||
const mainTable = row.main_table;
|
||||
const actionType = row.action_type as 'save' | 'edit' | 'delete' | 'transferData';
|
||||
const componentType = row.component_type || 'component';
|
||||
const targetTable = row.target_table || row.transfer_target_table || mainTable;
|
||||
|
||||
// 화면 정보가 없으면 초기화
|
||||
if (!screenSubTables[screenId]) {
|
||||
screenSubTables[screenId] = {
|
||||
screenId,
|
||||
screenName: row.screen_name,
|
||||
mainTable: mainTable || '',
|
||||
subTables: [],
|
||||
saveTables: [],
|
||||
};
|
||||
}
|
||||
|
||||
// saveTables 배열 초기화
|
||||
if (!screenSubTables[screenId].saveTables) {
|
||||
screenSubTables[screenId].saveTables = [];
|
||||
}
|
||||
|
||||
// 중복 체크
|
||||
const existingSaveTable = screenSubTables[screenId].saveTables!.find(
|
||||
(st) => st.tableName === targetTable && st.saveType === actionType
|
||||
);
|
||||
|
||||
if (!existingSaveTable && targetTable) {
|
||||
screenSubTables[screenId].saveTables!.push({
|
||||
tableName: targetTable,
|
||||
saveType: actionType,
|
||||
componentType,
|
||||
isMainTable: targetTable === mainTable,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
logger.info("화면 서브 테이블 정보 조회 완료", {
|
||||
screenIds,
|
||||
resultCount: Object.keys(screenSubTables).length,
|
||||
details: Object.values(screenSubTables).map(s => ({
|
||||
screenId: s.screenId,
|
||||
mainTable: s.mainTable,
|
||||
subTables: s.subTables.map(st => st.tableName)
|
||||
subTables: s.subTables.map(st => st.tableName),
|
||||
saveTables: s.saveTables?.map(st => st.tableName) || []
|
||||
}))
|
||||
});
|
||||
|
||||
|
||||
@@ -1053,19 +1053,39 @@ screenSubTables[screenId].subTables.push({
|
||||
|
||||
10. **방안 C 적용: 필터선 제거 + 보라색 테두리 애니메이션**
|
||||
- 필터 관계는 선 없이 뱃지 + 테이블 테두리로만 표시 (겹침 방지)
|
||||
- 필터링된 테이블에 **보라색 테두리 + 펄스 애니메이션** 적용
|
||||
- 필터링된 테이블에 **보라색 테두리** 적용 (부드러운 색상 전환)
|
||||
- 조인선(주황)만 표시, 필터선(보라) 제거
|
||||
|
||||
11. **테이블 높이 부드러운 애니메이션**
|
||||
- 포커스 시 컬럼 목록이 변경될 때 **부드러운 높이 전환** 적용
|
||||
- `transition: height 0.5s cubic-bezier(0.4, 0, 0.2, 1)` 사용
|
||||
- **Debounce 로직** (50ms): 듀얼 그리드에서 filterColumns와 joinColumns가 2단계로 업데이트되는 문제 해결
|
||||
- 중간 값(늘어났다가 줄어드는 현상) 무시, 최종 값만 적용
|
||||
|
||||
12. **뱃지 영역 레이아웃 개선**
|
||||
- 뱃지를 컬럼 목록 영역 **안에 포함** (높이 늘어남 방지)
|
||||
- `calculatedHeight`에 뱃지 높이(26px) 포함하여 계산
|
||||
- 뱃지와 컬럼 동시 변경으로 "늘어났다가 줄어드는" 현상 해결
|
||||
|
||||
13. **뱃지 스타일 개선**
|
||||
- 회색 테두리 (`border-slate-300`) + 연한 배경 (`bg-slate-50`)
|
||||
- 보라색 컬럼과 확실히 구분되는 디자인
|
||||
- 필터 태그: 보라색 pill 스타일 (`rounded-full bg-violet-600`)
|
||||
|
||||
**시각적 표현:**
|
||||
| 관계 유형 | 선 표시 | 테두리 | 배지 |
|
||||
|----------|---------|--------|------|
|
||||
| 조인 | ✅ 주황색 점선 | - | "조인" |
|
||||
| 필터 | ❌ 없음 | 보라색 펄스 | "필터 + 키값" |
|
||||
| 필터 | ❌ 없음 | 보라색 (부드러운 전환) | "필터 + 키값" |
|
||||
| 룩업 | ✅ 황색 점선 | - | "N곳 참조" |
|
||||
|
||||
**구현 상세:**
|
||||
- `ScreenRelationFlow.tsx`: `visualRelationType === 'filter'`인 경우 엣지 생성 건너뛰기
|
||||
- `ScreenNode.tsx`: `hasFilterRelation` 조건으로 보라색 테두리 + `animate-pulse` 클래스 적용
|
||||
- `ScreenNode.tsx`:
|
||||
- `hasFilterRelation` 조건으로 보라색 테두리 + 부드러운 색상 전환 적용
|
||||
- `calculatedHeight`에 뱃지 높이 포함
|
||||
- `debouncedHeight` 사용으로 중간 값 무시
|
||||
- 뱃지를 컬럼 목록 div 안에 배치
|
||||
|
||||
### 향후 개선 가능 사항
|
||||
|
||||
@@ -1087,9 +1107,182 @@ screenSubTables[screenId].subTables.push({
|
||||
8. [x] FK 컬럼 보라색 강조 + 키값 정보 표시
|
||||
9. [x] 포커스 상태 기반 필터 표시 개선
|
||||
10. [x] 필터 컬럼 상단 정렬 (조인 → 필터 → 사용 순서)
|
||||
11. [x] 방안 C 적용: 필터선 제거 + 보라색 테두리 애니메이션
|
||||
12. [ ] 범례 UI 추가 (선택사항)
|
||||
13. [ ] 엣지 라벨에 관계 유형 표시 (선택사항)
|
||||
11. [x] 방안 C 적용: 필터선 제거 + 보라색 테두리 (펄스 → 부드러운 전환으로 변경)
|
||||
12. [x] 테이블 높이 부드러운 애니메이션 + Debounce 적용
|
||||
13. [x] 뱃지 영역 레이아웃 개선 (컬럼 목록 안에 포함)
|
||||
14. [x] 뱃지 스타일 개선 (회색 테두리로 컬럼과 구분)
|
||||
15. [x] 서브테이블 Y 좌표 조정 (690px → 740px)
|
||||
16. [x] **저장 테이블 시각화** (구현 완료)
|
||||
17. [x] 테이블 스크롤 기능 추가 (maxHeight + overflow-y-auto)
|
||||
18. [x] 테이블/헤더 둥근 모서리 (rounded-xl, rounded-t-xl)
|
||||
19. [x] 필터 테이블 조인선 + 참조 테이블 활성화
|
||||
20. [x] 조인선 색상 상수 통일 (RELATION_COLORS.join.stroke)
|
||||
21. [ ] **선 교차점 이질감 해결** (계획 중)
|
||||
22. [ ] 범례 UI 추가 (선택사항)
|
||||
23. [ ] 엣지 라벨에 관계 유형 표시 (선택사항)
|
||||
|
||||
---
|
||||
|
||||
## 저장 테이블 시각화 (구현 완료)
|
||||
|
||||
### 개요
|
||||
화면에서 데이터가 **어떤 테이블에 저장**되는지 시각화
|
||||
|
||||
### 저장 테이블 유형
|
||||
|
||||
| 유형 | 설명 | 예시 |
|
||||
|------|------|------|
|
||||
| **메인 저장** | 화면의 메인 테이블에 직접 저장 | 수주등록 → `sales_order_mng` |
|
||||
| **연계 저장** | 버튼 클릭 → 다른 화면의 테이블에 저장 | 수주관리 → 출하계획 → `shipment_plan` |
|
||||
| **서브 저장** | 듀얼 그리드에서 서브 테이블에 저장 | 거래처관리 → `customer_item_mapping` |
|
||||
|
||||
### 데이터 수집 방법 (백엔드)
|
||||
|
||||
저장 테이블 정보를 찾을 수 있는 곳:
|
||||
1. `componentConfig.action.type = 'save'` (edit, delete 제외)
|
||||
2. `componentConfig.targetTable` (modal-repeater-table 등)
|
||||
3. `action.dataTransfer.targetTable` (데이터 전송 대상)
|
||||
4. **제외 조건**: `action.targetScreenId IS NOT NULL` (모달 열기 버튼)
|
||||
|
||||
### 시각적 표현 (구현됨)
|
||||
|
||||
**핑크색 막대기 표시**
|
||||
- 테이블 노드 **왼쪽 바깥**에 핑크색 세로 막대기 표시
|
||||
- 위에서 아래로 나타나는 애니메이션 (`scaleY` 트랜지션)
|
||||
- 포커스 해제 시 사라지는 애니메이션
|
||||
- 막대기 양끝 그라데이션 (투명 → 핑크 → 투명)
|
||||
|
||||
**스타일:**
|
||||
```css
|
||||
/* 저장 막대기 스타일 */
|
||||
position: absolute;
|
||||
left: -6px; /* -left-1.5 */
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
width: 2px; /* w-0.5 */
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
#f472b6 15%, /* pink-400 */
|
||||
#f472b6 85%,
|
||||
transparent 100%
|
||||
);
|
||||
transition: all 0.5s ease-out;
|
||||
transform-origin: top;
|
||||
```
|
||||
|
||||
**애니메이션:**
|
||||
- 포커스 시: `opacity: 1, scaleY: 1` (나타남)
|
||||
- 포커스 해제 시: `opacity: 0, scaleY: 0` (사라짐)
|
||||
|
||||
### 색상 팔레트
|
||||
|
||||
| 관계 유형 | 선 색상 | 뱃지/막대 색상 | 컬럼 강조 |
|
||||
|----------|---------|---------------|----------|
|
||||
| 조인 | 주황 (#F97316) | 주황 | 주황 |
|
||||
| 필터 | - | 보라 (#8B5CF6) | 보라 |
|
||||
| 룩업 | 황색 (#EAB308) | 황색 | - |
|
||||
| **저장** | - | 핑크 (#F472B6) | - |
|
||||
|
||||
### 구현 단계 (완료)
|
||||
|
||||
1. [x] 백엔드: `getScreenSubTables`에서 저장 테이블 정보 추출
|
||||
2. [x] 타입 정의: `SaveTableInfo` 인터페이스 추가
|
||||
3. [x] 프론트엔드: 핑크색 막대기 UI 구현
|
||||
4. [x] 프론트엔드: 포커싱 시에만 표시
|
||||
5. [x] 프론트엔드: 나타나기/사라지기 애니메이션
|
||||
6. [ ] 프론트엔드: 뱃지 클릭 시 팝오버 상세정보 (향후)
|
||||
|
||||
---
|
||||
|
||||
## 필터 테이블 조인선 시각화 (구현 완료)
|
||||
|
||||
### 개요
|
||||
마스터-디테일 관계에서 **필터 대상 테이블**이 **다른 테이블과 조인**하는 경우도 시각화
|
||||
|
||||
### 시나리오
|
||||
"거래처관리 화면" (1번 화면) 포커싱 시:
|
||||
- `customer_mng` (마스터) → `customer_item_mapping` (디테일) 필터 관계
|
||||
- `customer_item_mapping` → `item_info` **조인 관계** (품목 ID → 품번)
|
||||
|
||||
### 구현 내용
|
||||
|
||||
1. **화면 → 필터 대상 테이블 연결선**
|
||||
- 파란색 점선으로 화면 → `customer_item_mapping` 연결
|
||||
- 기존 `customer_mng`로만 가던 연결 외에 추가
|
||||
|
||||
2. **필터 대상 테이블의 조인선**
|
||||
- `customer_item_mapping` → `item_info` 주황색 점선 조인선
|
||||
- `joinColumnRefs` 기반으로 자동 생성
|
||||
|
||||
3. **참조 테이블 활성화**
|
||||
- `item_info` 테이블도 함께 활성화 (회색 처리 안 함)
|
||||
- 조인 컬럼 주황색 강조 표시
|
||||
|
||||
### 포커싱 제어
|
||||
- 해당 화면이 포커싱됐을 때만 조인선 활성화
|
||||
- 다른 화면 포커싱 시 흐리게 처리 (opacity: 0.3)
|
||||
|
||||
### 코드 위치
|
||||
- `ScreenRelationFlow.tsx`: 필터 조인 엣지 생성 로직
|
||||
- `styledNodes`: 필터 대상 테이블의 조인 참조 테이블 활성화 로직
|
||||
|
||||
---
|
||||
|
||||
## 테이블 노드 UI 개선 (구현 완료)
|
||||
|
||||
### 스크롤 기능
|
||||
- 컬럼이 많을 경우 스크롤 가능 (`overflow-y-auto`)
|
||||
- 최대 높이 제한 (`maxHeight: 300px`)
|
||||
- 얇은 스크롤바 (`scrollbar-thin`)
|
||||
|
||||
### 둥근 모서리
|
||||
- 테이블 전체: `rounded-xl` (12px)
|
||||
- 헤더: `rounded-t-xl` (상단만 12px)
|
||||
|
||||
### 조인선 색상 통일
|
||||
- 모든 조인선이 `RELATION_COLORS.join.stroke` 상수 사용
|
||||
- 기본 색상: `#f97316` (orange-500)
|
||||
- 강조 색상: `#ea580c` (orange-600)
|
||||
|
||||
---
|
||||
|
||||
## [계획] 선 교차점 이질감 해결
|
||||
|
||||
> **상태**: 방안 검토 중 (미구현)
|
||||
|
||||
### 배경
|
||||
여러 파란색 연결선이 서로 교차할 때 시각적 이질감 발생
|
||||
|
||||
### 해결 방안
|
||||
|
||||
#### 방안 C: 배경색 테두리 (Outline) - 권장
|
||||
- 각 선에 **흰색 테두리(outline)** 추가
|
||||
- 교차할 때 위에 있는 선이 아래 선을 "덮는" 효과
|
||||
- SVG stroke에 흰색 outline 적용
|
||||
|
||||
**구현 방식:**
|
||||
```typescript
|
||||
// 커스텀 엣지 컴포넌트에서
|
||||
<path
|
||||
d={edgePath}
|
||||
stroke="white"
|
||||
strokeWidth={strokeWidth + 4} // 배경 테두리
|
||||
/>
|
||||
<path
|
||||
d={edgePath}
|
||||
stroke={strokeColor}
|
||||
strokeWidth={strokeWidth} // 실제 선
|
||||
/>
|
||||
```
|
||||
|
||||
**장점:**
|
||||
- 구현 비교적 쉬움
|
||||
- 교차점이 깔끔하게 분리되어 보임
|
||||
- 핸들 위치/경로 변경 없음
|
||||
|
||||
**단점:**
|
||||
- 선이 약간 두꺼워 보일 수 있음
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -388,4 +388,18 @@ select {
|
||||
border-spacing: 0 !important;
|
||||
}
|
||||
|
||||
/* ===== 저장 테이블 막대기 애니메이션 ===== */
|
||||
@keyframes saveBarDrop {
|
||||
0% {
|
||||
transform: scaleY(0);
|
||||
transform-origin: top;
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: scaleY(1);
|
||||
transform-origin: top;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== End of Global Styles ===== */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import React, { useMemo, useState, useEffect } from "react";
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import {
|
||||
Monitor,
|
||||
@@ -80,6 +80,13 @@ export interface TableNodeData {
|
||||
fieldMappings?: FieldMappingDisplay[]; // 서브 테이블일 때 조인 관계 표시
|
||||
// 참조 관계 정보 (다른 테이블에서 이 테이블을 참조하는 경우)
|
||||
referencedBy?: ReferenceInfo[]; // 이 테이블을 참조하는 관계들
|
||||
// 저장 관계 정보
|
||||
saveInfos?: Array<{
|
||||
saveType: string; // 'save' | 'edit' | 'delete' | 'transferData'
|
||||
componentType: string; // 버튼 컴포넌트 타입
|
||||
isMainTable: boolean; // 메인 테이블 저장인지
|
||||
sourceScreenId?: number; // 어떤 화면에서 저장하는지
|
||||
}>;
|
||||
}
|
||||
|
||||
// ========== 유틸리티 함수 ==========
|
||||
@@ -440,7 +447,7 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType:
|
||||
|
||||
// ========== 테이블 노드 (하단) - 컬럼 목록 표시 (컴팩트) ==========
|
||||
export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
||||
const { label, subLabel, isMain, isFocused, isFaded, columns, highlightedColumns, joinColumns, joinColumnRefs, filterColumns, fieldMappings, referencedBy } = data;
|
||||
const { label, subLabel, isMain, isFocused, isFaded, columns, highlightedColumns, joinColumns, joinColumnRefs, filterColumns, fieldMappings, referencedBy, saveInfos } = data;
|
||||
|
||||
// 강조할 컬럼 세트 (영문 컬럼명 기준)
|
||||
const highlightSet = new Set(highlightedColumns || []);
|
||||
@@ -531,18 +538,41 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
||||
// 컬럼 수 기반 높이 계산 (DOM 측정 없이)
|
||||
// - 각 컬럼 행 높이: 약 22px (py-0.5 + text + gap-px)
|
||||
// - 컨테이너 패딩: p-1.5 = 12px (상하 합계)
|
||||
// - 뱃지 높이: 약 26px (py-1 + text + gap)
|
||||
const COLUMN_ROW_HEIGHT = 22;
|
||||
const CONTAINER_PADDING = 12;
|
||||
const MAX_HEIGHT = 180;
|
||||
const BADGE_HEIGHT = 26;
|
||||
const MAX_HEIGHT = 200; // 뱃지 포함 가능하도록 증가
|
||||
|
||||
// 뱃지가 표시될지 미리 계산 (필터/참조만, 저장은 헤더에 표시)
|
||||
const hasFilterOrLookupBadge = referencedBy && referencedBy.some(r => r.relationType === 'filter' || r.relationType === 'lookup');
|
||||
const hasBadge = hasFilterOrLookupBadge;
|
||||
|
||||
const calculatedHeight = useMemo(() => {
|
||||
const rawHeight = CONTAINER_PADDING + (displayColumns.length * COLUMN_ROW_HEIGHT);
|
||||
const badgeHeight = hasBadge ? BADGE_HEIGHT : 0;
|
||||
const rawHeight = CONTAINER_PADDING + badgeHeight + (displayColumns.length * COLUMN_ROW_HEIGHT);
|
||||
return Math.min(rawHeight, MAX_HEIGHT);
|
||||
}, [displayColumns.length]);
|
||||
}, [displayColumns.length, hasBadge]);
|
||||
|
||||
// Debounce된 높이: 중간 값(늘어났다가 줄어드는 현상)을 무시하고 최종 값만 사용
|
||||
// 듀얼 그리드에서 filterColumns와 joinColumns가 2단계로 업데이트되는 문제 해결
|
||||
const [debouncedHeight, setDebouncedHeight] = useState(calculatedHeight);
|
||||
|
||||
useEffect(() => {
|
||||
// 50ms 내에 다시 변경되면 이전 값 무시
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedHeight(calculatedHeight);
|
||||
}, 50);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [calculatedHeight]);
|
||||
|
||||
// 저장 대상 여부
|
||||
const hasSaveTarget = saveInfos && saveInfos.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative flex w-[260px] flex-col overflow-hidden rounded-lg border shadow-md ${
|
||||
className={`group relative flex w-[260px] flex-col overflow-visible rounded-xl border shadow-md ${
|
||||
// 필터 관련 테이블 (마스터 또는 디테일): 보라색
|
||||
(hasFilterRelation || isFilterSource)
|
||||
? "border-2 border-violet-500 ring-4 ring-violet-500/30 shadow-xl bg-violet-50"
|
||||
@@ -560,7 +590,21 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
||||
// 색상/테두리/그림자만 transition (높이 제외)
|
||||
transition: "background-color 0.7s ease, border-color 0.7s ease, box-shadow 0.7s ease, filter 0.3s ease, opacity 0.3s ease",
|
||||
}}
|
||||
title={hasSaveTarget ? "저장 대상 테이블" : undefined}
|
||||
>
|
||||
{/* 저장 대상: 테이블 바깥 왼쪽에 띄워진 막대기 (나타나기/사라지기 애니메이션) */}
|
||||
<div
|
||||
className="absolute -left-1.5 top-1 bottom-1 w-0.5 z-20 rounded-full transition-all duration-500 ease-out"
|
||||
title={hasSaveTarget ? "저장 대상 테이블" : undefined}
|
||||
style={{
|
||||
background: 'linear-gradient(to bottom, transparent 0%, #f472b6 15%, #f472b6 85%, transparent 100%)',
|
||||
opacity: hasSaveTarget ? 1 : 0,
|
||||
transform: hasSaveTarget ? 'scaleY(1)' : 'scaleY(0)',
|
||||
transformOrigin: 'top',
|
||||
pointerEvents: hasSaveTarget ? 'auto' : 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Handles */}
|
||||
{/* top target: 화면 → 메인테이블 연결용 */}
|
||||
<Handle
|
||||
@@ -605,7 +649,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
||||
/>
|
||||
|
||||
{/* 헤더 (필터 관계: 보라색, 필터 소스: 보라색, 메인: 초록색, 기본: 슬레이트) */}
|
||||
<div className={`flex items-center gap-2 px-3 py-1.5 text-white transition-colors duration-700 ease-in-out ${
|
||||
<div className={`flex items-center gap-2 px-3 py-1.5 text-white rounded-t-xl transition-colors duration-700 ease-in-out ${
|
||||
isFaded ? "bg-gray-400" : (hasFilterRelation || isFilterSource) ? "bg-violet-600" : isMain ? "bg-emerald-600" : "bg-slate-500"
|
||||
}`}>
|
||||
<Database className="h-3.5 w-3.5 shrink-0" />
|
||||
@@ -627,49 +671,54 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 필터링/참조 관계 표시 (포커스 시에만 표시, 헤더 아래 별도 영역) */}
|
||||
{referencedBy && referencedBy.length > 0 && (() => {
|
||||
const filterRefs = referencedBy.filter(r => r.relationType === 'filter');
|
||||
const lookupRefs = referencedBy.filter(r => r.relationType === 'lookup');
|
||||
|
||||
if (filterRefs.length === 0 && lookupRefs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-violet-50 border-b border-violet-100 text-[9px]">
|
||||
{filterRefs.length > 0 && (
|
||||
<span
|
||||
className="flex items-center gap-0.5 rounded bg-violet-500 px-1.5 py-0.5 text-white font-medium"
|
||||
title={`마스터-디테일 필터링\n${filterRefs.map(r => `${r.fromTable}.${r.fromColumn || 'id'} → ${r.toColumn}`).join('\n')}`}
|
||||
>
|
||||
<Link2 className="h-2.5 w-2.5" />
|
||||
<span>필터</span>
|
||||
</span>
|
||||
)}
|
||||
{filterRefs.length > 0 && (
|
||||
<span className="text-violet-600 truncate">
|
||||
{filterRefs.map(r => `${r.fromTableLabel || r.fromTable}.${r.fromColumnLabel || r.fromColumn || 'id'}`).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
{lookupRefs.length > 0 && (
|
||||
<span
|
||||
className="flex items-center gap-0.5 rounded bg-amber-500 px-1.5 py-0.5 text-white font-medium"
|
||||
title={`코드 참조 (lookup)\n${lookupRefs.map(r => `${r.fromTable} → ${r.toColumn}`).join('\n')}`}
|
||||
>
|
||||
{lookupRefs.length}곳 참조
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 컬럼 목록 - 컴팩트하게 (부드러운 높이 전환) */}
|
||||
{/* 컬럼 목록 - 컴팩트하게 (부드러운 높이 전환 + 스크롤) */}
|
||||
{/* 뱃지도 이 영역 안에 포함되어 높이 계산에 반영됨 */}
|
||||
<div
|
||||
className="p-1.5 overflow-hidden"
|
||||
className="p-1.5 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-transparent"
|
||||
style={{
|
||||
height: `${calculatedHeight}px`,
|
||||
transition: 'height 0.7s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
height: `${debouncedHeight}px`,
|
||||
maxHeight: `${MAX_HEIGHT}px`,
|
||||
// Debounce로 중간 값이 무시되므로 항상 부드러운 transition 적용 가능
|
||||
transition: 'height 0.5s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
}}
|
||||
>
|
||||
{/* 필터링/참조 관계 뱃지 (컬럼 목록 영역 안에 포함, 저장은 헤더에 표시) */}
|
||||
{hasBadge && (() => {
|
||||
const filterRefs = referencedBy?.filter(r => r.relationType === 'filter') || [];
|
||||
const lookupRefs = referencedBy?.filter(r => r.relationType === 'lookup') || [];
|
||||
|
||||
if (filterRefs.length === 0 && lookupRefs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 mb-1.5 rounded border border-slate-300 bg-slate-50 text-[9px]">
|
||||
{/* 필터 뱃지 */}
|
||||
{filterRefs.length > 0 && (
|
||||
<span
|
||||
className="flex items-center gap-1 rounded-full bg-violet-600 px-2 py-px text-white font-semibold shadow-sm"
|
||||
title={`마스터-디테일 필터링\n${filterRefs.map(r => `${r.fromTable}.${r.fromColumn || 'id'} → ${r.toColumn}`).join('\n')}`}
|
||||
>
|
||||
<Link2 className="h-3 w-3" />
|
||||
<span>필터</span>
|
||||
</span>
|
||||
)}
|
||||
{filterRefs.length > 0 && (
|
||||
<span className="text-violet-700 font-medium truncate">
|
||||
{filterRefs.map(r => `${r.fromTableLabel || r.fromTable}.${r.fromColumnLabel || r.fromColumn || 'id'}`).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
{/* 참조 뱃지 */}
|
||||
{lookupRefs.length > 0 && (
|
||||
<span
|
||||
className="flex items-center gap-1 rounded-full bg-amber-500 px-2 py-px text-white font-semibold shadow-sm"
|
||||
title={`코드 참조 (lookup)\n${lookupRefs.map(r => `${r.fromTable} → ${r.toColumn}`).join('\n')}`}
|
||||
>
|
||||
{lookupRefs.length}곳 참조
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{displayColumns.length > 0 ? (
|
||||
<div className="flex flex-col gap-px transition-all duration-700 ease-in-out">
|
||||
{displayColumns.map((col, idx) => {
|
||||
|
||||
@@ -39,7 +39,7 @@ const RELATION_COLORS: Record<VisualRelationType, { stroke: string; strokeLight:
|
||||
hierarchy: { stroke: '#06b6d4', strokeLight: '#a5f3fc', label: '계층 구조' }, // 시안색
|
||||
lookup: { stroke: '#f59e0b', strokeLight: '#fcd34d', label: '코드 참조' }, // 주황색 (기존)
|
||||
mapping: { stroke: '#10b981', strokeLight: '#6ee7b7', label: '데이터 매핑' }, // 녹색
|
||||
join: { stroke: '#ea580c', strokeLight: '#fdba74', label: '엔티티 조인' }, // 주황색 (진한)
|
||||
join: { stroke: '#f97316', strokeLight: '#fdba74', label: '엔티티 조인' }, // orange-500 (기존 주황색)
|
||||
};
|
||||
|
||||
// 노드 타입 등록
|
||||
@@ -51,7 +51,7 @@ const nodeTypes = {
|
||||
// 레이아웃 상수
|
||||
const SCREEN_Y = 50; // 화면 노드 Y 위치 (상단)
|
||||
const TABLE_Y = 420; // 메인 테이블 노드 Y 위치 (중단)
|
||||
const SUB_TABLE_Y = 690; // 서브 테이블 노드 Y 위치 (하단) - 메인과 270px 간격
|
||||
const SUB_TABLE_Y = 740; // 서브 테이블 노드 Y 위치 (하단) - 메인과 320px 간격
|
||||
const NODE_WIDTH = 260; // 노드 너비
|
||||
const NODE_GAP = 40; // 노드 간격
|
||||
|
||||
@@ -493,7 +493,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||
subLabel: subLabel,
|
||||
isMain: true, // mainTableSet의 모든 테이블은 메인
|
||||
columns: formattedColumns,
|
||||
// referencedBy, filterColumns는 styledNodes에서 포커스 상태에 따라 동적으로 설정
|
||||
// referencedBy, filterColumns, saveInfos는 styledNodes에서 포커스 상태에 따라 동적으로 설정
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -547,7 +547,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||
isMain: false,
|
||||
columns: formattedColumns,
|
||||
isFaded: true, // 기본적으로 흐리게 표시 (포커스 시에만 활성화)
|
||||
// referencedBy, filterColumns는 styledNodes에서 포커스 상태에 따라 동적으로 설정
|
||||
// referencedBy, filterColumns, saveInfos는 styledNodes에서 포커스 상태에 따라 동적으로 설정
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -599,6 +599,104 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 필터링 관계일 때 화면 → 필터 대상 테이블 연결선 추가 (점선)
|
||||
// rightPanelRelation (split-panel-layout의 마스터-디테일) 관계일 때
|
||||
// + 필터 대상 테이블의 조인 관계도 함께 표시
|
||||
const filterJoinEdgeSet = new Set<string>(); // 필터 테이블의 조인선 중복 방지
|
||||
|
||||
Object.entries(subTablesData).forEach(([screenIdStr, screenSubData]) => {
|
||||
const sourceScreenId = parseInt(screenIdStr);
|
||||
|
||||
screenSubData.subTables.forEach((subTable) => {
|
||||
// rightPanelRelation (필터 관계)이고, 해당 테이블이 존재하는 경우
|
||||
// 메인 테이블이든 서브 테이블이든 상관없이 연결선 추가
|
||||
if (subTable.relationType === 'rightPanelRelation') {
|
||||
// 테이블 노드 ID 결정: 메인 테이블 영역 또는 서브 테이블 영역
|
||||
const isFilterTargetMainTable = mainTableSet.has(subTable.tableName);
|
||||
const isFilterTargetSubTable = subTableSet.has(subTable.tableName);
|
||||
|
||||
if (!isFilterTargetMainTable && !isFilterTargetSubTable) return; // 노드가 없으면 스킵
|
||||
|
||||
const targetNodeId = isFilterTargetMainTable
|
||||
? `table-${subTable.tableName}`
|
||||
: `subtable-${subTable.tableName}`;
|
||||
|
||||
// 화면 → 필터 대상 테이블 연결선
|
||||
newEdges.push({
|
||||
id: `edge-screen-filter-${sourceScreenId}-${subTable.tableName}`,
|
||||
source: `screen-${sourceScreenId}`,
|
||||
target: targetNodeId,
|
||||
sourceHandle: "bottom",
|
||||
targetHandle: "top",
|
||||
type: "smoothstep",
|
||||
animated: true,
|
||||
style: {
|
||||
stroke: "#3b82f6",
|
||||
strokeWidth: 2,
|
||||
strokeDasharray: "5,5", // 점선으로 필터 관계 표시
|
||||
},
|
||||
data: {
|
||||
sourceScreenId,
|
||||
},
|
||||
});
|
||||
|
||||
// 필터 대상 테이블의 조인 관계 (joinColumnRefs)도 조인선으로 표시
|
||||
// 예: customer_item_mapping → item_info (품목 ID가 item_info.item_number 참조)
|
||||
if (subTable.joinColumnRefs && subTable.joinColumnRefs.length > 0) {
|
||||
subTable.joinColumnRefs.forEach((joinRef) => {
|
||||
const refTable = joinRef.refTable;
|
||||
if (!refTable) return;
|
||||
|
||||
// 참조 테이블이 메인 테이블 또는 서브 테이블에 있는지 확인
|
||||
const isRefMainTable = mainTableSet.has(refTable);
|
||||
const isRefSubTable = subTableSet.has(refTable);
|
||||
|
||||
if (!isRefMainTable && !isRefSubTable) return;
|
||||
|
||||
// 중복 체크 (같은 화면에서 같은 조인 관계 중복 방지)
|
||||
const joinKey = `${sourceScreenId}-${subTable.tableName}-${refTable}`;
|
||||
if (filterJoinEdgeSet.has(joinKey)) return;
|
||||
filterJoinEdgeSet.add(joinKey);
|
||||
|
||||
// 소스/타겟 노드 ID 결정
|
||||
const sourceNodeId = isFilterTargetMainTable
|
||||
? `table-${subTable.tableName}`
|
||||
: `subtable-${subTable.tableName}`;
|
||||
const refTargetNodeId = isRefMainTable
|
||||
? `table-${refTable}`
|
||||
: `subtable-${refTable}`;
|
||||
|
||||
// 조인선 추가 (초기 스타일 - styledEdges에서 포커싱에 따라 스타일 결정)
|
||||
newEdges.push({
|
||||
id: `edge-filter-join-${sourceScreenId}-${subTable.tableName}-${refTable}`,
|
||||
source: sourceNodeId,
|
||||
target: refTargetNodeId,
|
||||
sourceHandle: "bottom",
|
||||
targetHandle: "bottom_target",
|
||||
type: "smoothstep",
|
||||
animated: false,
|
||||
style: {
|
||||
stroke: RELATION_COLORS.join.strokeLight, // 초기값 (연한색)
|
||||
strokeWidth: 1.5,
|
||||
strokeDasharray: "6,4",
|
||||
opacity: 0.3,
|
||||
},
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: RELATION_COLORS.join.strokeLight
|
||||
},
|
||||
data: {
|
||||
sourceScreenId,
|
||||
isFilterJoin: true,
|
||||
visualRelationType: 'join',
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 메인 테이블 → 서브 테이블 연결선 생성 (점선)
|
||||
// 메인 테이블 → 메인 테이블 연결선도 생성 (점선, 연한 주황색)
|
||||
@@ -978,6 +1076,24 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 필터 대상 테이블의 joinColumnRefs가 있으면 해당 참조 테이블도 활성화
|
||||
// 예: customer_item_mapping → item_info (품목 ID → item_info.item_number)
|
||||
if (subTable.relationType === 'rightPanelRelation' && subTable.joinColumnRefs) {
|
||||
subTable.joinColumnRefs.forEach((joinRef) => {
|
||||
const refTable = joinRef.refTable;
|
||||
if (refTable && allMainTableSet.has(refTable) && refTable !== focusedSubTablesData.mainTable) {
|
||||
if (!relatedMainTables[refTable]) {
|
||||
relatedMainTables[refTable] = { columns: [], displayNames: [] };
|
||||
}
|
||||
// 참조 테이블의 컬럼도 추가 (조인 관계 표시용)
|
||||
if (joinRef.refColumn && !relatedMainTables[refTable].columns.includes(joinRef.refColumn)) {
|
||||
relatedMainTables[refTable].columns.push(joinRef.refColumn);
|
||||
relatedMainTables[refTable].displayNames.push(joinRef.columnLabel || joinRef.refColumn);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1181,6 +1297,9 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||
// 조인 컬럼 참조 정보 수집
|
||||
let focusedJoinColumnRefs: Array<{ column: string; refTable: string; refColumn: string }> = [];
|
||||
|
||||
// 포커싱된 화면 기준 저장 정보
|
||||
let focusedSaveInfos: Array<{ saveType: string; componentType: string; isMainTable: boolean; sourceScreenId?: number }> = [];
|
||||
|
||||
if (focusedScreenId !== null && focusedSubTablesData) {
|
||||
// 포커스된 화면에서 이 테이블이 rightPanelRelation의 서브테이블인 경우
|
||||
focusedSubTablesData.subTables.forEach((subTable) => {
|
||||
@@ -1251,6 +1370,20 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 포커싱된 화면 기준 저장 정보 추출
|
||||
if (focusedSubTablesData.saveTables) {
|
||||
focusedSubTablesData.saveTables.forEach((st) => {
|
||||
if (st.tableName === tableName) {
|
||||
focusedSaveInfos.push({
|
||||
saveType: st.saveType,
|
||||
componentType: st.componentType,
|
||||
isMainTable: st.isMainTable,
|
||||
sourceScreenId: focusedSubTablesData.screenId,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1265,6 +1398,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||
joinColumnRefs: focusedJoinColumnRefs.length > 0 ? focusedJoinColumnRefs : undefined, // 조인 컬럼 참조 정보
|
||||
filterColumns: focusedFilterColumns, // 포커스 상태에서만 표시
|
||||
referencedBy: focusedReferencedBy.length > 0 ? focusedReferencedBy : undefined, // 포커스 상태에서만 표시
|
||||
saveInfos: focusedSaveInfos.length > 0 ? focusedSaveInfos : undefined, // 포커스 상태에서만 표시
|
||||
fieldMappings: isFocusedTable ? mainTableFieldMappings : (isRelatedTable ? relatedTableFieldMappings : []),
|
||||
},
|
||||
};
|
||||
@@ -1317,6 +1451,10 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||
// reference, source, parentMapping, rightPanelRelation 타입: sourceField = 메인테이블 컬럼, targetField = 서브테이블 컬럼
|
||||
// lookup 타입: sourceField = 서브테이블 컬럼, targetField = 메인테이블 컬럼 (swap 필요)
|
||||
let displayFieldMappings: Array<{ sourceField: string; targetField: string; sourceDisplayName?: string; targetDisplayName?: string }> = [];
|
||||
|
||||
// 포커싱된 화면 기준 저장 정보 (서브 테이블)
|
||||
let subTableSaveInfos: Array<{ saveType: string; componentType: string; isMainTable: boolean; sourceScreenId?: number }> = [];
|
||||
|
||||
if (isActiveSubTable && focusedSubTablesData) {
|
||||
const subTableInfo = focusedSubTablesData.subTables.find(st => st.tableName === subTableName);
|
||||
if (subTableInfo?.fieldMappings) {
|
||||
@@ -1345,6 +1483,20 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 서브 테이블에 대한 저장 정보 추출
|
||||
if (focusedSubTablesData.saveTables) {
|
||||
focusedSubTablesData.saveTables.forEach((st) => {
|
||||
if (st.tableName === subTableName) {
|
||||
subTableSaveInfos.push({
|
||||
saveType: st.saveType,
|
||||
componentType: st.componentType,
|
||||
isMainTable: st.isMainTable,
|
||||
sourceScreenId: focusedSubTablesData.screenId,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1361,6 +1513,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||
highlightedColumns: isActiveSubTable ? subTableHighlightedColumns : [],
|
||||
joinColumns: isActiveSubTable ? subTableJoinColumns : [],
|
||||
fieldMappings: isActiveSubTable ? displayFieldMappings : [],
|
||||
saveInfos: subTableSaveInfos.length > 0 ? subTableSaveInfos : undefined, // 포커스 상태에서만 표시
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1600,8 +1753,8 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||
animated: isActive, // 활성화된 것만 애니메이션
|
||||
style: {
|
||||
...edge.style,
|
||||
stroke: isActive ? "#f97316" : "#d1d5db",
|
||||
strokeWidth: isActive ? 2 : 1,
|
||||
stroke: isActive ? RELATION_COLORS.join.stroke : "#d1d5db", // 상수 사용
|
||||
strokeWidth: isActive ? 2.5 : 1,
|
||||
strokeDasharray: "6,4", // 항상 점선
|
||||
opacity: isActive ? 1 : 0.2,
|
||||
},
|
||||
@@ -1612,6 +1765,59 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||
};
|
||||
}
|
||||
|
||||
// 필터 조인 엣지 (필터 대상 테이블 → 조인 참조 테이블)
|
||||
// 규격: 해당 화면이 포커싱됐을 때만 활성화
|
||||
if (edge.id.startsWith("edge-filter-join-")) {
|
||||
const edgeSourceScreenId = (edge.data as any)?.sourceScreenId;
|
||||
|
||||
// 포커스가 없으면 흐리게 표시
|
||||
if (focusedScreenId === null) {
|
||||
return {
|
||||
...edge,
|
||||
animated: false,
|
||||
style: {
|
||||
...edge.style,
|
||||
stroke: RELATION_COLORS.join.strokeLight,
|
||||
strokeWidth: 1.5,
|
||||
strokeDasharray: "6,4",
|
||||
opacity: 0.3,
|
||||
},
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: RELATION_COLORS.join.strokeLight,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 포커스된 화면과 일치하는지 확인
|
||||
const isMyConnection = edgeSourceScreenId === focusedScreenId;
|
||||
|
||||
if (!isMyConnection) {
|
||||
// 다른 화면의 필터 조인 엣지는 숨김
|
||||
return {
|
||||
...edge,
|
||||
hidden: true,
|
||||
};
|
||||
}
|
||||
|
||||
// 내 화면의 필터 조인 엣지는 활성화
|
||||
return {
|
||||
...edge,
|
||||
animated: true,
|
||||
style: {
|
||||
...edge.style,
|
||||
stroke: RELATION_COLORS.join.stroke,
|
||||
strokeWidth: 2,
|
||||
strokeDasharray: "6,4",
|
||||
opacity: 1,
|
||||
},
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: RELATION_COLORS.join.stroke,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 메인 테이블 → 메인 테이블 연결선 (서브테이블 구간 통과)
|
||||
// 규격: bottom → bottom_target 고정 (아래쪽 서브테이블 선 구간을 통해 연결)
|
||||
if (edge.source.startsWith("table-") && edge.target.startsWith("table-") && edge.id.startsWith("edge-main-main-")) {
|
||||
|
||||
@@ -456,11 +456,25 @@ export function inferVisualRelationType(subTable: SubTableInfo): VisualRelationT
|
||||
return 'join';
|
||||
}
|
||||
|
||||
// 저장 테이블 정보 타입
|
||||
export interface SaveTableInfo {
|
||||
tableName: string;
|
||||
saveType: 'save' | 'edit' | 'delete' | 'transferData';
|
||||
componentType: string;
|
||||
isMainTable: boolean;
|
||||
mappingRules?: Array<{
|
||||
sourceField: string;
|
||||
targetField: string;
|
||||
transform?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ScreenSubTablesData {
|
||||
screenId: number;
|
||||
screenName: string;
|
||||
mainTable: string;
|
||||
subTables: SubTableInfo[];
|
||||
saveTables?: SaveTableInfo[]; // 저장 대상 테이블 목록
|
||||
}
|
||||
|
||||
// 여러 화면의 서브 테이블 정보 조회 (메인 테이블 → 서브 테이블 관계)
|
||||
|
||||
Reference in New Issue
Block a user