feat: 저장 테이블 정보 및 애니메이션 기능 추가

- 화면 서브 테이블에서 저장 테이블 정보를 추출하는 쿼리 추가
- 저장 테이블 정보 구조를 TableNodeData 인터페이스에 통합
- 저장 테이블의 시각적 표현을 위한 애니메이션 효과 추가
- 필터링 및 참조 관계 뱃지 레이아웃 개선
- 테이블 높이 부드러운 애니메이션 및 스크롤 기능 구현
This commit is contained in:
DDD1542
2026-01-09 11:19:30 +09:00
parent b8c8b31033
commit af4072cef1
6 changed files with 602 additions and 59 deletions

View File

@@ -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) || []
}))
});

View File

@@ -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} // 실제 선
/>
```
**장점:**
- 구현 비교적 쉬움
- 교차점이 깔끔하게 분리되어 보임
- 핸들 위치/경로 변경 없음
**단점:**
- 선이 약간 두꺼워 보일 수 있음
---

View File

@@ -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 ===== */

View File

@@ -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) => {

View File

@@ -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-")) {

View File

@@ -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[]; // 저장 대상 테이블 목록
}
// 여러 화면의 서브 테이블 정보 조회 (메인 테이블 → 서브 테이블 관계)