From a6569909a28c717b11ad72ae32979a64ceb0b6fd Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Fri, 9 Jan 2026 17:03:00 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A0=80=EC=9E=A5=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EC=A0=9C=EC=99=B8=20=EC=A1=B0=EA=B1=B4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=ED=8F=AC=EC=BB=A4=EC=8B=B1=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 저장 테이블 쿼리에 table-list와 체크박스가 활성화된 화면, openModalWithData 버튼이 있는 화면을 제외하는 조건 추가 - 화면 그룹 클릭 시 새 그룹 진입 시 포커싱 없이 시작하도록 로직 개선 - 관련 문서에 제외 조건 및 SQL 예시 추가 --- .../src/controllers/screenGroupController.ts | 16 + docs/화면관계_시각화_개선_보고서.md | 404 +++- .../admin/screenMng/screenMngList/page.tsx | 13 +- .../components/screen/NodeSettingModal.tsx | 1889 +++++++++++++++++ frontend/components/screen/ScreenNode.tsx | 4 +- .../components/screen/ScreenRelationFlow.tsx | 455 +++- .../components/screen/ScreenSettingModal.tsx | 1073 ++++++++++ .../components/screen/TableSettingModal.tsx | 1094 ++++++++++ .../screen/panels/DataFlowPanel.tsx | 1 + .../screen/panels/FieldJoinPanel.tsx | 1 + 10 files changed, 4880 insertions(+), 70 deletions(-) create mode 100644 frontend/components/screen/NodeSettingModal.tsx create mode 100644 frontend/components/screen/ScreenSettingModal.tsx create mode 100644 frontend/components/screen/TableSettingModal.tsx diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts index c5e15263..6d3564a6 100644 --- a/backend-node/src/controllers/screenGroupController.ts +++ b/backend-node/src/controllers/screenGroupController.ts @@ -1901,6 +1901,9 @@ export const getScreenSubTables = async (req: Request, res: Response) => { // ============================================================ // 저장 테이블 정보 추출 // ============================================================ + // 제외 조건: + // 1. table-list + 체크박스 활성화 + openModalWithData 버튼이 있는 화면 + // → 선택 후 다음 화면으로 넘기는 패턴 (실제 DB 저장 아님) const saveTableQuery = ` SELECT DISTINCT sd.screen_id, @@ -1915,6 +1918,19 @@ export const getScreenSubTables = async (req: Request, res: Response) => { WHERE sd.screen_id = ANY($1) AND sl.properties->'componentConfig'->'action'->>'type' = 'save' AND sl.properties->'componentConfig'->'action'->>'targetScreenId' IS NULL + -- 제외: table-list + 체크박스가 있는 화면 + AND NOT EXISTS ( + SELECT 1 FROM screen_layouts sl_list + WHERE sl_list.screen_id = sd.screen_id + AND sl_list.properties->>'componentType' = 'table-list' + AND (sl_list.properties->'componentConfig'->'checkbox'->>'enabled')::boolean = true + ) + -- 제외: openModalWithData 버튼이 있는 화면 (선택 → 다음 화면 패턴) + AND NOT EXISTS ( + SELECT 1 FROM screen_layouts sl_modal + WHERE sl_modal.screen_id = sd.screen_id + AND sl_modal.properties->'componentConfig'->'action'->>'type' = 'openModalWithData' + ) ORDER BY sd.screen_id `; diff --git a/docs/화면관계_시각화_개선_보고서.md b/docs/화면관계_시각화_개선_보고서.md index fcd08fae..83411d7f 100644 --- a/docs/화면관계_시각화_개선_보고서.md +++ b/docs/화면관계_시각화_개선_보고서.md @@ -1117,7 +1117,10 @@ screenSubTables[screenId].subTables.push({ 18. [x] 테이블/헤더 둥근 모서리 (rounded-xl, rounded-t-xl) 19. [x] 필터 테이블 조인선 + 참조 테이블 활성화 20. [x] 조인선 색상 상수 통일 (RELATION_COLORS.join.stroke) -21. [ ] **선 교차점 이질감 해결** (계획 중) +21. [x] 필터 연결선 포커싱 제어 (해당 화면 포커싱 시에만 표시) +22. [x] 저장 테이블 제외 조건 추가 (table-list + 체크박스 + openModalWithData) +23. [x] 첫 진입 시 포커싱 없이 시작 (트리에서 화면 클릭 시 그룹만 진입) +24. [ ] **선 교차점 이질감 해결** (계획 중) 22. [ ] 범례 UI 추가 (선택사항) 23. [ ] 엣지 라벨에 관계 유형 표시 (선택사항) @@ -1142,7 +1145,27 @@ screenSubTables[screenId].subTables.push({ 1. `componentConfig.action.type = 'save'` (edit, delete 제외) 2. `componentConfig.targetTable` (modal-repeater-table 등) 3. `action.dataTransfer.targetTable` (데이터 전송 대상) -4. **제외 조건**: `action.targetScreenId IS NOT NULL` (모달 열기 버튼) + +**제외 조건:** +1. `action.targetScreenId IS NOT NULL` (모달 열기 버튼) +2. `table-list` + 체크박스 활성화 + `openModalWithData` 버튼이 있는 화면 + - 예: "거래처별 품목 추가 모달" - 선택 후 다음 화면으로 넘기는 패턴 + - 이 경우 "저장" 버튼은 DB 저장이 아닌 **선택 확인 용도** + +```sql +-- 제외 조건 SQL +AND NOT EXISTS ( + SELECT 1 FROM screen_layouts sl_list + WHERE sl_list.screen_id = sd.screen_id + AND sl_list.properties->>'componentType' = 'table-list' + AND (sl_list.properties->'componentConfig'->'checkbox'->>'enabled')::boolean = true +) +AND NOT EXISTS ( + SELECT 1 FROM screen_layouts sl_modal + WHERE sl_modal.screen_id = sd.screen_id + AND sl_modal.properties->'componentConfig'->'action'->>'type' = 'openModalWithData' +) +``` ### 시각적 표현 (구현됨) @@ -1220,11 +1243,46 @@ transform-origin: top; - 조인 컬럼 주황색 강조 표시 ### 포커싱 제어 -- 해당 화면이 포커싱됐을 때만 조인선 활성화 + +**조인선 (주황색 점선)** +- 해당 화면이 포커싱됐을 때만 활성화 - 다른 화면 포커싱 시 흐리게 처리 (opacity: 0.3) +- 엣지 ID: `edge-filter-join-{screenId}-{sourceTable}-{targetTable}` + +**필터 연결선 (파란색 점선)** +- 화면 → 필터 대상 테이블 연결선 +- 해당 화면이 포커싱됐을 때만 표시 (opacity: 1) +- 포커스 해제 시 완전히 숨김 (opacity: 0) +- 엣지 ID: `edge-screen-filter-{screenId}-{tableName}` + +**styledEdges 처리:** +```typescript +// 필터 조인 엣지 (주황색) +if (edge.id.startsWith("edge-filter-join-")) { + const isActive = focusedScreenId === edgeSourceScreenId; + return { + ...edge, + style: { + stroke: isActive ? RELATION_COLORS.join.stroke : RELATION_COLORS.join.strokeLight, + opacity: isActive ? 1 : 0.3, + }, + }; +} + +// 화면 → 필터 대상 테이블 연결선 (파란색) +if (edge.id.startsWith("edge-screen-filter-")) { + const isActive = focusedScreenId === edgeSourceScreenId; + return { + ...edge, + style: { + opacity: isActive ? 1 : 0, // 포커스 해제 시 완전히 숨김 + }, + }; +} +``` ### 코드 위치 -- `ScreenRelationFlow.tsx`: 필터 조인 엣지 생성 로직 +- `ScreenRelationFlow.tsx`: 필터 조인 엣지 생성 + styledEdges 처리 - `styledNodes`: 필터 대상 테이블의 조인 참조 테이블 활성화 로직 --- @@ -1245,6 +1303,39 @@ transform-origin: top; - 기본 색상: `#f97316` (orange-500) - 강조 색상: `#ea580c` (orange-600) +### 첫 진입 시 포커싱 없이 시작 + +**문제:** +- 트리에서 화면을 클릭하면 해당 화면이 자동 포커싱됨 +- 첫 진입 시 노드 위치가 안정화되기 전에 필터선이 그려져 "망가진" 모습 + +**해결:** +- 트리에서 화면 클릭 시: 그룹만 진입, 포커싱 없음 +- ReactFlow 안에서 화면 클릭 시: 정상 포커싱 + +**코드 변경:** +```typescript +// page.tsx - onScreenSelectInGroup 콜백 +onScreenSelectInGroup={(group, screenId) => { + const isNewGroup = selectedGroup?.id !== group.id; + + if (isNewGroup) { + // 새 그룹 진입: 포커싱 없이 시작 + setSelectedGroup(group); + setFocusedScreenIdInGroup(null); + } else { + // 같은 그룹 내에서 다른 화면 클릭: 포커싱 유지 + setFocusedScreenIdInGroup(screenId); + } + setSelectedScreen(null); +}} +``` + +**사용자 경험:** +1. 트리에서 화면 클릭 (첫 진입) → 깔끔한 초기 상태 (모든 화면/테이블 동일 밝기) +2. 같은 그룹 내에서 다른 화면 클릭 → 포커싱 + 연결선 표시 +3. ReactFlow에서 화면 노드 클릭 → 포커싱 + 연결선 표시 + --- ## [계획] 선 교차점 이질감 해결 @@ -1286,6 +1377,311 @@ transform-origin: top; --- +## 화면 관리 시스템 업그레이드 현황 + +### 프로젝트 개요 + +화면 관리 시스템 업그레이드를 통해 다음 3가지 핵심 기능을 구현: + +| 기능 | 설명 | 상태 | +|------|------|------| +| **화면 그룹핑** | 관련 화면들을 그룹으로 묶어 관리 (트리 구조) | 기본 구현 완료 | +| **화면-테이블 관계 시각화** | React Flow를 사용한 노드 기반 시각화 | 기본 구현 완료 | +| **테이블 조인 설정** | 화면 내에서 테이블 간 조인 관계 직접 설정 | 미구현 | + +--- + +### 데이터베이스 테이블 (5개) + +| 테이블명 | 용도 | 상태 | +|----------|------|------| +| `screen_groups` | 화면 그룹 정보 | 생성됨 | +| `screen_group_screens` | 화면-그룹 연결 (N:M) | 생성됨 | +| `screen_field_joins` | 화면 필드 조인 설정 | 생성됨 | +| `screen_data_flows` | 화면 간 데이터 흐름 | 생성됨 | +| `screen_table_relations` | 화면-테이블 관계 | 생성됨 | + +--- + +### 백엔드 API 현황 + +| 파일 | 상태 | 엔드포인트 | +|------|------|-----------| +| `screenGroupController.ts` | 완성됨 | 그룹/화면/조인/흐름/관계 CRUD | +| `screenGroupRoutes.ts` | 완성됨 | `/api/screen-groups/*` | + +--- + +### 프론트엔드 컴포넌트 현황 + +| 컴포넌트 | 경로 | 상태 | +|----------|------|------| +| `ScreenGroupTreeView.tsx` | `components/screen/` | **완료** | +| `ScreenGroupModal.tsx` | `components/screen/` | **완료** (그룹 CRUD 모달) | +| `ScreenRelationFlow.tsx` | `components/screen/` | **완료** | +| `ScreenNode.tsx` | `components/screen/` | **완료** | +| `FieldJoinPanel.tsx` | `components/screen/panels/` | **완료** (조인 설정) | +| `DataFlowPanel.tsx` | `components/screen/panels/` | **완료** (데이터 흐름 설정) | +| API 클라이언트 | `lib/api/screenGroup.ts` | **완료** | + +--- + +### 구현 완료 목록 + +| # | 항목 | 완료일 | +|---|------|--------| +| 1 | DB 테이블 5개 생성 및 메타데이터 등록 | - | +| 2 | 백엔드 API 전체 구현 (CRUD) | - | +| 3 | 프론트엔드 API 클라이언트 구현 | - | +| 4 | 트리 뷰 기본 구현 (그룹/화면 표시) | - | +| 5 | React Flow 시각화 기본 구현 (노드 배치, 연결선) | - | +| 6 | 노드 디자인 1차 개선 (정사각형, 흰색 테마) | - | +| 7 | 화면 레이아웃 요약 API 추가 | 2026-01-01 | +| 8 | 화면 노드 미리보기 구현 (폼/그리드/대시보드) | 2026-01-01 | +| 9 | 테이블 노드 개선 (PK/FK 아이콘, 컬럼 목록) | 2026-01-01 | +| 10 | 연결선 스타일 개선 (CRUD 라벨 제거, 1:N 표시) | 2026-01-01 | + +--- + +### 추가 구현 완료 목록 + +| # | 항목 | 컴포넌트 | 상태 | +|---|------|----------|------| +| 11 | **그룹 관리 UI** | `ScreenGroupModal.tsx` | **완료** | +| 12 | **조인 설정 UI** | `FieldJoinPanel.tsx` (414줄) | **완료** | +| 13 | **데이터 흐름 설정 UI** | `DataFlowPanel.tsx` (462줄) | **완료** | + +--- + +### 미구현 작업 목록 (UI 선택사항) + +| # | 항목 | 설명 | 우선순위 | +|---|------|------|----------| +| 1 | **화면 미리보기 고도화** | 실제 컴포넌트 렌더링, 더 상세한 폼 필드 표시 | 낮음 | +| 2 | 범례(Legend) UI 추가 | 관계 유형별 색상 설명 | 낮음 | +| 3 | 뱃지 클릭 시 팝오버 상세정보 | 저장/필터/조인 뱃지 클릭 시 상세 정보 | 낮음 | +| 4 | 선 교차점 이질감 해결 | 배경색 테두리 방식 | 낮음 | + +--- + +## [다음 단계] 노드 플로워 기반 화면-테이블 설정 시스템 + +### 배경 및 목적 + +**문제**: 화면 디자이너에 너무 많은 기능이 집중되어 있음 +- 조인 설정, 필터 설정, 필드-컬럼 매칭, 저장 테이블 설정 등 + +**해결책**: 화면 관리 노드 플로워에서 이러한 설정을 **직접** 할 수 있게 함 +- 노드 플로워 = 화면-테이블 관계 설정의 **또 다른 UI** +- 시각적으로 설정하고, DB에 저장되면 화면 디자이너/실제 화면에 자동 반영 + +### 핵심 개념 + +``` +노드 플로워에서 화면/테이블 노드 클릭 (우클릭/더블클릭) + ↓ + 모달/팝업 열림 + ↓ + 설정 (조인, 필터, 필드-컬럼 매칭, 저장 테이블 등) + ↓ + DB 저장 (screen_layouts.properties, screen_field_joins 등) + ↓ + 시각화 자동 반영 (데이터 기반으로 그리니까) + 화면 디자이너 자동 반영 (같은 데이터 사용) + 실제 화면 자동 반영 (같은 데이터 사용) +``` + +### 구현 대상 기능 + +| 기능 | 설명 | +|------|------| +| **테이블 연결 설정** | 화면이 어떤 테이블과 연결되는지 | +| **테이블 조인 설정** | 테이블 간 조인 관계 (LEFT, INNER 등) | +| **필터링 설정** | 마스터-디테일 필터링 관계 | +| **필드-컬럼 매칭** | 화면 필드 ↔ 테이블 컬럼 매핑 | +| **저장 테이블 설정** | 어떤 테이블에 데이터가 저장되는지 | + +### 구현 방안 (초안, 미확정) + +#### 방안 A: 통합 설정 모달 + +노드 클릭 시 **하나의 모달**에서 탭으로 모든 설정 + +``` +[화면 노드] 더블클릭 + ↓ +┌─────────────────────────────────┐ +│ 수주관리 화면 설정 │ +│ │ +│ [탭1: 테이블 연결] │ +│ [탭2: 조인 설정] │ +│ [탭3: 필터 설정] │ +│ [탭4: 필드-컬럼 매칭] │ +│ [탭5: 저장 테이블] │ +│ │ +│ [저장] [취소] │ +└─────────────────────────────────┘ +``` + +#### 방안 B: 기능별 분리 모달 + +우클릭 컨텍스트 메뉴로 기능 선택 → 해당 기능 모달 열림 + +``` +[화면 노드] 우클릭 + ↓ +┌─────────────────┐ +│ 테이블 연결 설정 │ +│ 조인 설정 │ +│ 필터 설정 │ +│ 필드-컬럼 매칭 │ +│ 저장 테이블 설정 │ +└─────────────────┘ +``` + +#### 방안 C: 사이드 패널 + +노드 클릭 시 **오른쪽 패널**에 설정 UI 표시 (모달 없이) + +### 현재 상태 + +| 항목 | 상태 | +|------|------| +| 노드 플로워 시각화 | ✅ 완료 (읽기 전용) | +| DB 테이블 | ✅ 있음 (`screen_field_joins`, `screen_data_flows` 등) | +| 백엔드 API | ✅ 있음 (CRUD) | +| 패널 UI | ✅ 있음 (`FieldJoinPanel`, `DataFlowPanel`) | +| **노드에서 직접 설정** | ✅ **구현 완료** (방안 A) | + +--- + +## 노드에서 직접 설정 기능 (방안 A: 통합 설정 모달) + +### 구현 완료 (2026-01-09) + +노드 더블클릭 시 통합 설정 모달이 열리며, 4개 탭으로 다양한 설정을 수행할 수 있습니다. + +#### 사용법 + +1. **화면 노드** 또는 **테이블 노드**를 **더블클릭** +2. 통합 설정 모달이 열림 +3. 탭 선택하여 설정 +4. 저장 후 시각화 자동 새로고침 + +#### 탭 구성 + +| 탭 | 기능 | 설명 | +|----|------|------| +| 테이블 연결 | 화면-테이블 관계 설정 | 메인/서브/조회/저장 테이블 지정, CRUD 권한 설정 | +| 조인 설정 | FK-PK 조인 관계 설정 | 저장 테이블의 FK 컬럼 ↔ 조인 테이블의 PK 컬럼 매핑, 표시 컬럼 지정 | +| 데이터 흐름 | 화면 간 데이터 이동 설정 | 소스 화면 → 타겟 화면, 단방향/양방향 흐름 설정 | +| 필드 매핑 | 테이블 컬럼 정보 조회 | 현재 테이블의 컬럼 목록, 데이터 타입, 웹 타입 확인 | + +#### 구현 파일 + +| 파일 | 역할 | +|------|------| +| `frontend/components/screen/NodeSettingModal.tsx` | **새로 생성** - 통합 설정 모달 컴포넌트 | +| `frontend/components/screen/ScreenRelationFlow.tsx` | 노드 더블클릭 이벤트 핸들러 추가 | + +#### 주요 코드 변경 + +**NodeSettingModal.tsx (신규)** +- 4개 탭 컴포넌트 내장 (TableRelationTab, JoinSettingTab, DataFlowTab, FieldMappingTab) +- 기존 API 활용: `getTableRelations`, `getFieldJoins`, `getDataFlows` +- CRUD 연동: `createFieldJoin`, `updateFieldJoin`, `deleteFieldJoin` 등 +- 저장 후 부모 컴포넌트 새로고침 콜백 (`onRefresh`) + +**ScreenRelationFlow.tsx (수정)** +```typescript +// 노드 더블클릭 이벤트 핸들러 추가 +const handleNodeDoubleClick = useCallback((_event: React.MouseEvent, node: Node) => { + // 화면/테이블 노드 판별 후 모달 오픈 + if (node.id.startsWith("screen-")) { + // 화면 노드 처리 + } else if (node.id.startsWith("table-")) { + // 테이블 노드 처리 + } + setIsSettingModalOpen(true); +}, [screenTableMap, screenSubTableMap]); + +// ReactFlow에 이벤트 연결 + + +// 모달 렌더링 + +``` + +#### 시각화 새로고침 메커니즘 + +```typescript +// 강제 새로고침용 키 +const [refreshKey, setRefreshKey] = useState(0); + +// 새로고침 핸들러 +const handleRefreshVisualization = useCallback(() => { + setRefreshKey(prev => prev + 1); +}, []); + +// useEffect 의존성에 refreshKey 추가 +useEffect(() => { + // 데이터 로드 로직 +}, [screen, selectedGroup, ..., refreshKey]); +``` + +--- + +### 주요 파일 경로 + +``` +backend-node/src/ +├── controllers/screenGroupController.ts # 화면 그룹 API +├── routes/screenGroupRoutes.ts # 라우트 정의 + +frontend/ +├── app/(main)/admin/screenMng/screenMngList/page.tsx # 메인 페이지 +├── components/screen/ +│ ├── ScreenGroupTreeView.tsx # 트리 뷰 (그룹/화면 표시) +│ ├── ScreenGroupModal.tsx # 그룹 추가/수정 모달 +│ ├── ScreenRelationFlow.tsx # React Flow 시각화 + 더블클릭 이벤트 +│ ├── ScreenNode.tsx # 노드 컴포넌트 +│ ├── NodeSettingModal.tsx # **신규** - 통합 설정 모달 +│ └── panels/ +│ ├── FieldJoinPanel.tsx # 필드 조인 설정 UI (개별 패널) +│ └── DataFlowPanel.tsx # 데이터 흐름 설정 UI (개별 패널) +└── lib/api/screenGroup.ts # API 클라이언트 +``` + +--- + +## 향후 개선 사항 + +### 필드 매핑 탭 고도화 + +현재 필드 매핑 탭은 테이블 컬럼 정보를 조회만 가능합니다. 향후 다음 기능 추가 가능: + +1. **컬럼-컴포넌트 바인딩 설정**: 화면 컴포넌트와 DB 컬럼 직접 연결 +2. **드래그 앤 드롭**: 시각적 매핑 UI +3. **자동 매핑 추천**: 컬럼명 기반 자동 매핑 제안 + +### 관계 시각화 연동 + +설정 저장 후 시각화에 즉시 반영되지만, 다음 개선 가능: + +1. **실시간 프리뷰**: 저장 전 미리보기 +2. **관계 유형별 색상 커스터마이징** +3. **관계 라벨 표시 옵션** + +--- + ## 관련 문서 - [멀티테넌시 구현 가이드](.cursor/rules/multi-tenancy-guide.mdc) diff --git a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx index 1157810d..b49c78dc 100644 --- a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx @@ -160,13 +160,18 @@ export default function ScreenManagementPage() { setFocusedScreenIdInGroup(null); // 포커스 초기화 }} onScreenSelectInGroup={(group, screenId) => { - // 그룹 내 화면 클릭 시: 해당 화면 포커스 - // 이미 같은 그룹이 선택된 상태라면 그룹을 다시 설정하지 않음 (데이터 재로드 방지) - if (selectedGroup?.id !== group.id) { + // 그룹 내 화면 클릭 시 + const isNewGroup = selectedGroup?.id !== group.id; + + if (isNewGroup) { + // 새 그룹 진입: 포커싱 없이 시작 (첫 진입 시 망가지는 문제 방지) setSelectedGroup(group); + setFocusedScreenIdInGroup(null); + } else { + // 같은 그룹 내에서 다른 화면 클릭: 포커싱 유지 + setFocusedScreenIdInGroup(screenId); } setSelectedScreen(null); - setFocusedScreenIdInGroup(screenId); }} /> diff --git a/frontend/components/screen/NodeSettingModal.tsx b/frontend/components/screen/NodeSettingModal.tsx new file mode 100644 index 00000000..173e5664 --- /dev/null +++ b/frontend/components/screen/NodeSettingModal.tsx @@ -0,0 +1,1889 @@ +"use client"; + +import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { Badge } from "@/components/ui/badge"; +import { + Database, + Link2, + GitBranch, + Columns3, + Save, + Plus, + Pencil, + Trash2, + RefreshCw, + Loader2, + Check, + ChevronsUpDown, +} from "lucide-react"; +import { + getTableRelations, + createTableRelation, + updateTableRelation, + deleteTableRelation, + getFieldJoins, + createFieldJoin, + updateFieldJoin, + deleteFieldJoin, + getDataFlows, + createDataFlow, + updateDataFlow, + deleteDataFlow, + FieldJoin, + DataFlow, + TableRelation, +} from "@/lib/api/screenGroup"; +import { tableManagementApi, ColumnTypeInfo, TableInfo } from "@/lib/api/tableManagement"; + +// ============================================================ +// 타입 정의 +// ============================================================ + +// 기존 설정 정보 (화면 디자이너에서 추출) +interface ExistingConfig { + joinColumnRefs?: Array<{ + column: string; + refTable: string; + refTableLabel?: string; + refColumn: string; + }>; + filterColumns?: string[]; + fieldMappings?: Array<{ + targetField: string; + sourceField: string; + sourceTable?: string; + sourceDisplayName?: string; + }>; + referencedBy?: Array<{ + fromTable: string; + fromTableLabel?: string; + fromColumn: string; + toColumn: string; + toColumnLabel?: string; + relationType: string; + }>; + columns?: Array<{ + name: string; + originalName?: string; + type: string; + isPrimaryKey?: boolean; + isForeignKey?: boolean; + }>; + // 화면 노드용 테이블 정보 + mainTable?: string; + filterTables?: Array<{ + tableName: string; + tableLabel: string; + filterColumns: string[]; + joinColumnRefs: Array<{ + column: string; + refTable: string; + refTableLabel?: string; + refColumn: string; + }>; + }>; +} + +interface NodeSettingModalProps { + isOpen: boolean; + onClose: () => void; + // 노드 정보 + nodeType: "screen" | "table"; + nodeId: string; // 노드 ID (예: screen-1, table-sales_order_mng) + screenId: number; + screenName: string; + tableName?: string; // 테이블 노드인 경우 + tableLabel?: string; + // 그룹 정보 (데이터 흐름 설정에 필요) + groupId?: number; + groupScreens?: Array<{ screen_id: number; screen_name: string }>; + // 기존 설정 정보 (화면 디자이너에서 추출한 조인/필터 정보) + existingConfig?: ExistingConfig; + // 새로고침 콜백 + onRefresh?: () => void; +} + +// 탭 ID +type TabId = "table-relation" | "join-setting" | "data-flow" | "field-mapping"; + +// ============================================================ +// 검색 가능한 셀렉트 컴포넌트 +// ============================================================ + +interface SearchableSelectProps { + value: string; + onValueChange: (value: string) => void; + options: Array<{ value: string; label: string; description?: string }>; + placeholder?: string; + searchPlaceholder?: string; + emptyText?: string; + disabled?: boolean; + className?: string; +} + +function SearchableSelect({ + value, + onValueChange, + options, + placeholder = "선택", + searchPlaceholder = "검색...", + emptyText = "항목을 찾을 수 없습니다.", + disabled = false, + className, +}: SearchableSelectProps) { + const [open, setOpen] = useState(false); + + const selectedOption = options.find((opt) => opt.value === value); + + return ( + + + + + + + + + + {emptyText} + + + {options.map((option) => ( + { + onValueChange(option.value); + setOpen(false); + }} + className="text-xs" + > + +
+ {option.label} + {option.description && ( + + {option.description} + + )} +
+
+ ))} +
+
+
+
+
+ ); +} + +// ============================================================ +// 컴포넌트 +// ============================================================ + +export default function NodeSettingModal({ + isOpen, + onClose, + nodeType, + nodeId, + screenId, + screenName, + tableName, + tableLabel, + groupId, + groupScreens = [], + existingConfig, + onRefresh, +}: NodeSettingModalProps) { + // 탭 상태 + const [activeTab, setActiveTab] = useState("table-relation"); + + // 로딩 상태 + const [loading, setLoading] = useState(false); + + // 테이블 목록 (조인/필터 설정용) + const [tables, setTables] = useState([]); + const [tableColumns, setTableColumns] = useState>({}); + + // 테이블 연결 데이터 + const [tableRelations, setTableRelations] = useState([]); + + // 조인 설정 데이터 + const [fieldJoins, setFieldJoins] = useState([]); + + // 데이터 흐름 데이터 + const [dataFlows, setDataFlows] = useState([]); + + // ============================================================ + // 데이터 로드 + // ============================================================ + + // 테이블 목록 로드 + const loadTables = useCallback(async () => { + try { + const response = await tableManagementApi.getTableList(); + if (response.success && response.data) { + setTables(response.data); + } + } catch (error) { + console.error("테이블 목록 로드 실패:", error); + } + }, []); + + // 테이블 컬럼 로드 + const loadTableColumns = useCallback(async (tblName: string) => { + if (tableColumns[tblName]) return; // 이미 로드됨 + + try { + const response = await tableManagementApi.getColumnList(tblName); + if (response.success && response.data) { + setTableColumns(prev => ({ + ...prev, + [tblName]: response.data?.columns || [], + })); + } + } catch (error) { + console.error(`테이블 컬럼 로드 실패 (${tblName}):`, error); + } + }, [tableColumns]); + + // 테이블 연결 로드 + const loadTableRelations = useCallback(async () => { + if (!screenId) return; + + setLoading(true); + try { + const response = await getTableRelations({ screen_id: screenId }); + if (response.success && response.data) { + setTableRelations(response.data); + } + } catch (error) { + console.error("테이블 연결 로드 실패:", error); + } finally { + setLoading(false); + } + }, [screenId]); + + // 조인 설정 로드 + const loadFieldJoins = useCallback(async () => { + if (!screenId) return; + + setLoading(true); + try { + const response = await getFieldJoins(screenId); + if (response.success && response.data) { + setFieldJoins(response.data); + } + } catch (error) { + console.error("조인 설정 로드 실패:", error); + } finally { + setLoading(false); + } + }, [screenId]); + + // 데이터 흐름 로드 + const loadDataFlows = useCallback(async () => { + if (!groupId) return; + + setLoading(true); + try { + const response = await getDataFlows(groupId); + if (response.success && response.data) { + // 현재 화면 관련 흐름만 필터링 + const filtered = response.data.filter( + flow => flow.source_screen_id === screenId || flow.target_screen_id === screenId + ); + setDataFlows(filtered); + } + } catch (error) { + console.error("데이터 흐름 로드 실패:", error); + } finally { + setLoading(false); + } + }, [groupId, screenId]); + + // 모달 열릴 때 데이터 로드 + useEffect(() => { + if (isOpen) { + loadTables(); + loadTableRelations(); + loadFieldJoins(); + if (groupId) { + loadDataFlows(); + } + // 현재 테이블 컬럼 로드 + if (tableName) { + loadTableColumns(tableName); + } + } + }, [isOpen, loadTables, loadTableRelations, loadFieldJoins, loadDataFlows, tableName, groupId, loadTableColumns]); + + // ============================================================ + // 이벤트 핸들러 + // ============================================================ + + // 모달 닫기 + const handleClose = () => { + onClose(); + }; + + // 새로고침 + const handleRefresh = async () => { + setLoading(true); + try { + await Promise.all([ + loadTableRelations(), + loadFieldJoins(), + groupId ? loadDataFlows() : Promise.resolve(), + ]); + toast.success("데이터가 새로고침되었습니다."); + } catch (error) { + toast.error("새로고침 실패"); + } finally { + setLoading(false); + } + }; + + // ============================================================ + // 렌더링 + // ============================================================ + + // 모달 제목 + const modalTitle = nodeType === "screen" + ? `화면 설정: ${screenName}` + : `테이블 설정: ${tableLabel || tableName}`; + + // 모달 설명 + const modalDescription = nodeType === "screen" + ? "화면의 테이블 연결, 조인, 데이터 흐름을 설정합니다." + : "테이블의 조인 관계 및 필드 매핑을 설정합니다."; + + return ( + + + + + {nodeType === "screen" ? ( + + ) : ( + + )} + {modalTitle} + + + {modalDescription} + + + +
+ setActiveTab(v as TabId)} className="h-full flex flex-col"> +
+ + + + 테이블 연결 + 연결 + + + + 조인 설정 + 조인 + + + + 데이터 흐름 + 흐름 + + + + 필드 매핑 + 매핑 + + + + +
+ + {/* 탭 컨텐츠 */} +
+ {/* 탭1: 테이블 연결 */} + + + + + {/* 탭2: 조인 설정 */} + + + + + {/* 탭3: 데이터 흐름 */} + + + + + {/* 탭4: 필드 매핑 */} + + + +
+
+
+
+
+ ); +} + + +// ============================================================ +// 탭1: 테이블 연결 설정 +// ============================================================ + +interface TableRelationTabProps { + screenId: number; + screenName: string; + tableRelations: TableRelation[]; + tables: TableInfo[]; + loading: boolean; + onReload: () => void; + onRefreshVisualization?: () => void; + nodeType: "screen" | "table"; + existingConfig?: ExistingConfig; +} + +function TableRelationTab({ + screenId, + screenName, + tableRelations, + tables, + loading, + onReload, + onRefreshVisualization, + nodeType, + existingConfig, +}: TableRelationTabProps) { + const [isEditing, setIsEditing] = useState(false); + const [editItem, setEditItem] = useState(null); + const [formData, setFormData] = useState({ + table_name: "", + relation_type: "main", + crud_operations: "CR", + description: "", + is_active: "Y", + }); + + // 폼 초기화 + const resetForm = () => { + setFormData({ + table_name: "", + relation_type: "main", + crud_operations: "CR", + description: "", + is_active: "Y", + }); + setEditItem(null); + setIsEditing(false); + }; + + // 수정 모드 + const handleEdit = (item: TableRelation) => { + setEditItem(item); + setFormData({ + table_name: item.table_name, + relation_type: item.relation_type, + crud_operations: item.crud_operations, + description: item.description || "", + is_active: item.is_active, + }); + setIsEditing(true); + }; + + // 저장 + const handleSave = async () => { + if (!formData.table_name) { + toast.error("테이블을 선택해주세요."); + return; + } + + try { + const payload = { + screen_id: screenId, + ...formData, + }; + + let response; + if (editItem) { + response = await updateTableRelation(editItem.id, payload); + } else { + response = await createTableRelation(payload); + } + + if (response.success) { + toast.success(editItem ? "테이블 연결이 수정되었습니다." : "테이블 연결이 추가되었습니다."); + resetForm(); + onReload(); + onRefreshVisualization?.(); + } else { + toast.error(response.message || "저장에 실패했습니다."); + } + } catch (error: any) { + toast.error(error.message || "저장 중 오류가 발생했습니다."); + } + }; + + // 삭제 + const handleDelete = async (id: number) => { + if (!confirm("정말 삭제하시겠습니까?")) return; + + try { + const response = await deleteTableRelation(id); + if (response.success) { + toast.success("테이블 연결이 삭제되었습니다."); + onReload(); + onRefreshVisualization?.(); + } else { + toast.error(response.message || "삭제에 실패했습니다."); + } + } catch (error: any) { + toast.error(error.message || "삭제 중 오류가 발생했습니다."); + } + }; + + // 화면 디자이너에서 추출한 테이블 관계를 통합 목록으로 변환 + const designerTableRelations = useMemo(() => { + if (nodeType !== "screen" || !existingConfig) return []; + + const result: Array<{ + id: string; + source: "designer"; + table_name: string; + table_label?: string; + relation_type: string; + crud_operations: string; + description: string; + filterColumns?: string[]; + joinColumnRefs?: Array<{ column: string; refTable: string; refTableLabel?: string; refColumn: string; }>; + }> = []; + + // 메인 테이블 추가 + if (existingConfig.mainTable) { + result.push({ + id: `designer-main-${existingConfig.mainTable}`, + source: "designer", + table_name: existingConfig.mainTable, + table_label: existingConfig.mainTable, + relation_type: "main", + crud_operations: "CRUD", + description: "화면의 주요 데이터 소스 테이블", + }); + } + + // 필터 테이블 추가 + if (existingConfig.filterTables) { + existingConfig.filterTables.forEach((ft, idx) => { + result.push({ + id: `designer-filter-${ft.tableName}-${idx}`, + source: "designer", + table_name: ft.tableName, + table_label: ft.tableLabel, + relation_type: "sub", + crud_operations: "R", + description: "마스터-디테일 필터 테이블", + filterColumns: ft.filterColumns, + joinColumnRefs: ft.joinColumnRefs, + }); + }); + } + + return result; + }, [nodeType, existingConfig]); + + // DB 테이블 관계와 디자이너 테이블 관계 통합 + const unifiedTableRelations = useMemo(() => { + // DB 관계 + const dbRelations = tableRelations.map(item => ({ + ...item, + id: item.id, + source: "db" as const, + })); + + // 디자이너 관계 (DB에 이미 있는 테이블은 제외) + const dbTableNames = new Set(tableRelations.map(r => r.table_name)); + const filteredDesignerRelations = designerTableRelations.filter( + dr => !dbTableNames.has(dr.table_name) + ); + + return [...filteredDesignerRelations, ...dbRelations]; + }, [tableRelations, designerTableRelations]); + + // 디자이너 항목 수정 (DB로 저장) + const handleEditDesignerRelation = (item: typeof designerTableRelations[0]) => { + setFormData({ + table_name: item.table_name, + relation_type: item.relation_type, + crud_operations: item.crud_operations, + description: item.description || "", + is_active: "Y", + }); + setEditItem(null); + setIsEditing(true); + }; + + return ( +
+ {/* 입력 폼 */} +
+
{isEditing ? "테이블 연결 수정" : "새 테이블 연결 추가"}
+ +
+
+ + setFormData(prev => ({ ...prev, table_name: v }))} + options={tables.map((t) => ({ + value: t.tableName, + label: t.displayName || t.tableName, + description: t.tableName !== t.displayName ? t.tableName : undefined, + }))} + placeholder="테이블 선택" + searchPlaceholder="테이블 검색..." + /> +
+ +
+ + setFormData(prev => ({ ...prev, relation_type: v }))} + options={[ + { value: "main", label: "메인 테이블" }, + { value: "sub", label: "서브 테이블" }, + { value: "lookup", label: "조회 테이블" }, + { value: "save", label: "저장 테이블" }, + ]} + placeholder="관계 유형" + searchPlaceholder="유형 검색..." + /> +
+ +
+ + setFormData(prev => ({ ...prev, crud_operations: v }))} + options={[ + { value: "C", label: "생성(C)" }, + { value: "R", label: "읽기(R)" }, + { value: "CR", label: "생성+읽기(CR)" }, + { value: "CRU", label: "생성+읽기+수정(CRU)" }, + { value: "CRUD", label: "전체(CRUD)" }, + ]} + placeholder="CRUD 권한" + searchPlaceholder="권한 검색..." + /> +
+ +
+ + setFormData(prev => ({ ...prev, description: e.target.value }))} + placeholder="설명 입력" + className="h-9 text-xs" + /> +
+
+ +
+ {isEditing && ( + + )} + +
+
+ + {/* 목록 */} +
+ + + + 출처 + 테이블 + 관계 유형 + CRUD + 설명 + 작업 + + + + {loading ? ( + + + + + + ) : unifiedTableRelations.length === 0 ? ( + + + 등록된 테이블 연결이 없습니다. + + + ) : ( + unifiedTableRelations.map((item) => ( + + + + {item.source === "designer" ? "화면" : "DB"} + + + +
+ {item.table_label || item.table_name} + {item.table_label && item.table_label !== item.table_name && ( + ({item.table_name}) + )} +
+ {/* 필터 테이블의 경우 필터 컬럼/조인 정보 표시 */} + {item.source === "designer" && "filterColumns" in item && item.filterColumns && item.filterColumns.length > 0 && ( +
+ {item.filterColumns.map((col, idx) => ( + + {col} + + ))} +
+ )} + {item.source === "designer" && "joinColumnRefs" in item && item.joinColumnRefs && item.joinColumnRefs.length > 0 && ( +
+ {item.joinColumnRefs.map((join, idx) => ( + + {join.column}→{join.refTable} + + ))} +
+ )} +
+ + + {item.relation_type === "main" ? "메인" : + item.relation_type === "sub" ? "필터" : + item.relation_type === "save" ? "저장" : + item.relation_type === "lookup" ? "조회" : item.relation_type} + + + {item.crud_operations} + + {item.description || "-"} + + +
+ {item.source === "db" ? ( + <> + + + + ) : ( + + )} +
+
+
+ )) + )} +
+
+
+
+ ); +} + + +// ============================================================ +// 탭2: 조인 설정 +// ============================================================ + +interface JoinSettingTabProps { + screenId: number; + tableName?: string; + fieldJoins: FieldJoin[]; + tables: TableInfo[]; + tableColumns: Record; + loading: boolean; + onReload: () => void; + onLoadColumns: (tableName: string) => void; + onRefreshVisualization?: () => void; + // 기존 설정 정보 (화면 디자이너에서 추출) + existingConfig?: ExistingConfig; +} + +// 화면 디자이너 조인 설정을 통합 형식으로 변환하기 위한 인터페이스 +interface UnifiedJoinItem { + id: number | string; // DB는 숫자, 화면 디자이너는 문자열 + source: "db" | "designer"; // 출처 + save_table: string; + save_table_label?: string; + save_column: string; + join_table: string; + join_table_label?: string; + join_column: string; + display_column?: string; + join_type: string; +} + +function JoinSettingTab({ + screenId, + tableName, + fieldJoins, + tables, + tableColumns, + loading, + onReload, + onLoadColumns, + onRefreshVisualization, + existingConfig, +}: JoinSettingTabProps) { + const [isEditing, setIsEditing] = useState(false); + const [editItem, setEditItem] = useState(null); + const [editingDesignerItem, setEditingDesignerItem] = useState(null); + const [formData, setFormData] = useState({ + field_name: "", + save_table: tableName || "", + save_column: "", + join_table: "", + join_column: "", + display_column: "", + join_type: "LEFT", + filter_condition: "", + is_active: "Y", + }); + + // 테이블 라벨 가져오기 (tableName -> displayName) - 먼저 선언해야 함 + const tableLabel = tables.find(t => t.tableName === tableName)?.displayName; + + // 화면 디자이너 조인 설정을 통합 형식으로 변환 + // 1. 현재 테이블의 조인 설정 + const directJoins: UnifiedJoinItem[] = (existingConfig?.joinColumnRefs || []).map((ref, idx) => ({ + id: `designer-direct-${idx}`, + source: "designer" as const, + save_table: tableName || "", + save_table_label: tableLabel || tableName, + save_column: ref.column, + join_table: ref.refTable, + join_table_label: ref.refTableLabel, + join_column: ref.refColumn, + display_column: "", + join_type: "LEFT", + })); + + // 2. 필터 테이블들의 조인 설정 (화면 노드에서 열었을 때) + const filterTableJoins: UnifiedJoinItem[] = (existingConfig?.filterTables || []).flatMap((ft, ftIdx) => + (ft.joinColumnRefs || []).map((ref, refIdx) => ({ + id: `designer-filter-${ftIdx}-${refIdx}`, + source: "designer" as const, + save_table: ft.tableName, + save_table_label: ft.tableLabel || ft.tableName, + save_column: ref.column, + join_table: ref.refTable, + join_table_label: ref.refTableLabel, + join_column: ref.refColumn, + display_column: "", + join_type: "LEFT", + })) + ); + + // 모든 디자이너 조인 설정 통합 + const designerJoins: UnifiedJoinItem[] = [...directJoins, ...filterTableJoins]; + + // DB 조인 설정을 통합 형식으로 변환 + const dbJoins: UnifiedJoinItem[] = fieldJoins.map((item) => ({ + id: item.id, + source: "db" as const, + save_table: item.save_table, + save_table_label: item.save_table_label, + save_column: item.save_column, + join_table: item.join_table, + join_table_label: item.join_table_label, + join_column: item.join_column, + display_column: item.display_column, + join_type: item.join_type, + })); + + // 통합된 조인 목록 (화면 디자이너 + DB) + const unifiedJoins = [...designerJoins, ...dbJoins]; + + // 저장 테이블 변경 시 컬럼 로드 + useEffect(() => { + if (formData.save_table) { + onLoadColumns(formData.save_table); + } + }, [formData.save_table, onLoadColumns]); + + // 조인 테이블 변경 시 컬럼 로드 + useEffect(() => { + if (formData.join_table) { + onLoadColumns(formData.join_table); + } + }, [formData.join_table, onLoadColumns]); + + // 폼 초기화 + const resetForm = () => { + setFormData({ + field_name: "", + save_table: tableName || "", + save_column: "", + join_table: "", + join_column: "", + display_column: "", + join_type: "LEFT", + filter_condition: "", + is_active: "Y", + }); + setEditItem(null); + setEditingDesignerItem(null); + setIsEditing(false); + }; + + // 수정 모드 (DB 설정) + const handleEdit = (item: FieldJoin) => { + setEditItem(item); + setEditingDesignerItem(null); + setFormData({ + field_name: item.field_name || "", + save_table: item.save_table, + save_column: item.save_column, + join_table: item.join_table, + join_column: item.join_column, + display_column: item.display_column, + join_type: item.join_type, + filter_condition: item.filter_condition || "", + is_active: item.is_active, + }); + setIsEditing(true); + // 컬럼 로드 + onLoadColumns(item.save_table); + onLoadColumns(item.join_table); + }; + + // 통합 목록에서 수정 버튼 클릭 + const handleEditUnified = (item: UnifiedJoinItem) => { + if (item.source === "db") { + // DB 설정은 기존 로직 사용 + const originalItem = fieldJoins.find(j => j.id === item.id); + if (originalItem) handleEdit(originalItem); + } else { + // 화면 디자이너 설정은 폼에 채우고 새로 저장하도록 + setEditItem(null); + setEditingDesignerItem(item); + setFormData({ + field_name: "", + save_table: item.save_table, + save_column: item.save_column, + join_table: item.join_table, + join_column: item.join_column, + display_column: item.display_column || "", + join_type: item.join_type, + filter_condition: "", + is_active: "Y", + }); + setIsEditing(true); + // 컬럼 로드 + onLoadColumns(item.save_table); + onLoadColumns(item.join_table); + } + }; + + // 통합 목록에서 삭제 버튼 클릭 + const handleDeleteUnified = async (item: UnifiedJoinItem) => { + if (item.source === "db") { + // DB 설정만 삭제 가능 + await handleDelete(item.id as number); + } else { + // 화면 디자이너 설정은 삭제 불가 (화면 디자이너에서 수정해야 함) + toast.info("화면 디자이너 설정은 화면 디자이너에서 수정해주세요."); + } + }; + + // 저장 + const handleSave = async () => { + if (!formData.save_table || !formData.save_column || !formData.join_table || !formData.join_column) { + toast.error("필수 필드를 모두 입력해주세요."); + return; + } + + try { + const payload = { + screen_id: screenId, + ...formData, + }; + + let response; + if (editItem) { + response = await updateFieldJoin(editItem.id, payload); + } else { + response = await createFieldJoin(payload); + } + + if (response.success) { + toast.success(editItem ? "조인 설정이 수정되었습니다." : "조인 설정이 추가되었습니다."); + resetForm(); + onReload(); + onRefreshVisualization?.(); + } else { + toast.error(response.message || "저장에 실패했습니다."); + } + } catch (error: any) { + toast.error(error.message || "저장 중 오류가 발생했습니다."); + } + }; + + // 삭제 + const handleDelete = async (id: number) => { + if (!confirm("정말 삭제하시겠습니까?")) return; + + try { + const response = await deleteFieldJoin(id); + if (response.success) { + toast.success("조인 설정이 삭제되었습니다."); + onReload(); + onRefreshVisualization?.(); + } else { + toast.error(response.message || "삭제에 실패했습니다."); + } + } catch (error: any) { + toast.error(error.message || "삭제 중 오류가 발생했습니다."); + } + }; + + // 저장 테이블 컬럼 + const saveTableColumns = tableColumns[formData.save_table] || []; + + // 조인 테이블 컬럼 + const joinTableColumns = tableColumns[formData.join_table] || []; + + return ( +
+ {/* 필터링 컬럼 정보 */} + {existingConfig?.filterColumns && existingConfig.filterColumns.length > 0 && ( +
+
+ + 필터링 컬럼 (마스터-디테일 연동) +
+
+ {existingConfig.filterColumns.map((col, idx) => ( + + {col} + + ))} +
+

+ * 이 컬럼들을 기준으로 상위 화면에서 데이터가 필터링됩니다. +

+
+ )} + + {/* 참조 정보 (이 테이블을 참조하는 다른 테이블들) */} + {existingConfig?.referencedBy && existingConfig.referencedBy.length > 0 && ( +
+
+ + 이 테이블을 참조하는 관계 +
+ + + + 참조하는 테이블 + 참조 유형 + 연결 + + + + {existingConfig.referencedBy.map((ref, idx) => ( + + + {ref.fromTableLabel || ref.fromTable} + + + + {ref.relationType} + + + + {ref.fromColumn} → {ref.toColumn} + + + ))} + +
+
+ )} + + {/* 입력 폼 */} +
+
{isEditing ? "조인 설정 수정" : "새 조인 설정 추가"}
+ +
+ {/* 저장 테이블 */} +
+ + { + setFormData(prev => ({ ...prev, save_table: v, save_column: "" })); + }} + options={tables.map((t) => ({ + value: t.tableName, + label: t.displayName || t.tableName, + description: t.tableName !== t.displayName ? t.tableName : undefined, + }))} + placeholder="테이블 선택" + searchPlaceholder="테이블 검색..." + /> +
+ + {/* 저장 컬럼 */} +
+ + setFormData(prev => ({ ...prev, save_column: v }))} + disabled={!formData.save_table} + options={saveTableColumns.map((c) => ({ + value: c.columnName, + label: c.displayName || c.columnName, + description: c.columnName !== c.displayName ? c.columnName : undefined, + }))} + placeholder="컬럼 선택" + searchPlaceholder="컬럼 검색..." + /> +
+ + {/* 조인 타입 */} +
+ + setFormData(prev => ({ ...prev, join_type: v }))} + options={[ + { value: "LEFT", label: "LEFT JOIN" }, + { value: "INNER", label: "INNER JOIN" }, + { value: "RIGHT", label: "RIGHT JOIN" }, + ]} + placeholder="조인 타입" + searchPlaceholder="타입 검색..." + /> +
+
+ +
+ {/* 조인 테이블 */} +
+ + { + setFormData(prev => ({ ...prev, join_table: v, join_column: "", display_column: "" })); + }} + options={tables.map((t) => ({ + value: t.tableName, + label: t.displayName || t.tableName, + description: t.tableName !== t.displayName ? t.tableName : undefined, + }))} + placeholder="테이블 선택" + searchPlaceholder="테이블 검색..." + /> +
+ + {/* 조인 컬럼 */} +
+ + setFormData(prev => ({ ...prev, join_column: v }))} + disabled={!formData.join_table} + options={joinTableColumns.map((c) => ({ + value: c.columnName, + label: c.displayName || c.columnName, + description: c.columnName !== c.displayName ? c.columnName : undefined, + }))} + placeholder="컬럼 선택" + searchPlaceholder="컬럼 검색..." + /> +
+ + {/* 표시 컬럼 */} +
+ + setFormData(prev => ({ ...prev, display_column: v }))} + disabled={!formData.join_table} + options={joinTableColumns.map((c) => ({ + value: c.columnName, + label: c.displayName || c.columnName, + description: c.columnName !== c.displayName ? c.columnName : undefined, + }))} + placeholder="표시할 컬럼 선택" + searchPlaceholder="컬럼 검색..." + /> +
+
+ +
+ {isEditing && ( + + )} + +
+
+ + {/* 통합 조인 목록 */} +
+
+ + + 조인 설정 목록 + + + 총 {unifiedJoins.length}개 + +
+ + + + 출처 + 저장 테이블 + FK 컬럼 + 조인 테이블 + PK 컬럼 + 타입 + 작업 + + + + {loading ? ( + + + + + + ) : unifiedJoins.length === 0 ? ( + + + 등록된 조인 설정이 없습니다. + + + ) : ( + unifiedJoins.map((item) => ( + + + {item.source === "designer" ? ( + + 화면 + + ) : ( + + DB + + )} + + {item.save_table_label || item.save_table} + {item.save_column} + {item.join_table_label || item.join_table} + {item.join_column} + + + {item.join_type} + + + +
+ + {item.source === "db" && ( + + )} +
+
+
+ )) + )} +
+
+ {designerJoins.length > 0 && ( +
+ * 화면: 화면 디자이너 설정 (수정 시 DB에 저장) | + * DB: DB 저장 설정 (직접 수정/삭제 가능) +
+ )} +
+
+ ); +} + + +// ============================================================ +// 탭3: 데이터 흐름 +// ============================================================ + +interface DataFlowTabProps { + screenId: number; + groupId?: number; + groupScreens: Array<{ screen_id: number; screen_name: string }>; + dataFlows: DataFlow[]; + loading: boolean; + onReload: () => void; + onRefreshVisualization?: () => void; +} + +function DataFlowTab({ + screenId, + groupId, + groupScreens, + dataFlows, + loading, + onReload, + onRefreshVisualization, +}: DataFlowTabProps) { + const [isEditing, setIsEditing] = useState(false); + const [editItem, setEditItem] = useState(null); + const [formData, setFormData] = useState({ + source_screen_id: screenId, + source_action: "", + target_screen_id: 0, + target_action: "", + flow_type: "unidirectional", + flow_label: "", + is_active: "Y", + }); + + // 폼 초기화 + const resetForm = () => { + setFormData({ + source_screen_id: screenId, + source_action: "", + target_screen_id: 0, + target_action: "", + flow_type: "unidirectional", + flow_label: "", + is_active: "Y", + }); + setEditItem(null); + setIsEditing(false); + }; + + // 수정 모드 + const handleEdit = (item: DataFlow) => { + setEditItem(item); + setFormData({ + source_screen_id: item.source_screen_id, + source_action: item.source_action || "", + target_screen_id: item.target_screen_id, + target_action: item.target_action || "", + flow_type: item.flow_type, + flow_label: item.flow_label || "", + is_active: item.is_active, + }); + setIsEditing(true); + }; + + // 저장 + const handleSave = async () => { + if (!formData.source_screen_id || !formData.target_screen_id) { + toast.error("소스 화면과 타겟 화면을 선택해주세요."); + return; + } + + try { + const payload = { + group_id: groupId, + ...formData, + }; + + let response; + if (editItem) { + response = await updateDataFlow(editItem.id, payload); + } else { + response = await createDataFlow(payload); + } + + if (response.success) { + toast.success(editItem ? "데이터 흐름이 수정되었습니다." : "데이터 흐름이 추가되었습니다."); + resetForm(); + onReload(); + onRefreshVisualization?.(); + } else { + toast.error(response.message || "저장에 실패했습니다."); + } + } catch (error: any) { + toast.error(error.message || "저장 중 오류가 발생했습니다."); + } + }; + + // 삭제 + const handleDelete = async (id: number) => { + if (!confirm("정말 삭제하시겠습니까?")) return; + + try { + const response = await deleteDataFlow(id); + if (response.success) { + toast.success("데이터 흐름이 삭제되었습니다."); + onReload(); + onRefreshVisualization?.(); + } else { + toast.error(response.message || "삭제에 실패했습니다."); + } + } catch (error: any) { + toast.error(error.message || "삭제 중 오류가 발생했습니다."); + } + }; + + // 그룹 없음 안내 + if (!groupId) { + return ( +
+ +

그룹 정보가 없습니다

+

+ 데이터 흐름 설정은 화면 그룹 내에서만 사용할 수 있습니다. +

+
+ ); + } + + return ( +
+ {/* 입력 폼 */} +
+
{isEditing ? "데이터 흐름 수정" : "새 데이터 흐름 추가"}
+ +
+ {/* 소스 화면 */} +
+ + setFormData(prev => ({ ...prev, source_screen_id: parseInt(v) }))} + options={groupScreens.map((s) => ({ + value: s.screen_id.toString(), + label: s.screen_name, + }))} + placeholder="화면 선택" + searchPlaceholder="화면 검색..." + /> +
+ + {/* 소스 액션 */} +
+ + setFormData(prev => ({ ...prev, source_action: e.target.value }))} + placeholder="예: 행 선택" + className="h-9 text-xs" + /> +
+ + {/* 타겟 화면 */} +
+ + setFormData(prev => ({ ...prev, target_screen_id: parseInt(v) }))} + options={groupScreens + .filter(s => s.screen_id !== formData.source_screen_id) + .map((s) => ({ + value: s.screen_id.toString(), + label: s.screen_name, + }))} + placeholder="화면 선택" + searchPlaceholder="화면 검색..." + /> +
+ + {/* 흐름 타입 */} +
+ + setFormData(prev => ({ ...prev, flow_type: v }))} + options={[ + { value: "unidirectional", label: "단방향" }, + { value: "bidirectional", label: "양방향" }, + ]} + placeholder="흐름 타입" + searchPlaceholder="타입 검색..." + /> +
+
+ +
+ {isEditing && ( + + )} + +
+
+ + {/* 목록 */} +
+ + + + 소스 화면 + 액션 + 타겟 화면 + 흐름 + 작업 + + + + {loading ? ( + + + + + + ) : dataFlows.length === 0 ? ( + + + 등록된 데이터 흐름이 없습니다. + + + ) : ( + dataFlows.map((item) => ( + + + {item.source_screen_name || `화면 ${item.source_screen_id}`} + + + {item.source_action || "-"} + + + {item.target_screen_name || `화면 ${item.target_screen_id}`} + + + + {item.flow_type === "bidirectional" ? "양방향" : "단방향"} + + + +
+ + +
+
+
+ )) + )} +
+
+
+
+ ); +} + + +// ============================================================ +// 탭4: 필드-컬럼 매핑 (화면 컴포넌트와 DB 컬럼 연결) +// ============================================================ + +interface FieldMappingTabProps { + screenId: number; + tableName?: string; + tableColumns: ColumnTypeInfo[]; + loading: boolean; +} + +function FieldMappingTab({ + screenId, + tableName, + tableColumns, + loading, +}: FieldMappingTabProps) { + // 필드 매핑은 screen_layouts.properties에서 관리됨 + // 이 탭에서는 현재 매핑 상태를 조회하고 편집 가능하게 제공 + + return ( +
+
+
+ + 필드-컬럼 매핑 +
+

+ 화면 컴포넌트와 데이터베이스 컬럼 간의 바인딩을 설정합니다. +
+ 현재는 화면 디자이너에서 설정된 내용을 확인할 수 있습니다. +

+
+ + {/* 테이블 컬럼 목록 */} + {tableName && ( +
+
+ 테이블: {tableName} +
+ + + + 컬럼명 + 한글명 + 데이터 타입 + 웹 타입 + PK + + + + {loading ? ( + + + + + + ) : tableColumns.length === 0 ? ( + + + 컬럼 정보가 없습니다. + + + ) : ( + tableColumns.slice(0, 20).map((col) => ( + + {col.columnName} + {col.displayName} + {col.dbType} + + + {col.webType} + + + + {col.isPrimaryKey && ( + + PK + + )} + + + )) + )} + +
+ {tableColumns.length > 20 && ( +
+ + {tableColumns.length - 20}개 더 있음 +
+ )} +
+ )} + + {!tableName && ( +
+ +

테이블 정보가 없습니다

+

+ 테이블 노드에서 더블클릭하여 필드 매핑을 확인하세요. +

+
+ )} +
+ ); +} + diff --git a/frontend/components/screen/ScreenNode.tsx b/frontend/components/screen/ScreenNode.tsx index e05789f5..e49aa470 100644 --- a/frontend/components/screen/ScreenNode.tsx +++ b/frontend/components/screen/ScreenNode.tsx @@ -569,7 +569,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { // 저장 대상 여부 const hasSaveTarget = saveInfos && saveInfos.length > 0; - + return (
= ({ data }) => { )}
- + {/* 컬럼 목록 - 컴팩트하게 (부드러운 높이 전환 + 스크롤) */} {/* 뱃지도 이 영역 안에 포함되어 높이 계산에 반영됨 */}
= { @@ -86,6 +88,64 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId // 그룹 내 포커스된 화면 ID (그룹 모드에서만 사용) const [focusedScreenId, setFocusedScreenId] = useState(null); + // 노드 설정 모달 상태 + const [isSettingModalOpen, setIsSettingModalOpen] = useState(false); + const [settingModalNode, setSettingModalNode] = useState<{ + nodeType: "screen" | "table"; + nodeId: string; + screenId: number; + screenName: string; + tableName?: string; + tableLabel?: string; + // 기존 설정 정보 (화면 디자이너에서 추출) + existingConfig?: { + joinColumnRefs?: Array<{ + column: string; + refTable: string; + refTableLabel?: string; + refColumn: string; + }>; + filterColumns?: string[]; + fieldMappings?: Array<{ + targetField: string; + sourceField: string; + sourceTable?: string; + sourceDisplayName?: string; + }>; + referencedBy?: Array<{ + fromTable: string; + fromTableLabel?: string; + fromColumn: string; + toColumn: string; + toColumnLabel?: string; + relationType: string; + }>; + columns?: Array<{ + name: string; + originalName?: string; + type: string; + isPrimaryKey?: boolean; + isForeignKey?: boolean; + }>; + // 화면 노드용 테이블 정보 + mainTable?: string; + filterTables?: Array<{ + tableName: string; + tableLabel: string; + filterColumns: string[]; + joinColumnRefs: Array<{ + column: string; + refTable: string; + refTableLabel?: string; + refColumn: string; + }>; + }>; + }; + } | null>(null); + + // 강제 새로고침용 키 (설정 저장 후 시각화 재로딩) + const [refreshKey, setRefreshKey] = useState(0); + // 그룹 또는 화면이 변경될 때 포커스 초기화 useEffect(() => { setFocusedScreenId(null); @@ -814,12 +874,11 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId const exists = newEdges.some((e) => e.id === edgeId); if (exists) return; - // 관계 타입에 따른 라벨 - let relationLabel = "참조"; - if (subTable.relationType === "lookup") relationLabel = "조회"; - else if (subTable.relationType === "source") relationLabel = "데이터 소스"; - else if (subTable.relationType === "join") relationLabel = "조인"; + // 관계 유형 결정 (스타일링용) + const visualRelationType = inferVisualRelationType(subTable); + const relationColor = RELATION_COLORS[visualRelationType]; + // 메인-서브 조인선 (메인-메인과 동일한 스타일, 라벨 없음) newEdges.push({ id: edgeId, source: `table-${mainTable}`, @@ -827,50 +886,47 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId sourceHandle: "bottom", targetHandle: "top", type: "smoothstep", - label: relationLabel, - labelStyle: { - fontSize: 9, - fill: "#94a3b8", - fontWeight: 500 - }, - labelBgStyle: { - fill: "white", - stroke: "#e2e8f0", - strokeWidth: 1 - }, - labelBgPadding: [3, 2] as [number, number], markerEnd: { type: MarkerType.ArrowClosed, - color: "#94a3b8" + color: relationColor.strokeLight }, - animated: false, // 기본: 애니메이션 비활성화 (포커스 시에만 활성화) + animated: false, style: { - stroke: "#94a3b8", - strokeWidth: 1, - strokeDasharray: "6,4", // 점선 - opacity: 0.5, // 기본 투명도 + stroke: relationColor.strokeLight, + strokeWidth: 1.5, + strokeDasharray: "8,4", + opacity: 0.5, + }, + data: { + sourceScreenId, + visualRelationType, }, - data: { sourceScreenId }, }); }); }); - // 조인 관계 엣지 (테이블 간 - 1:N 관계 표시) + // 조인 관계 엣지 (screen_field_joins 기반 - 라벨 없이 통일된 스타일) joins.forEach((join: any, idx: number) => { if (join.save_table && join.join_table && join.save_table !== join.join_table) { newEdges.push({ - id: `edge-join-${idx}`, + id: `edge-join-db-${idx}`, source: `table-${join.save_table}`, target: `table-${join.join_table}`, - sourceHandle: "right", - targetHandle: "left", + sourceHandle: "bottom", + targetHandle: "bottom_target", type: "smoothstep", - label: "1:N 관계", - labelStyle: { fontSize: 10, fill: "#6366f1", fontWeight: 500 }, - labelBgStyle: { fill: "white", stroke: "#e2e8f0", strokeWidth: 1 }, - labelBgPadding: [4, 2] as [number, number], - markerEnd: { type: MarkerType.ArrowClosed, color: "#6366f1" }, - style: { stroke: "#6366f1", strokeWidth: 1.5, strokeDasharray: "5,5" }, + markerEnd: { + type: MarkerType.ArrowClosed, + color: RELATION_COLORS.join.strokeLight + }, + animated: false, + style: { + stroke: RELATION_COLORS.join.strokeLight, + strokeWidth: 1.5, + strokeDasharray: "8,4", + opacity: 0.5, + }, + data: { visualRelationType: 'join' }, }); } }); @@ -955,8 +1011,9 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId loadRelations(); // focusedScreenId는 스타일링에만 영향을 미치므로 의존성에서 제외 + // refreshKey: 설정 저장 후 강제 새로고침용 // eslint-disable-next-line react-hooks/exhaustive-deps - }, [screen, selectedGroup, setNodes, setEdges, loadTableColumns]); + }, [screen, selectedGroup, setNodes, setEdges, loadTableColumns, refreshKey]); // 데이터 로드 완료 시 fitView 호출 (초기 로드 시에만) useEffect(() => { @@ -984,6 +1041,198 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId setFocusedScreenId((prev) => (prev === screenId ? null : screenId)); } }, [selectedGroup]); + + // 테이블 정보에서 조인/필터 정보 추출 (더블클릭 핸들러용) + const getTableExistingConfig = useCallback((tableName: string) => { + // subTablesDataMap에서 서브 테이블 정보 찾기 + for (const screenId in subTablesDataMap) { + const screenSubTables = subTablesDataMap[parseInt(screenId)]; + if (screenSubTables?.subTables) { + const subTable = screenSubTables.subTables.find(st => st.tableName === tableName); + if (subTable) { + return { + joinColumnRefs: subTable.joinColumnRefs, + filterColumns: subTable.filterColumns, + fieldMappings: subTable.fieldMappings?.map(m => ({ + targetField: m.targetField, + sourceField: m.sourceField, + sourceTable: m.sourceTable, + sourceDisplayName: m.sourceDisplayName, + })), + columns: [], // 컬럼 정보는 노드에서 가져옴 + }; + } + } + } + return undefined; + }, [subTablesDataMap]); + + // 노드 우클릭 핸들러 (설정 모달 열기) + const handleNodeContextMenu = useCallback((event: React.MouseEvent, node: Node) => { + // 기본 컨텍스트 메뉴 방지 + event.preventDefault(); + + // 화면 노드 우클릭 + if (node.id.startsWith("screen-")) { + const screenId = parseInt(node.id.replace("screen-", "")); + const nodeData = node.data as ScreenNodeData; + const mainTable = screenTableMap[screenId]; + + // 해당 화면의 서브 테이블 (필터 테이블) 정보 + // 1. screenSubTableMap에서 가져오기 + const screenSubTables = screenSubTableMap[screenId] || []; + + // 2. edges에서 필터 테이블 찾기 (edge-screen-filter-{screenId}-{tableName}) + const filterTableNamesFromEdges = edges + .filter(e => e.id.startsWith(`edge-screen-filter-${screenId}-`)) + .map(e => { + const match = e.id.match(/edge-screen-filter-\d+-(.+)/); + return match ? match[1] : null; + }) + .filter((name): name is string => name !== null); + + // 모든 필터 테이블 합치기 (중복 제거) + const allFilterTableNames = [...new Set([...screenSubTables, ...filterTableNamesFromEdges])]; + + const filterTables = allFilterTableNames.map(tableName => { + // subTablesDataMap에서 해당 테이블 정보 찾기 + const subTableData = subTablesDataMap[screenId]?.subTables?.find( + st => st.tableName === tableName + ); + + // 또는 nodes에서 테이블 노드 정보 찾기 + const tableNode = nodes.find(n => + n.id === `table-${tableName}` || n.id === `subtable-${tableName}` + ); + const tableNodeData = tableNode?.data as TableNodeData | undefined; + + return { + tableName, + tableLabel: subTableData?.tableLabel || tableNodeData?.label || tableName, + filterColumns: subTableData?.filterColumns || tableNodeData?.filterColumns || [], + joinColumnRefs: subTableData?.joinColumnRefs || tableNodeData?.joinColumnRefs || [], + }; + }); + + setSettingModalNode({ + nodeType: "screen", + nodeId: node.id, + screenId: screenId, + screenName: nodeData.label || `화면 ${screenId}`, + tableName: mainTable, + tableLabel: nodeData.subLabel, + // 화면의 테이블 정보 전달 + existingConfig: { + mainTable: mainTable, + filterTables: filterTables, + }, + }); + setIsSettingModalOpen(true); + return; + } + + // 메인 테이블 노드 더블클릭 + if (node.id.startsWith("table-") && !node.id.startsWith("table-sub-")) { + const tableName = node.id.replace("table-", ""); + const nodeData = node.data as TableNodeData; + + // 이 테이블을 사용하는 화면 찾기 + const screenId = Object.entries(screenTableMap).find( + ([_, tbl]) => tbl === tableName + )?.[0]; + + // 백엔드에서 받은 데이터에서 기존 설정 정보 추출 + const existingConfigFromData = getTableExistingConfig(tableName); + + setSettingModalNode({ + nodeType: "table", + nodeId: node.id, + screenId: screenId ? parseInt(screenId) : 0, + screenName: nodeData.subLabel || tableName, + tableName: tableName, + tableLabel: nodeData.label, + // 기존 설정 정보 전달 + existingConfig: existingConfigFromData || { + joinColumnRefs: nodeData.joinColumnRefs, + filterColumns: nodeData.filterColumns, + fieldMappings: nodeData.fieldMappings?.map(m => ({ + targetField: m.targetField, + sourceField: m.sourceField, + sourceTable: m.sourceTable, + sourceDisplayName: m.sourceDisplayName, + })), + referencedBy: nodeData.referencedBy?.map(r => ({ + fromTable: r.fromTable, + fromTableLabel: r.fromTableLabel, + fromColumn: r.fromColumn, + toColumn: r.toColumn, + toColumnLabel: r.toColumnLabel, + relationType: r.relationType, + })), + columns: nodeData.columns, + }, + }); + setIsSettingModalOpen(true); + return; + } + + // 서브 테이블 노드 더블클릭 + if (node.id.startsWith("subtable-")) { + const tableName = node.id.replace("subtable-", ""); + const nodeData = node.data as TableNodeData; + + // 이 서브 테이블을 사용하는 화면 찾기 + const screenId = Object.entries(screenSubTableMap).find( + ([_, tables]) => tables.includes(tableName) + )?.[0]; + + // 백엔드에서 받은 데이터에서 기존 설정 정보 추출 + const existingConfigFromData = getTableExistingConfig(tableName); + + setSettingModalNode({ + nodeType: "table", + nodeId: node.id, + screenId: screenId ? parseInt(screenId) : 0, + screenName: nodeData.subLabel || tableName, + tableName: tableName, + tableLabel: nodeData.label, + // 기존 설정 정보 전달 + existingConfig: existingConfigFromData || { + joinColumnRefs: nodeData.joinColumnRefs, + filterColumns: nodeData.filterColumns, + fieldMappings: nodeData.fieldMappings?.map(m => ({ + targetField: m.targetField, + sourceField: m.sourceField, + sourceTable: m.sourceTable, + sourceDisplayName: m.sourceDisplayName, + })), + referencedBy: nodeData.referencedBy?.map(r => ({ + fromTable: r.fromTable, + fromTableLabel: r.fromTableLabel, + fromColumn: r.fromColumn, + toColumn: r.toColumn, + toColumnLabel: r.toColumnLabel, + relationType: r.relationType, + })), + columns: nodeData.columns, + }, + }); + setIsSettingModalOpen(true); + return; + } + }, [screenTableMap, screenSubTableMap, subTablesDataMap, edges, nodes, getTableExistingConfig]); + + // 설정 모달 닫기 및 새로고침 + const handleSettingModalClose = useCallback(() => { + setIsSettingModalOpen(false); + setSettingModalNode(null); + }, []); + + // 시각화 새로고침 (설정 저장 후 호출) + const handleRefreshVisualization = useCallback(() => { + // 강제 새로고침: refreshKey 증가로 useEffect 재실행 + setRefreshKey(prev => prev + 1); + }, []); // 포커스에 따른 노드 스타일링 (그룹 모드에서 화면 클릭 시) const styledNodes = React.useMemo(() => { @@ -1686,14 +1935,47 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId // 화면-테이블 연결선 if (edge.source.startsWith("screen-") && edge.target.startsWith("table-")) { + const sourceId = parseInt(edge.source.replace("screen-", "")); + const isMyConnection = sourceId === focusedScreenId; + + // 필터 연결선 (edge-screen-filter-)은 포커싱 시에만 표시 + const isFilterEdge = edge.id.startsWith("edge-screen-filter-"); + + if (isFilterEdge) { + // 포커스가 없거나 다른 화면 포커스 시 숨김 + if (focusedScreenId === null || !isMyConnection) { + return { + ...edge, + animated: false, + style: { + ...edge.style, + stroke: "transparent", + strokeWidth: 0, + opacity: 0, + }, + }; + } + + // 포커싱된 화면의 필터 연결선은 표시 + return { + ...edge, + animated: true, + style: { + ...edge.style, + stroke: "#3b82f6", + strokeWidth: 2, + strokeDasharray: "5,5", + opacity: 1, + }, + }; + } + + // 메인 테이블 연결선 (edge-screen-table-)은 기존 로직 // 포커스가 없으면 모든 화면-테이블 연결선 정상 표시 if (focusedScreenId === null) { return edge; // 원본 그대로 } - const sourceId = parseInt(edge.source.replace("screen-", "")); - const isMyConnection = sourceId === focusedScreenId; - return { ...edge, animated: isMyConnection, @@ -1707,33 +1989,36 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId }; } - // 메인 테이블 → 서브 테이블 연결선 + // 메인 테이블 → 서브 테이블 연결선 (메인-메인과 동일한 스타일) // 규격: bottom → top 고정 (아래로 문어발처럼 뻗어나감) if (edge.source.startsWith("table-") && edge.target.startsWith("subtable-")) { + // 관계 유형별 색상 결정 + const visualRelationType = (edge.data as any)?.visualRelationType as VisualRelationType || 'join'; + const relationColor = RELATION_COLORS[visualRelationType]; + // 포커스가 없으면 모든 서브 테이블 연결선 흐리게 (기본 상태) if (focusedScreenId === null) { return { ...edge, - sourceHandle: "bottom", // 고정: 메인 테이블 하단에서 나감 - targetHandle: "top", // 고정: 서브 테이블 상단으로 들어감 + sourceHandle: "bottom", + targetHandle: "top", animated: false, style: { ...edge.style, - stroke: "#d1d5db", - strokeWidth: 1, - strokeDasharray: "6,4", - opacity: 0.3, + stroke: relationColor.strokeLight, + strokeWidth: 1.5, + strokeDasharray: "8,4", + opacity: 0.4, }, - labelStyle: { - ...edge.labelStyle, - opacity: 0.3, + markerEnd: { + type: MarkerType.ArrowClosed, + color: relationColor.strokeLight, }, }; } // 엣지 ID에서 화면 ID 추출: edge-main-sub-{screenId}-{mainTable}-{subTable} const idParts = edge.id.split("-"); - // edge-main-sub-1413-sales_order_mng-customer_mng 형식 const edgeScreenId = idParts.length >= 4 ? parseInt(idParts[3]) : null; // 포커스된 화면의 서브 테이블 연결인지 확인 @@ -1748,20 +2033,20 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId return { ...edge, - sourceHandle: "bottom", // 고정 - targetHandle: "top", // 고정 - animated: isActive, // 활성화된 것만 애니메이션 + sourceHandle: "bottom", + targetHandle: "top", + animated: isActive, style: { ...edge.style, - stroke: isActive ? RELATION_COLORS.join.stroke : "#d1d5db", // 상수 사용 - strokeWidth: isActive ? 2.5 : 1, - strokeDasharray: "6,4", // 항상 점선 - opacity: isActive ? 1 : 0.2, - }, - labelStyle: { - ...edge.labelStyle, + stroke: isActive ? relationColor.stroke : relationColor.strokeLight, + strokeWidth: isActive ? 2.5 : 1.5, + strokeDasharray: "8,4", opacity: isActive ? 1 : 0.3, }, + markerEnd: { + type: MarkerType.ArrowClosed, + color: isActive ? relationColor.stroke : relationColor.strokeLight, + }, }; } @@ -1820,7 +2105,11 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId // 메인 테이블 → 메인 테이블 연결선 (서브테이블 구간 통과) // 규격: bottom → bottom_target 고정 (아래쪽 서브테이블 선 구간을 통해 연결) - if (edge.source.startsWith("table-") && edge.target.startsWith("table-") && edge.id.startsWith("edge-main-main-")) { + // edge-main-main-*, edge-join-db-* 모두 동일한 스타일 적용 + const isMainToMainJoin = edge.source.startsWith("table-") && + edge.target.startsWith("table-") && + (edge.id.startsWith("edge-main-main-") || edge.id.startsWith("edge-join-db-")); + if (isMainToMainJoin) { // 관계 유형별 색상 결정 const visualRelationType = (edge.data as any)?.visualRelationType as VisualRelationType || 'join'; const relationColor = RELATION_COLORS[visualRelationType]; @@ -1882,6 +2171,18 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId return [...styledOriginalEdges, ...joinEdges]; }, [edges, nodes, selectedGroup, focusedScreenId, screen, screenSubTableMap, subTablesDataMap, screenTableMap]); + // 그룹의 화면 목록 (데이터 흐름 설정용) - 모든 조건부 return 전에 선언해야 함 + const groupScreensList = React.useMemo(() => { + if (!selectedGroup) return []; + // nodes에서 screen- 으로 시작하는 노드들 추출 + return nodes + .filter(n => n.id.startsWith("screen-")) + .map(n => ({ + screen_id: parseInt(n.id.replace("screen-", "")), + screen_name: (n.data as ScreenNodeData).label || `화면 ${n.id}`, + })); + }, [selectedGroup, nodes]); + // 조건부 렌더링 (모든 훅 선언 후에 위치해야 함) if (!screen && !selectedGroup) { return ( @@ -1912,6 +2213,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onNodeClick={handleNodeClick} + onNodeContextMenu={handleNodeContextMenu} nodeTypes={nodeTypes} minZoom={0.3} maxZoom={1.5} @@ -1921,6 +2223,39 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
+ + {/* 화면 노드 설정 모달 */} + {settingModalNode && settingModalNode.nodeType === "screen" && ( + + )} + + {/* 테이블 노드 설정 모달 */} + {settingModalNode && settingModalNode.nodeType === "table" && ( + + )} ); } diff --git a/frontend/components/screen/ScreenSettingModal.tsx b/frontend/components/screen/ScreenSettingModal.tsx new file mode 100644 index 00000000..4e72a432 --- /dev/null +++ b/frontend/components/screen/ScreenSettingModal.tsx @@ -0,0 +1,1073 @@ +"use client"; + +import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Textarea } from "@/components/ui/textarea"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { + Database, + Link2, + GitBranch, + Columns3, + Eye, + Save, + Plus, + Pencil, + Trash2, + RefreshCw, + Loader2, + Check, + ChevronsUpDown, + ExternalLink, + Table2, + ArrowRight, + Settings2, +} from "lucide-react"; +import { + getDataFlows, + createDataFlow, + updateDataFlow, + deleteDataFlow, + DataFlow, +} from "@/lib/api/screenGroup"; +import { tableManagementApi, ColumnTypeInfo, TableInfo } from "@/lib/api/tableManagement"; + +// ============================================================ +// 타입 정의 +// ============================================================ + +interface FilterTableInfo { + tableName: string; + tableLabel?: string; + filterColumns?: string[]; + joinColumnRefs?: Array<{ + column: string; + refTable: string; + refTableLabel?: string; + refColumn: string; + }>; +} + +interface FieldMappingInfo { + targetField: string; + sourceField: string; + sourceTable?: string; + sourceDisplayName?: string; + componentType?: string; +} + +interface ScreenSettingModalProps { + isOpen: boolean; + onClose: () => void; + screenId: number; + screenName: string; + groupId?: number; + mainTable?: string; + mainTableLabel?: string; + filterTables?: FilterTableInfo[]; + fieldMappings?: FieldMappingInfo[]; + componentCount?: number; + onSaveSuccess?: () => void; +} + +// 검색 가능한 Select 컴포넌트 +interface SearchableSelectProps { + value: string; + onValueChange: (value: string) => void; + options: Array<{ value: string; label: string; description?: string }>; + placeholder?: string; + disabled?: boolean; + className?: string; +} + +function SearchableSelect({ + value, + onValueChange, + options, + placeholder = "선택...", + disabled = false, + className, +}: SearchableSelectProps) { + const [open, setOpen] = useState(false); + + const selectedOption = options.find((opt) => opt.value === value); + + return ( + + + + + + + + + + 결과 없음 + + + {options.map((option) => ( + { + onValueChange(option.value); + setOpen(false); + }} + className="text-xs" + > + +
+ {option.label} + {option.description && ( + + {option.description} + + )} +
+
+ ))} +
+
+
+
+
+ ); +} + +// ============================================================ +// 메인 모달 컴포넌트 +// ============================================================ + +export function ScreenSettingModal({ + isOpen, + onClose, + screenId, + screenName, + groupId, + mainTable, + mainTableLabel, + filterTables = [], + fieldMappings = [], + componentCount = 0, + onSaveSuccess, +}: ScreenSettingModalProps) { + const [activeTab, setActiveTab] = useState("overview"); + const [loading, setLoading] = useState(false); + const [dataFlows, setDataFlows] = useState([]); + + // 데이터 로드 + const loadData = useCallback(async () => { + if (!screenId) return; + + setLoading(true); + try { + // 데이터 흐름 로드 + const flowsResponse = await getDataFlows(screenId); + if (flowsResponse.success && flowsResponse.data) { + setDataFlows(flowsResponse.data); + } + } catch (error) { + console.error("데이터 로드 실패:", error); + } finally { + setLoading(false); + } + }, [screenId]); + + useEffect(() => { + if (isOpen && screenId) { + loadData(); + } + }, [isOpen, screenId, loadData]); + + // 새로고침 + const handleRefresh = () => { + loadData(); + toast.success("새로고침 완료"); + }; + + return ( + + + + + + 화면 설정: {screenName} + + + 화면의 필드 매핑, 테이블 연결, 데이터 흐름을 확인하고 설정합니다. + + + + +
+ + + + 화면 개요 + + + + 필드 매핑 + + + + 데이터 흐름 + + + + 화면 프리뷰 + + + +
+ + {/* 탭 1: 화면 개요 */} + + + + + {/* 탭 2: 필드 매핑 */} + + + + + {/* 탭 3: 데이터 흐름 */} + + + + + {/* 탭 4: 화면 프리뷰 */} + + + +
+
+
+ ); +} + +// ============================================================ +// 탭 1: 화면 개요 +// ============================================================ + +interface OverviewTabProps { + screenId: number; + screenName: string; + mainTable?: string; + mainTableLabel?: string; + filterTables: FilterTableInfo[]; + fieldMappings: FieldMappingInfo[]; + componentCount: number; + dataFlows: DataFlow[]; + loading: boolean; +} + +function OverviewTab({ + screenId, + screenName, + mainTable, + mainTableLabel, + filterTables, + fieldMappings, + componentCount, + dataFlows, + loading, +}: OverviewTabProps) { + // 통계 계산 + const stats = useMemo(() => { + const totalJoins = filterTables.reduce( + (sum, ft) => sum + (ft.joinColumnRefs?.length || 0), + 0 + ); + const totalFilters = filterTables.reduce( + (sum, ft) => sum + (ft.filterColumns?.length || 0), + 0 + ); + + return { + tableCount: 1 + filterTables.length, // 메인 + 필터 + fieldCount: fieldMappings.length, + joinCount: totalJoins, + filterCount: totalFilters, + flowCount: dataFlows.length, + }; + }, [filterTables, fieldMappings, dataFlows]); + + return ( +
+ {/* 기본 정보 카드 */} +
+
+
{stats.tableCount}
+
연결된 테이블
+
+
+
{stats.fieldCount}
+
필드 매핑
+
+
+
{stats.joinCount}
+
조인 설정
+
+
+
{stats.filterCount}
+
필터 컬럼
+
+
+
{stats.flowCount}
+
데이터 흐름
+
+
+ + {/* 메인 테이블 */} +
+

+ + 메인 테이블 +

+ {mainTable ? ( +
+ +
+
{mainTableLabel || mainTable}
+ {mainTableLabel && mainTable !== mainTableLabel && ( +
{mainTable}
+ )} +
+ + 메인 + +
+ ) : ( +
+ 메인 테이블이 설정되지 않았습니다. +
+ )} +
+ + {/* 필터 테이블 */} +
+

+ + 필터 테이블 ({filterTables.length}개) +

+ {filterTables.length > 0 ? ( +
+ {filterTables.map((ft, idx) => ( +
+
+ +
+
{ft.tableLabel || ft.tableName}
+ {ft.tableLabel && ft.tableName !== ft.tableLabel && ( +
{ft.tableName}
+ )} +
+ + 필터 + +
+ + {/* 조인 정보 */} + {ft.joinColumnRefs && ft.joinColumnRefs.length > 0 && ( +
+
조인 설정:
+ {ft.joinColumnRefs.map((join, jIdx) => ( +
+ + {join.column} + + + + {join.refTableLabel || join.refTable}.{join.refColumn} + +
+ ))} +
+ )} + + {/* 필터 컬럼 */} + {ft.filterColumns && ft.filterColumns.length > 0 && ( +
+
필터 컬럼:
+
+ {ft.filterColumns.map((col, cIdx) => ( + + {col} + + ))} +
+
+ )} +
+ ))} +
+ ) : ( +
+ 필터 테이블이 없습니다. +
+ )} +
+ + {/* 데이터 흐름 요약 */} +
+

+ + 데이터 흐름 ({dataFlows.length}개) +

+ {dataFlows.length > 0 ? ( +
+ {dataFlows.slice(0, 3).map((flow) => ( +
+ + {flow.flow_type} + + {flow.description || "설명 없음"} + + 화면 {flow.target_screen_id} +
+ ))} + {dataFlows.length > 3 && ( +
+ +{dataFlows.length - 3}개 더 있음 +
+ )} +
+ ) : ( +
+ 설정된 데이터 흐름이 없습니다. +
+ )} +
+
+ ); +} + +// ============================================================ +// 탭 2: 필드 매핑 +// ============================================================ + +interface FieldMappingTabProps { + screenId: number; + mainTable?: string; + fieldMappings: FieldMappingInfo[]; + loading: boolean; +} + +function FieldMappingTab({ + screenId, + mainTable, + fieldMappings, + loading, +}: FieldMappingTabProps) { + // 컴포넌트 타입별 그룹핑 + const groupedMappings = useMemo(() => { + const grouped: Record = {}; + + fieldMappings.forEach((mapping) => { + const type = mapping.componentType || "기타"; + if (!grouped[type]) { + grouped[type] = []; + } + grouped[type].push(mapping); + }); + + return grouped; + }, [fieldMappings]); + + const componentTypes = Object.keys(groupedMappings); + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+

필드-컬럼 매핑 현황

+

+ 화면 필드가 어떤 테이블 컬럼과 연결되어 있는지 확인합니다. +

+
+ + 총 {fieldMappings.length}개 필드 + +
+ + {fieldMappings.length === 0 ? ( +
+ +

설정된 필드 매핑이 없습니다.

+
+ ) : ( +
+ + + + # + 필드명 + 테이블 + 컬럼 + 컴포넌트 타입 + + + + {fieldMappings.map((mapping, idx) => ( + + + {idx + 1} + + + {mapping.targetField} + + + + {mapping.sourceTable || mainTable || "-"} + + + + + {mapping.sourceField} + + + + {mapping.componentType || "-"} + + + ))} + +
+
+ )} + + {/* 컴포넌트 타입별 요약 */} + {componentTypes.length > 0 && ( +
+

컴포넌트 타입별 분류

+
+ {componentTypes.map((type) => ( + + {type} + + {groupedMappings[type].length} + + + ))} +
+
+ )} +
+ ); +} + +// ============================================================ +// 탭 3: 데이터 흐름 +// ============================================================ + +interface DataFlowTabProps { + screenId: number; + groupId?: number; + dataFlows: DataFlow[]; + loading: boolean; + onReload: () => void; + onSaveSuccess?: () => void; +} + +function DataFlowTab({ + screenId, + groupId, + dataFlows, + loading, + onReload, + onSaveSuccess, +}: DataFlowTabProps) { + const [isEditing, setIsEditing] = useState(false); + const [editItem, setEditItem] = useState(null); + const [formData, setFormData] = useState({ + target_screen_id: "", + action_type: "navigate", + data_mapping: "", + flow_type: "forward", + description: "", + is_active: "Y", + }); + + // 폼 초기화 + const resetForm = () => { + setFormData({ + target_screen_id: "", + action_type: "navigate", + data_mapping: "", + flow_type: "forward", + description: "", + is_active: "Y", + }); + setEditItem(null); + setIsEditing(false); + }; + + // 수정 모드 + const handleEdit = (item: DataFlow) => { + setEditItem(item); + setFormData({ + target_screen_id: String(item.target_screen_id), + action_type: item.action_type, + data_mapping: item.data_mapping || "", + flow_type: item.flow_type, + description: item.description || "", + is_active: item.is_active, + }); + setIsEditing(true); + }; + + // 저장 + const handleSave = async () => { + if (!formData.target_screen_id) { + toast.error("대상 화면을 선택해주세요."); + return; + } + + try { + const payload = { + source_screen_id: screenId, + target_screen_id: parseInt(formData.target_screen_id), + action_type: formData.action_type, + data_mapping: formData.data_mapping || null, + flow_type: formData.flow_type, + description: formData.description || null, + is_active: formData.is_active, + }; + + let response; + if (editItem) { + response = await updateDataFlow(editItem.id, payload); + } else { + response = await createDataFlow(payload); + } + + if (response.success) { + toast.success(editItem ? "데이터 흐름이 수정되었습니다." : "데이터 흐름이 추가되었습니다."); + resetForm(); + onReload(); + onSaveSuccess?.(); + } else { + toast.error(response.message || "저장에 실패했습니다."); + } + } catch (error) { + console.error("저장 오류:", error); + toast.error("저장 중 오류가 발생했습니다."); + } + }; + + // 삭제 + const handleDelete = async (id: number) => { + if (!confirm("정말로 삭제하시겠습니까?")) return; + + try { + const response = await deleteDataFlow(id); + if (response.success) { + toast.success("데이터 흐름이 삭제되었습니다."); + onReload(); + onSaveSuccess?.(); + } else { + toast.error(response.message || "삭제에 실패했습니다."); + } + } catch (error) { + console.error("삭제 오류:", error); + toast.error("삭제 중 오류가 발생했습니다."); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* 입력 폼 */} +
+
+ {isEditing ? "데이터 흐름 수정" : "새 데이터 흐름 추가"} +
+ +
+
+ + + setFormData({ ...formData, target_screen_id: e.target.value }) + } + placeholder="화면 ID" + className="h-8 text-xs" + /> +
+
+ + setFormData({ ...formData, action_type: v })} + options={[ + { value: "navigate", label: "화면 이동" }, + { value: "modal", label: "모달 열기" }, + { value: "callback", label: "콜백" }, + { value: "refresh", label: "새로고침" }, + ]} + placeholder="액션 선택" + /> +
+
+ + setFormData({ ...formData, flow_type: v })} + options={[ + { value: "forward", label: "전달 (Forward)" }, + { value: "return", label: "반환 (Return)" }, + { value: "broadcast", label: "브로드캐스트" }, + ]} + placeholder="흐름 선택" + /> +
+
+ +
+ +