diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts index a942861c..c5e15263 100644 --- a/backend-node/src/controllers/screenGroupController.ts +++ b/backend-node/src/controllers/screenGroupController.ts @@ -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) || [] })) }); diff --git a/docs/화면관계_시각화_개선_보고서.md b/docs/화면관계_시각화_개선_보고서.md index 20edf254..fcd08fae 100644 --- a/docs/화면관계_시각화_개선_보고서.md +++ b/docs/화면관계_시각화_개선_보고서.md @@ -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 +// 커스텀 엣지 컴포넌트에서 + + +``` + +**장점:** +- 구현 비교적 쉬움 +- 교차점이 깔끔하게 분리되어 보임 +- 핸들 위치/경로 변경 없음 + +**단점:** +- 선이 약간 두꺼워 보일 수 있음 --- diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 06b7bd27..a252eaff 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -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 ===== */ diff --git a/frontend/components/screen/ScreenNode.tsx b/frontend/components/screen/ScreenNode.tsx index 7c934b3e..e05789f5 100644 --- a/frontend/components/screen/ScreenNode.tsx +++ b/frontend/components/screen/ScreenNode.tsx @@ -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 (
= ({ 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} > + {/* 저장 대상: 테이블 바깥 왼쪽에 띄워진 막대기 (나타나기/사라지기 애니메이션) */} +
+ {/* Handles */} {/* top target: 화면 → 메인테이블 연결용 */} = ({ data }) => { /> {/* 헤더 (필터 관계: 보라색, 필터 소스: 보라색, 메인: 초록색, 기본: 슬레이트) */} -
@@ -627,49 +671,54 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { )}
- {/* 필터링/참조 관계 표시 (포커스 시에만 표시, 헤더 아래 별도 영역) */} - {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 ( -
- {filterRefs.length > 0 && ( - `${r.fromTable}.${r.fromColumn || 'id'} → ${r.toColumn}`).join('\n')}`} - > - - 필터 - - )} - {filterRefs.length > 0 && ( - - {filterRefs.map(r => `${r.fromTableLabel || r.fromTable}.${r.fromColumnLabel || r.fromColumn || 'id'}`).join(', ')} - - )} - {lookupRefs.length > 0 && ( - `${r.fromTable} → ${r.toColumn}`).join('\n')}`} - > - {lookupRefs.length}곳 참조 - - )} -
- ); - })()} - - {/* 컬럼 목록 - 컴팩트하게 (부드러운 높이 전환) */} + {/* 컬럼 목록 - 컴팩트하게 (부드러운 높이 전환 + 스크롤) */} + {/* 뱃지도 이 영역 안에 포함되어 높이 계산에 반영됨 */}
+ {/* 필터링/참조 관계 뱃지 (컬럼 목록 영역 안에 포함, 저장은 헤더에 표시) */} + {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 ( +
+ {/* 필터 뱃지 */} + {filterRefs.length > 0 && ( + `${r.fromTable}.${r.fromColumn || 'id'} → ${r.toColumn}`).join('\n')}`} + > + + 필터 + + )} + {filterRefs.length > 0 && ( + + {filterRefs.map(r => `${r.fromTableLabel || r.fromTable}.${r.fromColumnLabel || r.fromColumn || 'id'}`).join(', ')} + + )} + {/* 참조 뱃지 */} + {lookupRefs.length > 0 && ( + `${r.fromTable} → ${r.toColumn}`).join('\n')}`} + > + {lookupRefs.length}곳 참조 + + )} +
+ ); + })()} + {displayColumns.length > 0 ? (
{displayColumns.map((col, idx) => { diff --git a/frontend/components/screen/ScreenRelationFlow.tsx b/frontend/components/screen/ScreenRelationFlow.tsx index 96b68753..b84fef21 100644 --- a/frontend/components/screen/ScreenRelationFlow.tsx +++ b/frontend/components/screen/ScreenRelationFlow.tsx @@ -39,7 +39,7 @@ const RELATION_COLORS: Record(); // 필터 테이블의 조인선 중복 방지 + + 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-")) { diff --git a/frontend/lib/api/screenGroup.ts b/frontend/lib/api/screenGroup.ts index b5003b83..3c89d01c 100644 --- a/frontend/lib/api/screenGroup.ts +++ b/frontend/lib/api/screenGroup.ts @@ -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[]; // 저장 대상 테이블 목록 } // 여러 화면의 서브 테이블 정보 조회 (메인 테이블 → 서브 테이블 관계)