From f321aaf7aae7345f71e3ac1790da8c5cf2384ce0 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Wed, 14 Jan 2026 14:35:27 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=99=94=EB=A9=B4=20=EB=94=94=EC=9E=90?= =?UTF-8?q?=EC=9D=B4=EB=84=88=20=EB=AA=A8=EB=8B=AC=20=EB=B0=8F=20=EC=A0=9C?= =?UTF-8?q?=EC=96=B4=20=EA=B4=80=EB=A6=AC=20=ED=83=AD=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 화면 설정 모달에 "제어 관리" 탭 추가하여 버튼 제어 설정을 간편하게 관리 - 버튼 액션 설정 기능 구현: 버튼 목록 표시 및 각 버튼의 액션 타입 수정 가능 - 화면 디자이너 모달 통합: 전체화면 Dialog 내부에 ScreenDesigner 임베드 - URL 쿼리 파라미터로 화면 디자이너 자동 열기 기능 추가 - 화면 캔버스 크기 자동 조절 기능 구현: 최소 크기 보장 및 여유 마진 추가 - 필드 추가/제거 기능 개선: 기존 그리드 컬럼 변경 로직과 통합하여 사용자 경험 향상 --- .../src/controllers/screenGroupController.ts | 14 +- docs/화면설정모달_개선_완료_보고서.md | 511 ++++- .../admin/screenMng/screenMngList/page.tsx | 16 + .../dataflow/node-editor/FlowEditor.tsx | 20 +- .../dataflow/node-editor/FlowToolbar.tsx | 22 +- .../screen/InteractiveScreenViewer.tsx | 16 +- .../screen/InteractiveScreenViewerDynamic.tsx | 14 +- .../screen/OptimizedButtonComponent.tsx | 18 +- .../components/screen/ScreenSettingModal.tsx | 2010 +++++++++++++---- .../components/screen/TableSettingModal.tsx | 1431 ++++++------ .../screen/widgets/types/ButtonWidget.tsx | 21 +- .../button-primary/ButtonPrimaryComponent.tsx | 53 +- 12 files changed, 3022 insertions(+), 1124 deletions(-) diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts index 52464ed4..569fe793 100644 --- a/backend-node/src/controllers/screenGroupController.ts +++ b/backend-node/src/controllers/screenGroupController.ts @@ -1010,7 +1010,8 @@ export const getMultipleScreenLayoutSummary = async (req: Request, res: Response properties->'componentConfig'->>'bindField', properties->>'bindField', properties->'componentConfig'->>'field', - properties->>'field' + properties->>'field', + properties->>'columnName' ) as bind_field, -- componentConfig 전체 (JavaScript에서 다양한 패턴 파싱용) properties->'componentConfig' as component_config @@ -1113,6 +1114,17 @@ export const getMultipleScreenLayoutSummary = async (req: Request, res: Response } } + // 4. bindField가 있으면 usedColumns에 추가 (인풋 필드, 텍스트 필드 등) + if (row.bind_field && !usedColumns.includes(row.bind_field)) { + usedColumns.push(row.bind_field); + } + + // 5. componentConfig.field 또는 componentConfig.valueField도 추가 + const configField = componentConfig.field || componentConfig.valueField; + if (configField && typeof configField === 'string' && !usedColumns.includes(configField)) { + usedColumns.push(configField); + } + if (summaryMap[screenId]) { summaryMap[screenId].widgetCounts[componentKind] = (summaryMap[screenId].widgetCounts[componentKind] || 0) + 1; diff --git a/docs/화면설정모달_개선_완료_보고서.md b/docs/화면설정모달_개선_완료_보고서.md index 493c3f37..c69d82e4 100644 --- a/docs/화면설정모달_개선_완료_보고서.md +++ b/docs/화면설정모달_개선_완료_보고서.md @@ -247,21 +247,153 @@ interface TableColumnAccordionProps { - 필터 테이블: 보라색 테마 (`purple`) - `themeColor`, `themeIcon`, `themeBadge` 변수로 동적 스타일 적용 -### 9. 드래그 앤 드롭 컬럼 순서 변경 +### 9. 제어 관리 탭 (신규) -#### 9.1 기능 설명 +#### 9.1 개요 +- 화면 설정 모달에 **"제어 관리"** 탭 추가 +- 화면 디자이너의 버튼 제어 설정을 간편하게 관리 +- 상세 설정은 화면 디자이너 링크 제공 + +#### 9.2 버튼 액션 설정 +- 화면에 배치된 버튼 목록 표시 +- 버튼별 액션 타입, 대상 화면, 플로우 연동 등 수정 가능 +- **편집** 버튼 클릭 시 인라인 편집 모드 활성화 +- **저장** 버튼 클릭 시 `screenApi.saveLayout()` 으로 저장 + +#### 9.3 지원 액션 타입 +| 액션 | 설명 | +|------|------| +| `save` | 저장 | +| `delete` | 삭제 | +| `edit` | 편집 | +| `copy` | 복사 | +| `navigate` | 페이지 이동 | +| `modal` | 모달 열기 | +| `openModalWithData` | 데이터 전달 + 모달 | +| `openRelatedModal` | 연관 데이터 모달 | +| `transferData` | 데이터 전달 | +| `quickInsert` | 즉시 저장 | +| `control` | 제어 흐름 | +| `view_table_history` | 테이블 이력 | +| `excel_download` | 엑셀 다운로드 | +| `excel_upload` | 엑셀 업로드 | + +#### 9.4 모달/네비게이션 화면 선택 +- 액션 타입이 `modal`, `openModalWithData`, `openRelatedModal`인 경우: + - **모달 화면** 선택 가능 + - 검색 가능한 드롭다운 (Combobox) + - **현재 그룹** 화면 우선 표시, **다른 그룹** 화면도 선택 가능 +- 액션 타입이 `navigate`인 경우: + - **이동 화면** 선택 가능 + - 동일하게 검색 가능한 드롭다운 제공 + +#### 9.5 다중 플로우 연동 지원 (신규) +- **한 버튼에 여러 플로우 연동 가능** +- 버튼별 플로우 목록을 세로 리스트 형식으로 표시 +- 각 플로우별 **실행 타이밍** 개별 설정: `before` (버튼 실행 전), `after` (버튼 실행 후) +- 플로우 개별 제거 가능 (X 버튼) +- **플로우 추가** 버튼으로 새 플로우 연동 (검색 가능한 Combobox) +- 화면 디자이너의 `webTypeConfig.dataflowConfig.flowConfigs` 배열로 저장 + +#### 9.6 상시 편집 모드 (개선) +- 기존: "편집" 버튼 클릭 시에만 설정 변경 가능 +- 개선: **상시 편집 가능** (별도 편집 버튼 없음) +- 변경사항이 있으면 "저장" 버튼 활성화 +- 더 빠르고 직관적인 설정 변경 경험 + +#### 9.7 섹션 구분 개선 (UI 개선) +- **버튼 액션 설정** 섹션: + - 파란색 테마 (`border-blue-200 bg-blue-50/30`) + - 헤더: `bg-blue-100/50 text-blue-900` + - 아이콘: MousePointer (파란색) +- **플로우 연동 현황** 섹션: + - 보라색 테마 (`border-purple-200 bg-purple-50/30`) + - 헤더: `bg-purple-100/50 text-purple-900` + - 아이콘: Workflow (보라색) +- 시각적으로 명확한 섹션 구분 + +#### 9.8 플로우 연동 현황 표시 개선 +- 플로우 이름: **일반 텍스트**로 표시 (배지 아님) +- 연동된 버튼: 배지로 표시 +- 미연동 플로우: **보라색 "미연동" 배지**로 표시 +- 플로우 관리 바로가기 버튼 제공 + +#### 9.9 다중 플로우 저장 로직 +```typescript +// 버튼 설정 저장 시 다중 플로우 처리 +if (values.linkedFlows !== undefined) { + if (values.linkedFlows && values.linkedFlows.length > 0) { + comp.webTypeConfig.enableDataflowControl = true; + comp.webTypeConfig.dataflowConfig = { + controlMode: "flow", + // 다중 플로우 저장 + flowConfigs: values.linkedFlows.map((lf: any) => ({ + flowId: lf.id, + flowName: lf.name, + executionTiming: lf.timing || "after", + })), + // 레거시 호환 - 첫 번째 플로우를 단일 flowConfig로도 저장 + flowConfig: { + flowId: values.linkedFlows[0].id, + flowName: values.linkedFlows[0].name, + executionTiming: values.linkedFlows[0].timing || "after", + }, + }; + } else { + // 플로우 연동 해제 (빈 배열) + comp.webTypeConfig.enableDataflowControl = false; + delete comp.webTypeConfig.dataflowConfig; + } +} +``` + +#### 9.10 화면 목록 조회 로직 +```typescript +// 1. 전체 화면 조회 (모든 화면의 ID→이름 맵핑) +const allScreensResponse = await screenApi.getScreens({ size: 1000 }); +const allScreensMap = new Map(); +allScreensResponse.data.forEach((s: any) => { + const sid = Number(s.screenId || s.screen_id || s.id); + const sname = s.screenName || s.screen_name || s.name || `화면 ${sid}`; + if (!isNaN(sid)) allScreensMap.set(sid, sname); +}); + +// 2. 그룹 내 화면 조회 +if (groupId) { + const groupResponse = await getScreenGroup(groupId); + if (groupResponse.success && groupResponse.data?.screens) { + groupScreenIds = groupResponse.data.screens.map((s: any) => + Number(s.screen_id || s.screenId || s.id) + ).filter(id => !isNaN(id)); + } +} + +// 3. 화면 목록 구성 (그룹 내 우선, 전체 포함) +groupScreenIds.forEach(sid => { + screenListResult.push({ id: sid, name: allScreensMap.get(sid), inGroup: true }); +}); +allScreensMap.forEach((name, id) => { + if (!groupScreenIds.includes(id)) { + screenListResult.push({ id, name, inGroup: false }); + } +}); +``` + +### 10. 드래그 앤 드롭 컬럼 순서 변경 + +#### 10.1 기능 설명 - 사용 중인 컬럼(필드)을 드래그하여 순서 변경 가능 - 드래그 중에는 시각적으로만 순서 변경, **드롭 시에만 저장** - 드래그 취소(영역 밖으로 나간 경우) 시 원래 순서로 복원 -#### 9.2 드래그 상태 관리 +#### 10.2 드래그 상태 관리 ```typescript // 드래그 상태 const [draggedIndex, setDraggedIndex] = useState(null); const [localColumnOrder, setLocalColumnOrder] = useState(null); ``` -#### 9.3 드래그 핸들러 +#### 10.3 드래그 핸들러 ```typescript // 드래그 시작: 현재 순서를 로컬 상태로 저장 const handleDragStart = (e: React.DragEvent, index: number) => { @@ -297,12 +429,12 @@ const handleDragEnd = () => { }; ``` -#### 9.4 시각적 피드백 +#### 10.4 시각적 피드백 - 드래그 가능한 컬럼: `cursor-grab active:cursor-grabbing` - 드래그 중인 컬럼: `opacity-50 scale-95` - 드래그 중 실시간 순서 변경 표시 -#### 9.5 저장 로직 (`handleColumnReorder`) +#### 10.5 저장 로직 (`handleColumnReorder`) ```typescript const handleColumnReorder = async (tableType: "main" | "filter", newOrder: string[]) => { const currentLayout = await screenApi.getLayout(screenId); @@ -337,7 +469,7 @@ const handleColumnReorder = async (tableType: "main" | "filter", newOrder: strin }; ``` -#### 9.6 지원 범위 +#### 10.6 지원 범위 - 메인 테이블: `onColumnReorder={(newOrder) => handleColumnReorder("main", newOrder)}` - 필터 테이블: `onColumnReorder={(newOrder) => handleColumnReorder("filter", newOrder)}` - 지원 배열: @@ -346,6 +478,190 @@ const handleColumnReorder = async (tableType: "main" | "filter", newOrder: strin - `componentConfig.usedColumns` - `componentConfig.columns` +### 11. FlowEditor 임베드 (신규) + +#### 11.1 개요 +- 제어 관리 탭에서 **플로우 빠른 생성** 시 전체 FlowEditor를 모달로 임베드 +- 골격 생성이 아닌 **완전한 플로우 생성** 가능 +- 저장 시 자동으로 버튼에 연동 + +#### 11.2 FlowEditor 컴포넌트 수정 +```typescript +interface FlowEditorProps { + initialFlowId?: number | null; + onSaveComplete?: (flowId: number, flowName: string) => void; // 저장 완료 콜백 + embedded?: boolean; // 임베디드 모드 +} +``` + +#### 11.3 FlowToolbar 수정 +- 저장 완료 시 `onSaveComplete` 콜백 호출 +- 기존 postMessage 로직 대체 + +#### 11.4 사용 방법 +1. "새 플로우" 버튼 클릭 +2. 전체화면 모달에서 FlowEditor 열림 +3. 플로우 완전 구성 (테이블, 필드 매핑, 조건 등) +4. 저장 시 자동으로: + - 플로우 생성 + - 버튼에 연동 (버튼에서 시작한 경우) + - 플로우 목록 새로고침 + +### 12. 화면 캔버스 크기 자동 조절 (신규) + +#### 12.1 문제 +- 기존: iframe 크기가 고정되어 화면 내용이 잘림 +- 특히 폼 화면에서 인풋 필드, 저장 버튼 등이 보이지 않음 + +#### 12.2 해결 +- 백엔드: 컴포넌트 최대 좌표 기준으로 `canvasWidth`, `canvasHeight` 계산 +- 프론트엔드: `PreviewTab`에 캔버스 크기 전달, 여유 마진 추가 + +#### 12.3 구현 +```typescript +// 백엔드 (screenGroupController.ts) +const rightEdge = (row.position_x || 0) + (row.width || 100); +const bottomEdge = (row.position_y || 0) + (row.height || 30); +if (rightEdge > summaryMap[screenId].canvasWidth) { + summaryMap[screenId].canvasWidth = rightEdge; +} +if (bottomEdge > summaryMap[screenId].canvasHeight) { + summaryMap[screenId].canvasHeight = bottomEdge; +} + +// 프론트엔드 (ScreenSettingModal.tsx) +const designWidth = Math.max((canvasWidth || 400) + 120, 500); +const designHeight = Math.max((canvasHeight || 400) + 250, 650); +``` + +### 13. 인풋 필드 인식 개선 (신규) + +#### 13.1 문제 +- 폼 화면의 인풋 필드가 "필드"로 인식되지 않음 +- 필드 매핑 0개로 표시 + +#### 13.2 원인 +- 백엔드 SQL 쿼리에서 `columnName` 속성을 추출하지 않음 +- 프론트엔드에서 `bindField`를 필드 카운트에 포함하지 않음 + +#### 13.3 해결 +```sql +-- 백엔드 SQL 수정 +COALESCE( + properties->'componentConfig'->>'bindField', + properties->>'bindField', + properties->'componentConfig'->>'field', + properties->>'field', + properties->>'columnName' -- 추가됨 +) as bind_field, +``` + +```typescript +// 프론트엔드 필드 카운트 수정 +layoutItems.forEach((item) => { + if (item.usedColumns) { + item.usedColumns.forEach((col) => layoutColumnsSet.add(col)); + } + if (item.bindField) { + layoutColumnsSet.add(item.bindField); // 추가됨 + } +}); +``` + +### 14. 폼 화면 필드 추가/제거 (신규) + +#### 14.1 기존 그리드 vs 폼 화면 +- **그리드 화면**: `leftPanel.columns`, `rightPanel.columns` 배열에서 컬럼 추가/제거 +- **폼 화면**: `text-input` 등 컴포넌트 자체를 추가/제거해야 함 + +#### 14.2 구현 +```typescript +// 필드 추가: 새 text-input 컴포넌트 생성 +if (isAddingField && !columnChanged) { + const newFormComponent: LayoutItem = { + id: `comp-${Date.now()}`, + componentType: "text-input", + label: newColumn, + bindField: newColumn, + position_x: newComponentX, + position_y: newComponentY, + width: 300, + height: 30, + // ... + }; + updatedComponents.push(newFormComponent); +} + +// 필드 제거: bindField가 일치하는 컴포넌트 삭제 +if (isRemovingField && !columnChanged) { + updatedComponents = updatedComponents.filter((comp: any) => + comp.bindField?.toLowerCase() !== oldColumn.toLowerCase() + ); +} +``` + +### 15. 화면 디자이너 모달 통합 (신규) + +#### 15.1 개요 +- 기존: "디자이너" 버튼 클릭 시 새 탭/창에서 열림 +- 변경: 전체화면 Dialog 내부에 ScreenDesigner 임베드 + +#### 15.2 장점 +- 화면 설정 모달을 닫지 않고 디자이너 사용 +- 디자이너 닫을 때 자동 새로고침 + +#### 15.3 구현 +```tsx + + + { + setShowDesignerModal(false); + await loadData(); + setIframeKey(prev => prev + 1); + }} + /> + + +``` + +### 16. 그룹 내 화면 전환 셀렉트박스 (신규) + +#### 16.1 개요 +- 그룹에 여러 화면이 있을 때 모달 내에서 화면 전환 가능 +- 모달을 닫지 않고도 다른 화면 설정 가능 + +#### 16.2 구현 +```typescript +// 그룹 내 화면 목록 로드 +const loadGroupScreens = useCallback(async () => { + if (!groupId) return; + const groupRes = await getScreenGroup(groupId); + if (groupRes.success && groupRes.data) { + const screens = groupRes.data.screens || []; + screens.sort((a, b) => (a.display_order || 0) - (b.display_order || 0)); + setGroupScreens(screens); + } +}, [groupId]); + +// 화면 선택 변경 핸들러 +const handleScreenChange = useCallback(async (newScreenId: number) => { + const selectedScreen = groupScreens.find(s => s.screen_id === newScreenId); + if (!selectedScreen) return; + + setCurrentScreenId(newScreenId); + setCurrentScreenName(selectedScreen.screen_name); + setCurrentMainTable(selectedScreen.table_name); + setIframeKey(prev => prev + 1); +}, [groupScreens]); +``` + +#### 16.3 UI +- 그룹 내 화면이 2개 이상: 제목 옆에 셀렉트박스 표시 +- 그룹 내 화면이 1개: 기존처럼 텍스트 표시 +- 화면 역할(screen_role)도 함께 표시 + ## 기술 스택 ### 신규 의존성 @@ -449,13 +765,17 @@ const handleColumnChange = async (fieldLabel: string, oldColumn: string, newColu | 파일 | 변경 내용 | |------|----------| -| `frontend/components/screen/ScreenSettingModal.tsx` | 전체 UI 개선, 줌/드래그 기능, 컬럼 변경/추가/제거 기능, 조인 설정 기능, 필드 매핑 통합, 실시간 반영 | +| `frontend/components/screen/ScreenSettingModal.tsx` | 전체 UI 개선, 줌/드래그 기능, 컬럼 변경/추가/제거 기능, 조인 설정 기능, 필드 매핑 통합, 실시간 반영, 제어 관리 탭 추가, 버튼 액션 설정, 플로우 연동, FlowEditor 임베드, ScreenDesigner 모달 임베드, 그룹 내 화면 전환 셀렉트박스 | | `frontend/components/screen/ScreenRelationFlow.tsx` | `filterKeyMapping`, `joinColumnRefs` 데이터 전달 | +| `frontend/components/dataflow/node-editor/FlowEditor.tsx` | `onSaveComplete` 콜백, `embedded` 모드 props 추가 | +| `frontend/components/dataflow/node-editor/FlowToolbar.tsx` | 저장 완료 시 `onSaveComplete` 콜백 호출 | | `frontend/lib/api/entityJoin.ts` | `companyCodeOverride` 파라미터 추가 | | `frontend/lib/api/screen.ts` | `saveLayout`, `getLayout` API 사용 | | `frontend/lib/api/tableManagement.ts` | `getTableList`, `getColumnList`, `updateColumnSettings` API | | `frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx` | `companyCode` prop 추가 | +| `frontend/lib/registry/components/button/ButtonPrimaryComponent.tsx` | `webTypeConfig.backgroundColor/textColor` 지원 추가 | | `backend-node/src/controllers/entityJoinController.ts` | `companyCodeOverride` 처리 로직 추가 | +| `backend-node/src/controllers/screenGroupController.ts` | `bind_field` 쿼리에 `columnName` 추가, `canvasWidth`/`canvasHeight` 계산 | ## 사용 방법 @@ -512,10 +832,124 @@ const handleColumnChange = async (fieldLabel: string, oldColumn: string, newColu - 미사용 컬럼은 드래그 불가 - 드래그 중에는 저장되지 않고, 드롭 시에만 저장됨 +### 그룹 내 화면 전환 +1. 화면 설정 모달을 열면 제목 옆에 **셀렉트박스** 표시 (그룹 내 화면이 2개 이상일 때) +2. 셀렉트박스 클릭하여 같은 그룹의 다른 화면 선택 +3. 선택 즉시: + - 화면 데이터 자동 리로드 + - 프리뷰 iframe 자동 새로고침 +4. 화면 역할(list, form, modal 등)도 함께 표시 + +**참고:** +- 그룹 내 화면이 1개뿐이면 기존처럼 텍스트로 표시 +- 모달을 닫지 않고도 여러 화면 설정 가능 + +### 플로우 빠른 생성 +1. 제어 관리 탭의 "플로우 연동 현황"에서 **"새 플로우"** 버튼 클릭 +2. 또는 버튼별 플로우 선택에서 **"새 플로우 생성"** 옵션 클릭 +3. 전체화면 모달에서 FlowEditor가 열림 +4. 플로우를 완전하게 구성 (테이블 선택, 필드 매핑, 조건 등) +5. 저장 시 자동으로 해당 버튼에 연동 + +**참고:** +- 버튼에서 생성 시: 해당 버튼에 자동 연동 +- 헤더에서 생성 시: 연동 없이 플로우만 생성 +- 모달을 닫으면 플로우 목록 자동 새로고침 + +### 화면 디자이너 열기 +1. 화면 설정 모달의 제목 옆 **외부 링크 아이콘** 클릭 +2. 전체화면 모달에서 ScreenDesigner가 열림 +3. 컴포넌트 배치, 속성 변경 등 디자인 작업 수행 +4. 모달 닫을 때 자동으로: + - 화면 데이터 리로드 + - 프리뷰 iframe 새로고침 + +--- + +## 향후 개선 계획 (Phase 2) + +### 컨셉: "손쉬운 사용" +> 화면 디자이너에 들어가지 않고도 **자잘한 설정을 빠르게** 처리 +> **화면 테스트 → 설정 수정 → 다시 테스트** 사이클을 최소화 + +### 1순위: 버튼 이름 변경 + 색상 변경 ✅ 완료 +| 항목 | 내용 | +|------|------| +| **필요성** | 오타 수정, 문구 변경, 버튼 색상 변경 시 화면 디자이너 진입 필요 | +| **현재 상태** | ✅ **구현 완료** | +| **구현 내용** | | + +#### 버튼 이름 변경 +- 버튼 이름을 직접 입력 필드에서 수정 가능 +- 실시간 버튼 프리뷰 제공 +- 저장 위치: `componentConfig.text`, `comp.label`, `comp.title` (레거시 호환) + +#### 버튼 색상 변경 +- 배경색 + 글자색 컬러 피커 제공 +- **프리셋 색상 버튼**: 파랑, 초록, 빨강, 회색, 흰색 +- 실시간 버튼 프리뷰에 색상 반영 +- 저장 위치: + - 배경색: `componentConfig.backgroundColor`, `style.backgroundColor` + - 글자색: `componentConfig.textColor`, `style.color`, `style.labelColor` + +### 1순위: 컬럼 라벨(표시명) 변경 +| 항목 | 내용 | +|------|------| +| **필요성** | "거래처코드" → "고객코드" 같은 헤더 변경 | +| **현재 상태** | 컬럼명만 표시, 수정 불가 | +| **구현 방향** | 컬럼 설정 패널에 "표시명" Input 추가 | +| **주의사항** | 라벨만 변경, 실제 DB 컬럼명(`columnName`)은 변경 불가 (company_code 안전) | +| **예상 난이도** | 중간 (1시간) | + +### 2순위: 확인 메시지 설정 ✅ 완료 +| 항목 | 내용 | +|------|------| +| **필요성** | "삭제하시겠습니까?" 같은 확인 다이얼로그 메시지 수정 | +| **현재 상태** | ✅ **구현 완료** | +| **구현 내용** | 저장/삭제 버튼에 확인 메시지 Input 필드 추가, 화면 디자이너와 동일하게 `confirmMessage` 저장 | + +### 추가 요청: 플로우 빠른 생성 ✅ 완료 +| 항목 | 내용 | +|------|------| +| **필요성** | 시스템관리 > 제어관리에 나가지 않고 바로 플로우 생성 | +| **현재 상태** | ✅ **구현 완료** | +| **구현 내용** | FlowEditor를 전체화면 모달로 임베드, 저장 시 자동 버튼 연동 | +| **현재 상태** | 플로우 선택/연동만 가능, 생성 불가 | +| **구현 방안** | | + +#### 방안 A: 모달 내 간이 플로우 생성 +- 장점: 화면 이탈 없음 +- 단점: FlowEditor 축소판 개발 필요 (큰 작업) + +#### 방안 B: iframe으로 FlowEditor 임베드 +- 장점: 기존 FlowEditor 재사용 +- 단점: 화면 공간 부족, 상태 동기화 복잡 + +#### 방안 C: 새 창/탭으로 FlowEditor 열기 + 콜백 +- 장점: 전체 기능 사용 가능, 개발 비용 최소 +- 단점: 화면 전환 필요 + +#### 방안 D: 간이 플로우 템플릿 선택 ⭐ 권장 +- 장점: 빠른 설정, 사용자 친화적 +- 단점: 커스터마이징 제한 + +**템플릿 종류 예시:** +- 데이터 저장 (INSERT) +- 데이터 수정 (UPDATE) +- 이력 저장 (INSERT to 이력 테이블) +- 외부 API 호출 + +### 미포함 항목 (상세 설정 권장) +- 버튼 표시/숨김 +- 버튼 색상 변경 +- 컬럼 너비 조정 +- 필터 라벨 변경 +- 화면 이름/설명 변경 + --- ## 완료일 -2026-01-13 +2026-01-14 ## 변경 이력 - 2026-01-12: 최초 작성 (줌/드래그/클릭, company_code 전달) @@ -533,3 +967,62 @@ const handleColumnChange = async (fieldLabel: string, oldColumn: string, newColu - 2026-01-13: `MainTableAccordion`과 `FilterTableAccordion`을 `TableColumnAccordion`으로 통합 - 2026-01-13: 드래그 앤 드롭 컬럼 순서 변경 기능 구현 - 2026-01-13: 드래그 중에는 로컬 상태만 변경, 드롭 시에만 저장하도록 최적화 +- 2026-01-13: "제어 관리" 탭 신규 추가 (버튼 액션 설정, 플로우 연동) +- 2026-01-13: 버튼별 플로우 연동 및 실행 타이밍 설정 기능 +- 2026-01-13: 모달/네비게이션 화면 선택 시 검색 가능한 Combobox 적용 +- 2026-01-13: 화면 목록 조회 개선 (전체 화면 조회 후 그룹별 분류) +- 2026-01-13: 외부 연동 섹션 제거 (미사용) +- 2026-01-13: 플로우 연동 섹션 추가 (화면에 연동된 전체 플로우 목록) +- 2026-01-13: **다중 플로우 지원** - 한 버튼에 여러 플로우 연동 가능 (`flowConfigs` 배열) +- 2026-01-13: **상시 편집 모드** - "편집" 버튼 제거, 상시 설정 변경 가능 +- 2026-01-13: **섹션 구분 개선** - 버튼 액션(파란색) / 플로우 연동(보라색) 테마 분리 +- 2026-01-13: **플로우 표시 방식 개선** - 플로우명은 텍스트, 미연동은 보라색 배지 +- 2026-01-13: **플로우 타이밍 개별 설정** - 각 플로우별 실행 전/후 설정 가능 +- 2026-01-13: **향후 개선 계획 문서화** - 버튼 이름 변경, 컬럼 라벨 변경, 확인 메시지 설정, 플로우 빠른 생성 +- 2026-01-13: **버튼 이름 변경 기능 구현** - `` → ``, `componentConfig.text` 저장 +- 2026-01-13: **버튼 색상 변경 기능 추가** - 배경색/글자색 컬러 피커, 프리셋 색상 버튼, 실시간 프리뷰 +- 2026-01-13: **버튼 색상 저장 위치 수정** - `webTypeConfig.backgroundColor/textColor`에 저장 (OptimizedButtonComponent와 일치) +- 2026-01-14: **버튼 색상 렌더링 수정** - `ButtonPrimaryComponent.tsx`에서 `webTypeConfig.backgroundColor/textColor` 지원 추가 +- 2026-01-14: **확인 메시지 설정 기능 추가** - 화면 디자이너와 동일하게 `confirmMessage` 필드 사용, Input으로 메시지 설정 +- 2026-01-14: **확인 메시지 save/delete 전용** - `save`/`delete` 액션에서만 확인 메시지 필드 표시, 다른 액션 타입으로 변경 시 confirmMessage 자동 제거 +- 2026-01-14: **플로우 빠른 생성 기능 구현** - 제어 플로우 에디터와 동일한 형식으로 플로우 **골격** 생성 + - 플로우 연동 현황 헤더에 "빠른 생성" 버튼 추가 + - 버튼별 플로우 추가 드롭다운에도 "새 플로우 빠른 생성" 옵션 추가 + - 생성 후 자동 연동 옵션 (선택한 버튼에 자동 연결) + - 테이블 선택 또는 직접 입력, 액션 타입 선택 (INSERT/UPDATE/DELETE) + - **중요**: 빠른 생성은 기본 구조만 생성, 필드 매핑/WHERE 조건은 제어 관리에서 추가 설정 필요 + - "생성만" / "생성 후 편집" 버튼으로 워크플로우 선택 가능 + - 경고 안내 UI 추가 (추가 설정 필요 안내) +- 2026-01-14: **플로우 빠른 생성 → FlowEditor 임베드 방식으로 변경** + - 골격 생성 대신 **전체 FlowEditor를 전체화면 모달로 임베드** + - 플로우 생성 후 자동으로 버튼에 연동 + - `FlowEditor` 컴포넌트에 `onSaveComplete` 콜백과 `embedded` 모드 추가 + - `FlowToolbar`에서 저장 완료 시 콜백 호출 +- 2026-01-14: **인풋 필드 인식 개선** + - 백엔드 `getMultipleScreenLayoutSummary` SQL 쿼리에 `properties->>'columnName'` 추가 + - `bindField`, `componentConfig.field`, `componentConfig.valueField` 를 `usedColumns`에 포함 + - 프론트엔드 `stats` 계산 시 `item.bindField`도 `layoutColumnsSet`에 추가 + - `TableColumnAccordion`에 `usedFields` props 전달하여 필드 배지 정확히 표시 +- 2026-01-14: **화면 캔버스 크기 자동 조절** + - 백엔드에서 `canvasWidth`, `canvasHeight` 계산 (컴포넌트 최대 좌표 기준) + - `PreviewTab`에서 `canvasWidth`, `canvasHeight` props 수신 + - 여유 마진 추가: 가로 +120px, 세로 +250px (패딩, 헤더, 하단 요소 고려) + - 최소 크기 보장 (가로 500px, 세로 650px) +- 2026-01-14: **폼 화면 필드 추가/제거 기능** + - `handleColumnChange`에서 `text-input` 등 폼 컴포넌트 처리 로직 추가 + - 필드 추가 시: 마지막 컴포넌트 아래에 새 `text-input` 컴포넌트 자동 배치 + - 필드 제거 시: `bindField`가 일치하는 컴포넌트 삭제 + - 기존 그리드 컬럼 변경 로직과 통합 +- 2026-01-14: **화면 디자이너 모달 통합** + - 기존: "디자이너" 버튼 클릭 시 새 탭/창에서 열림 + - 변경: **전체화면 Dialog 내부에 ScreenDesigner 임베드** + - `showDesignerModal` 상태로 모달 제어 + - 디자이너 닫을 때 `loadData()` + `setIframeKey()` 호출하여 자동 새로고침 +- 2026-01-14: **그룹 내 화면 전환 기능 (셀렉트박스)** + - `groupId`가 있으면 `getScreenGroup(groupId)`로 그룹 내 화면 목록 조회 + - 그룹 내 화면이 2개 이상일 때 제목 옆에 **셀렉트박스** 표시 + - 화면 선택 시: + - `currentScreenId`, `currentScreenName`, `currentMainTable` 상태 업데이트 + - 레이아웃 데이터 자동 리로드 + - iframe 자동 새로고침 + - 화면 역할(screen_role)도 함께 표시 (예: "거래처 목록 (list)") diff --git a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx index c3947edd..030a9504 100644 --- a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect, useCallback } from "react"; +import { useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { ArrowLeft, Plus, RefreshCw, Search, LayoutGrid, LayoutList } from "lucide-react"; @@ -20,6 +21,7 @@ type Step = "list" | "design" | "template"; type ViewMode = "tree" | "table"; export default function ScreenManagementPage() { + const searchParams = useSearchParams(); const [currentStep, setCurrentStep] = useState("list"); const [selectedScreen, setSelectedScreen] = useState(null); const [selectedGroup, setSelectedGroup] = useState<{ id: number; name: string; company_code?: string } | null>(null); @@ -51,6 +53,20 @@ export default function ScreenManagementPage() { loadScreens(); }, [loadScreens]); + // URL 쿼리 파라미터로 화면 디자이너 자동 열기 + useEffect(() => { + const openDesignerId = searchParams.get("openDesigner"); + if (openDesignerId && screens.length > 0) { + const screenId = parseInt(openDesignerId, 10); + const targetScreen = screens.find((s) => s.screenId === screenId); + if (targetScreen) { + setSelectedScreen(targetScreen); + setCurrentStep("design"); + setStepHistory(["list", "design"]); + } + } + }, [searchParams, screens]); + // 화면 설계 모드일 때는 전체 화면 사용 const isDesignMode = currentStep === "design"; diff --git a/frontend/components/dataflow/node-editor/FlowEditor.tsx b/frontend/components/dataflow/node-editor/FlowEditor.tsx index 3686554f..9c4ad7e8 100644 --- a/frontend/components/dataflow/node-editor/FlowEditor.tsx +++ b/frontend/components/dataflow/node-editor/FlowEditor.tsx @@ -65,6 +65,10 @@ const nodeTypes = { */ interface FlowEditorInnerProps { initialFlowId?: number | null; + /** 임베디드 모드에서 저장 완료 시 호출되는 콜백 */ + onSaveComplete?: (flowId: number, flowName: string) => void; + /** 임베디드 모드 여부 */ + embedded?: boolean; } // 플로우 에디터 툴바 버튼 설정 @@ -87,7 +91,7 @@ const flowToolbarButtons: ToolbarButton[] = [ }, ]; -function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) { +function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: FlowEditorInnerProps) { const reactFlowWrapper = useRef(null); const { screenToFlowPosition, setCenter } = useReactFlow(); @@ -385,7 +389,7 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) { {/* 상단 툴바 */} - + @@ -416,13 +420,21 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) { */ interface FlowEditorProps { initialFlowId?: number | null; + /** 임베디드 모드에서 저장 완료 시 호출되는 콜백 */ + onSaveComplete?: (flowId: number, flowName: string) => void; + /** 임베디드 모드 여부 (헤더 표시 여부 등) */ + embedded?: boolean; } -export function FlowEditor({ initialFlowId }: FlowEditorProps = {}) { +export function FlowEditor({ initialFlowId, onSaveComplete, embedded = false }: FlowEditorProps = {}) { return (
- +
); diff --git a/frontend/components/dataflow/node-editor/FlowToolbar.tsx b/frontend/components/dataflow/node-editor/FlowToolbar.tsx index d837d355..f136d216 100644 --- a/frontend/components/dataflow/node-editor/FlowToolbar.tsx +++ b/frontend/components/dataflow/node-editor/FlowToolbar.tsx @@ -17,9 +17,11 @@ import { useToast } from "@/hooks/use-toast"; interface FlowToolbarProps { validations?: FlowValidation[]; + /** 임베디드 모드에서 저장 완료 시 호출되는 콜백 */ + onSaveComplete?: (flowId: number, flowName: string) => void; } -export function FlowToolbar({ validations = [] }: FlowToolbarProps) { +export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarProps) { const { toast } = useToast(); const { zoomIn, zoomOut, fitView } = useReactFlow(); const { @@ -59,13 +61,27 @@ export function FlowToolbar({ validations = [] }: FlowToolbarProps) { const result = await saveFlow(); if (result.success) { toast({ - title: "✅ 플로우 저장 완료", + title: "저장 완료", description: `${result.message}\nFlow ID: ${result.flowId}`, variant: "default", }); + + // 임베디드 모드에서 저장 완료 콜백 호출 + if (onSaveComplete && result.flowId) { + onSaveComplete(result.flowId, flowName); + } + + // 부모 창이 있으면 postMessage로 알림 (새 창에서 열린 경우) + if (window.opener && result.flowId) { + window.opener.postMessage({ + type: "FLOW_SAVED", + flowId: result.flowId, + flowName: flowName, + }, "*"); + } } else { toast({ - title: "❌ 저장 실패", + title: "저장 실패", description: result.message, variant: "destructive", }); diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index 376f9953..af6c9dbc 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -1909,23 +1909,27 @@ export const InteractiveScreenViewer: React.FC = ( } }; + // 커스텀 색상이 있으면 Tailwind 클래스 대신 직접 스타일 적용 + const hasCustomColors = config?.backgroundColor || config?.textColor; + return applyStyles( - + ); } diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index 4763507e..af4a4542 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -834,12 +834,18 @@ export const InteractiveScreenViewerDynamic: React.FC {label || "버튼"} - + ); }; diff --git a/frontend/components/screen/OptimizedButtonComponent.tsx b/frontend/components/screen/OptimizedButtonComponent.tsx index 3ff1c4e4..913bfef9 100644 --- a/frontend/components/screen/OptimizedButtonComponent.tsx +++ b/frontend/components/screen/OptimizedButtonComponent.tsx @@ -637,24 +637,28 @@ export const OptimizedButtonComponent: React.FC = ({ } } + // 색상이 설정되어 있으면 variant 스타일을 무시하고 직접 스타일 적용 + const hasCustomColors = config?.backgroundColor || config?.textColor; + return (
+
+ + +
{/* 탭 1: 화면 개요 */} {/* 탭 2: 제어 관리 */} - + + + {/* ScreenDesigner 전체 화면 모달 */} + + +
+ { + setShowDesignerModal(false); + // 디자이너에서 저장 후 모달 닫으면 데이터 새로고침 + await loadData(); + // 데이터 로드 완료 후 iframe 갱신 + setIframeKey(prev => prev + 1); + }} + /> +
+
+
+ + {/* TableSettingModal */} + {tableSettingTarget && ( + { + handleRefresh(); + }} + /> + )} + ); } @@ -415,6 +585,7 @@ interface TableColumnAccordionProps { onColumnChange?: (fieldLabel: string, oldColumn: string, newColumn: string) => void; onColumnReorder?: (newOrder: string[]) => void; // 컬럼 순서 변경 콜백 onJoinSettingSaved?: () => void; + usedFields?: Set; // 화면에서 사용 중인 컬럼 목록 // 필터 테이블 전용 props (optional) mainTable?: string; // 메인 테이블명 (필터 테이블에서 필터 연결 정보 표시용) @@ -430,6 +601,7 @@ function TableColumnAccordion({ onColumnChange, onColumnReorder, onJoinSettingSaved, + usedFields = new Set(), mainTable, filterKeyMapping, joinColumnRefs = [], @@ -681,7 +853,9 @@ function TableColumnAccordion({ const joinRef = joinColumnRefs?.find(j => j.column.toLowerCase() === colNameLower); const isJoinKey = !!joinRef; const mapping = columnMappingMap.get(colNameLower); - const isUsed = !!mapping; + // usedFields에서도 확인 (bindField 등에서 가져온 사용 컬럼) + const isUsed = !!mapping || usedFields.has(colNameLower) || + Array.from(usedFields).some(f => f.toLowerCase() === colNameLower); return { isFilterKey, isJoinKey, joinRef, isUsed, mapping }; }; @@ -1295,6 +1469,7 @@ interface OverviewTabProps { layoutItems: LayoutItem[]; // 컴포넌트 컬럼 정보 추가 loading: boolean; onRefresh?: () => void; // 컬럼 변경 후 새로고침 콜백 + onOpenTableSetting?: (tableName: string, tableLabel?: string) => void; // 테이블 설정 모달 열기 } function OverviewTab({ @@ -1309,6 +1484,7 @@ function OverviewTab({ layoutItems, loading, onRefresh, + onOpenTableSetting, }: OverviewTabProps) { const [isSavingColumn, setIsSavingColumn] = useState(false); @@ -1362,7 +1538,7 @@ function OverviewTab({ }); }); - const updatedComponents = currentLayout.components.map((comp: any) => { + let updatedComponents = currentLayout.components.map((comp: any) => { // usedColumns 배열이 있는 컴포넌트에서 oldColumn을 newColumn으로 교체 if (comp.usedColumns && Array.isArray(comp.usedColumns)) { // 필드 추가 @@ -1654,6 +1830,85 @@ function OverviewTab({ return comp; }); + // 폼 화면용 필드 추가/제거 처리 (개별 input 컴포넌트) + if (!columnChanged) { + // 폼 화면 필드 추가: 새 text-input 컴포넌트 생성 + if (isAddingField && newColumn) { + console.log("[handleColumnChange] 폼 화면 필드 추가 시도", { newColumn }); + + // 마지막 컴포넌트 위치 계산 + let maxY = 50; // 기본 시작 위치 + let lastComponentHeight = 30; + currentLayout.components.forEach((comp: any) => { + const compY = comp.position?.y || 0; + const compHeight = comp.size?.height || 30; + if (compY + compHeight > maxY) { + maxY = compY + compHeight; + lastComponentHeight = compHeight; + } + }); + + // 새 컴포넌트 위치: 마지막 컴포넌트 아래 + 간격 + const newY = maxY + 10; + const newComponentId = `comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + + // 새 text-input 컴포넌트 생성 + const newComponent = { + id: newComponentId, + type: "component", + label: newColumn, + columnName: newColumn, + bindField: newColumn, + widgetType: "text-input", + componentType: "text-input", + position: { x: 20, y: newY, z: 1 }, + size: { width: 300, height: 30 }, + gridColumns: 4, + componentConfig: { + type: "text-input", + webType: "text-input", + placeholder: `${newColumn}을(를) 입력하세요`, + }, + webTypeConfig: {}, + style: { + labelDisplay: true, + labelFontSize: "14px", + labelColor: "#212121", + width: "300px", + height: "30px", + }, + }; + + updatedComponents = [...updatedComponents, newComponent]; + columnChanged = true; + console.log("[handleColumnChange] 폼 화면 필드 추가 완료", { newComponentId, newY }); + } + + // 폼 화면 필드 제거: bindField가 일치하는 컴포넌트 삭제 + if (isRemovingField && oldColumn) { + console.log("[handleColumnChange] 폼 화면 필드 제거 시도", { oldColumn }); + + const beforeCount = updatedComponents.length; + updatedComponents = updatedComponents.filter((comp: any) => { + // bindField, columnName, 또는 properties.columnName으로 매칭 + const compBindField = comp.bindField || comp.columnName || comp.properties?.columnName; + if (compBindField?.toLowerCase() === oldColumn.toLowerCase()) { + console.log("[handleColumnChange] 폼 컴포넌트 제거", { compId: comp.id, compBindField }); + return false; // 제거 + } + return true; // 유지 + }); + + if (beforeCount > updatedComponents.length) { + columnChanged = true; + console.log("[handleColumnChange] 폼 화면 필드 제거 완료", { + beforeCount, + afterCount: updatedComponents.length + }); + } + } + } + if (!columnChanged) { toast.warning("변경할 컬럼을 찾을 수 없습니다."); console.warn("[handleColumnChange] 변경할 컬럼 없음", { oldColumn, newColumn }); @@ -1887,12 +2142,16 @@ function OverviewTab({ 0 ); - // layoutItems에서 사용하는 컬럼 수 계산 + // layoutItems에서 사용하는 컬럼 수 계산 (usedColumns + bindField) const layoutColumnsSet = new Set(); layoutItems.forEach((item) => { if (item.usedColumns) { item.usedColumns.forEach((col) => layoutColumnsSet.add(col)); } + // bindField도 포함 (인풋 필드 등) + if (item.bindField) { + layoutColumnsSet.add(item.bindField); + } }); const layoutColumnCount = layoutColumnsSet.size; @@ -1902,6 +2161,7 @@ function OverviewTab({ joinCount: totalJoins, filterCount: totalFilters, flowCount: dataFlows.length, + usedFields: layoutColumnsSet, // 사용 중인 컬럼 Set }; }, [filterTables, fieldMappings, dataFlows, layoutItems]); @@ -1933,27 +2193,50 @@ function OverviewTab({ {/* 메인 테이블 (아코디언 형식) */}
-

- - 메인 테이블 -

+
+

+ + 메인 테이블 +

+ {mainTable && ( + + )} +
{mainTable ? ( a.y - b.y) // 화면 순서대로 정렬 - .flatMap((item, idx) => - (item.usedColumns || []).map(col => ({ + .flatMap((item, idx) => { + const cols: string[] = []; + // usedColumns에서 가져오기 + if (item.usedColumns) { + cols.push(...item.usedColumns); + } + // bindField도 포함 + if (item.bindField && !cols.includes(item.bindField)) { + cols.push(item.bindField); + } + return cols.map(col => ({ columnName: col, - fieldLabel: col, // 컬럼명 자체를 식별자로 사용 (UI에서 columnLabel 표시) - order: idx * 100 + (item.usedColumns?.indexOf(col) || 0), // 순서 유지 - })) - ) + fieldLabel: col, + order: idx * 100 + cols.indexOf(col), + })); + }) // 중복 제거 (첫 번째 매핑만 유지) .filter((mapping, idx, arr) => arr.findIndex(m => m.columnName.toLowerCase() === mapping.columnName.toLowerCase()) === idx @@ -2762,12 +3045,28 @@ interface ButtonControlInfo { targetTable?: string; operations?: string[]; confirmMessage?: string; + confirmationEnabled?: boolean; + // 버튼 스타일 + backgroundColor?: string; + textColor?: string; + // 모달/네비게이션 관련 + modalScreenId?: number; + navigateScreenId?: number; + // 데이터 흐름 제어 hasDataflowControl?: boolean; dataflowControlMode?: string; + flowTiming?: "before" | "after"; linkedExternalCall?: { id: number; name: string; }; + // 다중 플로우 지원 + linkedFlows?: { + id: number; + name: string; + timing?: "before" | "after"; + }[]; + // 레거시 호환 (단일 플로우) linkedFlow?: { id: number; name: string; @@ -2776,6 +3075,7 @@ interface ButtonControlInfo { interface ControlManagementTabProps { screenId: number; + groupId?: number; layoutItems: LayoutItem[]; loading: boolean; onRefresh: () => void; @@ -2783,20 +3083,94 @@ interface ControlManagementTabProps { function ControlManagementTab({ screenId, + groupId, layoutItems, loading: parentLoading, onRefresh, }: ControlManagementTabProps) { const [buttonControls, setButtonControls] = useState([]); - const [externalCalls, setExternalCalls] = useState([]); - const [flows, setFlows] = useState([]); + const [flows, setFlows] = useState([]); const [loading, setLoading] = useState(false); const [expandedButton, setExpandedButton] = useState(null); const [editingButton, setEditingButton] = useState(null); const [editedValues, setEditedValues] = useState>({}); - // 테이블 목록 조회 - const [tableList, setTableList] = useState([]); + // 화면 목록 조회 (inGroup: 같은 그룹 내 화면인지) + const [screenList, setScreenList] = useState<{ id: number; name: string; inGroup?: boolean }[]>([]); + // 화면 검색 팝오버 상태 + const [openModalScreenSearch, setOpenModalScreenSearch] = useState(null); + const [openNavigateScreenSearch, setOpenNavigateScreenSearch] = useState(null); + const [openFlowSearch, setOpenFlowSearch] = useState(null); + + // 플로우 에디터 모달 상태 (전체 화면 임베드) + const [showFlowEditorModal, setShowFlowEditorModal] = useState(false); + const [flowEditorTargetButtonId, setFlowEditorTargetButtonId] = useState(null); + + // 플로우 빠른 생성 다이얼로그 상태 (골격 생성용 - 레거시) + const [showQuickFlowDialog, setShowQuickFlowDialog] = useState(false); + const [quickFlowData, setQuickFlowData] = useState({ + name: "", + description: "", + tableName: "", + tableLabel: "", + actionType: "update" as "insert" | "update" | "delete", + autoLink: true, + targetButtonId: null as string | null, + }); + const [isCreatingFlow, setIsCreatingFlow] = useState(false); + + // 대기 중인 버튼 ID (새 창에서 플로우 생성 후 연동할 버튼) + const [pendingLinkButtonId, setPendingLinkButtonId] = useState(null); + + // postMessage 이벤트 리스너 (새 창에서 플로우 저장 완료 시) + useEffect(() => { + const handleMessage = async (event: MessageEvent) => { + if (event.data?.type === "FLOW_SAVED") { + const { flowId, flowName } = event.data; + + // 플로우 목록 새로고침 + const flowList = await getNodeFlows(); + setFlows(flowList); + + // 대기 중인 버튼에 연동 + if (pendingLinkButtonId) { + const newFlow = { + id: flowId, + name: flowName, + timing: "after" as const, + }; + + setEditedValues(prev => ({ + ...prev, + [pendingLinkButtonId]: { + ...prev[pendingLinkButtonId], + linkedFlows: [ + ...(prev[pendingLinkButtonId]?.linkedFlows || + buttonControls.find(b => b.id === pendingLinkButtonId)?.linkedFlows || []), + newFlow, + ], + }, + })); + + toast.success(`플로우 "${flowName}"이(가) 버튼에 연동되었습니다`); + setPendingLinkButtonId(null); + } else { + toast.success(`플로우 "${flowName}"이(가) 생성되었습니다`); + } + } + }; + + window.addEventListener("message", handleMessage); + return () => window.removeEventListener("message", handleMessage); + }, [pendingLinkButtonId, buttonControls]); + + // 제어 관리 페이지를 새 창으로 열기 + const openFlowEditorInNewWindow = (buttonId?: string) => { + if (buttonId) { + setPendingLinkButtonId(buttonId); + } + window.open("/admin/systemMng/dataflow", "_blank", "width=1400,height=900"); + }; // 데이터 로드 const loadData = useCallback(async () => { @@ -2804,28 +3178,31 @@ function ControlManagementTab({ try { // 1. 화면 레이아웃에서 버튼 정보 추출 const layoutResponse = await screenApi.getLayout(screenId); - console.log("[제어관리] 레이아웃 응답:", layoutResponse); if (layoutResponse?.components) { const buttons: ButtonControlInfo[] = []; - // 컴포넌트에서 버튼 추출 (다양한 필드 확인) + // 컴포넌트에서 버튼 추출 (화면 디자이너 구조 기준) const extractButtons = (components: any[], depth = 0) => { for (const comp of components) { - // 버튼 컴포넌트 필터링 (다양한 조건 확인) + const config = comp.componentConfig || {}; + + // 버튼 컴포넌트 필터링 (화면 디자이너 저장 구조 기준) + // 1. 새 시스템: type="component" && widgetType="button" + // 2. 새 시스템: componentConfig.webType="button" + // 3. 레거시: type="button" const isButton = + comp.widgetType === "button" || comp.webType === "button" || - comp.componentType === "button" || comp.type === "button" || - comp.componentKind?.includes("button") || - comp.widgetType === "button"; + config.webType === "button" || + comp.componentType?.includes("button") || + comp.componentKind?.includes("button"); if (isButton) { - const config = comp.componentConfig || {}; const webTypeConfig = comp.webTypeConfig || {}; const action = config.action || {}; - - console.log("[제어관리] 버튼 발견:", comp); + const style = comp.style || {}; buttons.push({ id: comp.id || comp.componentId || `btn-${buttons.length}`, @@ -2833,10 +3210,30 @@ function ControlManagementTab({ actionType: typeof action === "string" ? action : (action.type || "custom"), targetTable: config.tableName || webTypeConfig.tableName || comp.tableName, operations: action.operations || [], - confirmMessage: action.confirmMessage || config.confirmMessage, + confirmMessage: action.confirmationMessage || action.confirmMessage || config.confirmMessage, + confirmationEnabled: action.confirmationEnabled ?? (!!action.confirmationMessage || !!action.confirmMessage), + // 버튼 스타일 (webTypeConfig 우선) + backgroundColor: webTypeConfig.backgroundColor || config.backgroundColor || style.backgroundColor, + textColor: webTypeConfig.textColor || config.textColor || style.color || style.labelColor, + // 모달/네비게이션 관련 (화면 디자이너는 targetScreenId 사용) + modalScreenId: action.targetScreenId || action.modalScreenId, + navigateScreenId: action.navigateScreenId || action.targetScreenId, + // 데이터 흐름 제어 hasDataflowControl: webTypeConfig.enableDataflowControl, dataflowControlMode: webTypeConfig.dataflowConfig?.controlMode, + flowTiming: webTypeConfig.dataflowConfig?.flowConfig?.executionTiming, linkedExternalCall: undefined, // TODO: 연결 정보 조회 + // 다중 플로우 지원 (flowConfigs 배열 또는 단일 flowConfig) + linkedFlows: webTypeConfig.dataflowConfig?.flowConfigs?.map((fc: any) => ({ + id: fc.flowId, + name: fc.flowName, + timing: fc.executionTiming || "after", + })) || (webTypeConfig.dataflowConfig?.flowConfig ? [{ + id: webTypeConfig.dataflowConfig.flowConfig.flowId, + name: webTypeConfig.dataflowConfig.flowConfig.flowName, + timing: webTypeConfig.dataflowConfig.flowConfig.executionTiming || "after", + }] : []), + // 레거시 호환 (단일 플로우) linkedFlow: webTypeConfig.dataflowConfig?.flowConfig ? { id: webTypeConfig.dataflowConfig.flowConfig.flowId, name: webTypeConfig.dataflowConfig.flowConfig.flowName, @@ -2860,39 +3257,222 @@ function ControlManagementTab({ }; extractButtons(layoutResponse.components); - console.log("[제어관리] 추출된 버튼:", buttons); setButtonControls(buttons); } - // 2. 외부 호출 목록 조회 - const externalResponse = await ExternalCallConfigAPI.getConfigs({ is_active: "Y" }); - if (externalResponse.success && externalResponse.data) { - setExternalCalls(externalResponse.data); + // 2. 플로우 목록 조회 (버튼 연동용) - node_flows 테이블에서 가져옴 + try { + const flowList = await getNodeFlows(); + console.log("플로우 목록 응답:", flowList); + setFlows(flowList); + } catch (flowError) { + console.error("플로우 목록 조회 실패:", flowError); } - // 3. 플로우 목록 조회 - const flowResponse = await getFlowDefinitions({ isActive: true }); - if (flowResponse.success && flowResponse.data) { - setFlows(flowResponse.data); + // 3. 화면 목록 조회 (모달/네비게이션용) + // 먼저 전체 화면 목록 가져오기 (기존 연결된 화면이 다른 그룹에 있을 수 있음) + // 모든 화면 데이터를 가져오기 위해 최대 크기로 조회 + const allScreensResponse = await screenApi.getScreens({ size: 1000 }); + const allScreensMap = new Map(); + if (allScreensResponse.data && allScreensResponse.data.length > 0) { + allScreensResponse.data.forEach((s: any) => { + // ScreenDefinition 타입: screenId, screenName 필드 사용 + const sid = Number(s.screenId || s.screen_id || s.id); + const sname = s.screenName || s.screen_name || s.name || `화면 ${sid}`; + if (!isNaN(sid)) { + allScreensMap.set(sid, sname); + } + }); } - // 4. 테이블 목록 조회 - const tableResponse = await tableManagementApi.getTableList(); - if (tableResponse.success && tableResponse.data) { - setTableList(tableResponse.data); + // 그룹 내 화면 목록 + let groupScreenIds: number[] = []; + if (groupId) { + const groupResponse = await getScreenGroup(groupId); + if (groupResponse.success && groupResponse.data?.screens) { + // API 응답 필드명: screen_id, screen_name (snake_case) - 문자열일 수 있으므로 Number()로 변환 + groupScreenIds = groupResponse.data.screens.map((s: any) => Number(s.screen_id || s.screenId || s.id)).filter(id => !isNaN(id)); + } } + + // 그룹 내 화면 우선, 전체 화면도 포함 + const screenListResult: { id: number; name: string; inGroup: boolean }[] = []; + + // 그룹 내 화면 먼저 추가 (숫자로 변환된 ID로 Map에서 조회) + groupScreenIds.forEach(sid => { + const name = allScreensMap.get(sid) || `화면 ${sid}`; + screenListResult.push({ id: sid, name, inGroup: true }); + allScreensMap.delete(sid); // 중복 제거 + }); + + // 나머지 전체 화면 추가 (다른 그룹에 있는 화면도 선택 가능하게) + allScreensMap.forEach((name, id) => { + screenListResult.push({ id, name, inGroup: false }); + }); + + setScreenList(screenListResult); } catch (error) { console.error("제어 관리 데이터 로드 실패:", error); toast.error("데이터 로드 실패"); } finally { setLoading(false); } - }, [screenId]); + }, [screenId, groupId]); useEffect(() => { loadData(); }, [loadData]); + // 플로우 빠른 생성 함수 + const handleQuickCreateFlow = async () => { + if (!quickFlowData.name.trim()) { + toast.error("플로우 이름을 입력해주세요"); + return; + } + if (!quickFlowData.tableName) { + toast.error("테이블을 선택해주세요"); + return; + } + + setIsCreatingFlow(true); + try { + // 제어 플로우 에디터(FlowEditor.tsx onDrop)와 동일한 형식으로 flowData 생성 + const timestamp = Date.now(); + const sourceNodeId = `tableSource_${timestamp}`; + const actionNodeId = `${quickFlowData.actionType}Action_${timestamp}`; + + // 액션 타입별 노드 타입 결정 + let actionNodeType: string; + + switch (quickFlowData.actionType) { + case "insert": + actionNodeType = "insertAction"; + break; + case "update": + actionNodeType = "updateAction"; + break; + case "delete": + actionNodeType = "deleteAction"; + break; + default: + actionNodeType = "updateAction"; + } + + // 액션 노드 기본 데이터 (FlowEditor.tsx onDrop 패턴과 동일) + const actionNodeData: any = { + displayName: quickFlowData.actionType === "insert" ? "데이터 추가" + : quickFlowData.actionType === "update" ? "데이터 수정" + : "데이터 삭제", + // 🔥 FlowEditor.tsx와 동일한 기본값 + targetType: "internal", + targetTable: quickFlowData.tableName, + targetTableLabel: quickFlowData.tableLabel || quickFlowData.tableName, + fieldMappings: [], + options: {}, + }; + + // update/delete는 whereConditions 추가 + if (quickFlowData.actionType === "update" || quickFlowData.actionType === "delete") { + actionNodeData.whereConditions = []; + } + + // delete는 fieldMappings 제거 (삭제에는 필드 매핑 불필요) + if (quickFlowData.actionType === "delete") { + delete actionNodeData.fieldMappings; + } + + const flowData = { + nodes: [ + { + id: sourceNodeId, + type: "tableSource", + position: { x: 100, y: 150 }, + data: { + // 🔥 FlowEditor.tsx와 동일한 기본값 (TableSourceProperties에서 테이블 선택 시 설정됨) + displayName: quickFlowData.tableLabel || quickFlowData.tableName || "테이블 소스", + tableName: quickFlowData.tableName, + fields: [], + // dataSourceType은 TableSourceProperties에서 기본값 "context-data" 사용 + }, + }, + { + id: actionNodeId, + type: actionNodeType, + position: { x: 450, y: 150 }, + data: actionNodeData, + }, + ], + edges: [ + { + id: `edge_${timestamp}`, + source: sourceNodeId, + target: actionNodeId, + sourceHandle: null, + targetHandle: null, + }, + ], + }; + + // 플로우 생성 API 호출 + const actionLabel = quickFlowData.actionType === "insert" ? "데이터 추가" + : quickFlowData.actionType === "update" ? "데이터 수정" + : "데이터 삭제"; + + const result = await createNodeFlow({ + flowName: quickFlowData.name, + flowDescription: quickFlowData.description || `${quickFlowData.tableLabel || quickFlowData.tableName} ${actionLabel} 플로우`, + flowData: JSON.stringify(flowData), + }); + + toast.success(`플로우 "${quickFlowData.name}" 생성 완료`); + + // 자동 연동 옵션이 켜져 있고 대상 버튼이 있으면 연동 + if (quickFlowData.autoLink && quickFlowData.targetButtonId) { + const newFlow = { + id: result.flowId, + name: quickFlowData.name, + timing: "after" as const, + }; + + // 해당 버튼의 linkedFlows에 추가 + setEditedValues(prev => ({ + ...prev, + [quickFlowData.targetButtonId!]: { + ...prev[quickFlowData.targetButtonId!], + linkedFlows: [ + ...(prev[quickFlowData.targetButtonId!]?.linkedFlows || + buttonControls.find(b => b.id === quickFlowData.targetButtonId)?.linkedFlows || []), + newFlow, + ], + }, + })); + + toast.success(`버튼에 플로우 자동 연동 완료`); + } + + // 플로우 목록 새로고침 + const flowList = await getNodeFlows(); + setFlows(flowList); + + // 다이얼로그 닫기 및 상태 초기화 + setShowQuickFlowDialog(false); + setQuickFlowData({ + name: "", + description: "", + tableName: "", + tableLabel: "", + actionType: "update", + autoLink: true, + targetButtonId: null, + }); + } catch (error) { + console.error("플로우 생성 실패:", error); + toast.error("플로우 생성 실패"); + } finally { + setIsCreatingFlow(false); + } + }; + // 버튼 설정 저장 const handleSaveButton = async (buttonId: string) => { const values = editedValues[buttonId]; @@ -2906,34 +3486,120 @@ function ControlManagementTab({ return; } - // 버튼 컴포넌트 업데이트 + // 버튼 컴포넌트 업데이트 (화면 디자이너 구조 기준) const updateButton = (components: any[]): boolean => { for (const comp of components) { - if ((comp.id === buttonId || comp.componentId === buttonId) && - (comp.webType === "button" || comp.componentKind?.includes("button"))) { + const config = comp.componentConfig || {}; + + // 버튼 식별 조건 (화면 디자이너 저장 구조 기준) + const isButton = + comp.widgetType === "button" || + comp.webType === "button" || + comp.type === "button" || + config.webType === "button" || + comp.componentType?.includes("button") || + comp.componentKind?.includes("button"); + + if ((comp.id === buttonId || comp.componentId === buttonId) && isButton) { // componentConfig 업데이트 if (!comp.componentConfig) comp.componentConfig = {}; if (!comp.componentConfig.action) comp.componentConfig.action = {}; - if (values.targetTable) { + // 버튼 라벨(텍스트) 업데이트 + if (values.label !== undefined) { + comp.componentConfig.text = values.label; + // 레거시 호환: 여러 위치에 저장 + comp.label = values.label; + comp.title = values.label; + } + + // 버튼 스타일(색상) 업데이트 (webTypeConfig에 저장해야 실제 버튼에 반영됨) + if (!comp.webTypeConfig) comp.webTypeConfig = {}; + if (!comp.style) comp.style = {}; + if (values.backgroundColor !== undefined) { + comp.webTypeConfig.backgroundColor = values.backgroundColor; + comp.componentConfig.backgroundColor = values.backgroundColor; + comp.style.backgroundColor = values.backgroundColor; + } + if (values.textColor !== undefined) { + comp.webTypeConfig.textColor = values.textColor; + comp.componentConfig.textColor = values.textColor; + comp.style.color = values.textColor; + comp.style.labelColor = values.textColor; + } + + // 액션 타입 업데이트 + if (values.actionType) { + comp.componentConfig.action.type = values.actionType; + } + + // 대상 테이블 업데이트 + if (values.targetTable !== undefined) { comp.componentConfig.tableName = values.targetTable; } - if (values.confirmMessage !== undefined) { - comp.componentConfig.action.confirmMessage = values.confirmMessage; + + // 확인 다이얼로그 설정 (save/delete 액션에서만 유효) + const currentActionType = values.actionType || comp.componentConfig.action?.type; + if (currentActionType === "save" || currentActionType === "delete") { + if (values.confirmMessage !== undefined) { + comp.componentConfig.action.confirmMessage = values.confirmMessage; + } + } else { + // save/delete가 아닌 경우 confirmMessage 제거 + if (comp.componentConfig.action) { + delete comp.componentConfig.action.confirmMessage; + } } + + // 모달/네비게이션 화면 설정 (화면 디자이너는 targetScreenId 사용) + if (values.modalScreenId !== undefined) { + comp.componentConfig.action.targetScreenId = values.modalScreenId || null; + } + + if (values.navigateScreenId !== undefined) { + comp.componentConfig.action.targetScreenId = values.navigateScreenId || null; + } + if (values.operations) { comp.componentConfig.action.operations = values.operations; } - // webTypeConfig 업데이트 (플로우 연동) + // webTypeConfig 업데이트 (플로우 연동 - 다중 플로우 지원) if (!comp.webTypeConfig) comp.webTypeConfig = {}; - if (values.linkedFlowId) { + + // 다중 플로우 처리 (linkedFlows 배열) + if (values.linkedFlows !== undefined) { + if (values.linkedFlows && values.linkedFlows.length > 0) { + comp.webTypeConfig.enableDataflowControl = true; + comp.webTypeConfig.dataflowConfig = { + controlMode: "flow", + // 다중 플로우 저장 + flowConfigs: values.linkedFlows.map((lf: any) => ({ + flowId: lf.id, + flowName: lf.name, + executionTiming: lf.timing || "after", + })), + // 레거시 호환 - 첫 번째 플로우를 단일 flowConfig로도 저장 + flowConfig: { + flowId: values.linkedFlows[0].id, + flowName: values.linkedFlows[0].name, + executionTiming: values.linkedFlows[0].timing || "after", + }, + }; + } else { + // 플로우 연동 해제 (빈 배열) + comp.webTypeConfig.enableDataflowControl = false; + delete comp.webTypeConfig.dataflowConfig; + } + } + // 레거시 단일 플로우 처리 + else if (values.linkedFlowId) { comp.webTypeConfig.enableDataflowControl = true; comp.webTypeConfig.dataflowConfig = { controlMode: "flow", flowConfig: { flowId: values.linkedFlowId, - flowName: flows.find(f => f.id === values.linkedFlowId)?.name || "", + flowName: flows.find(f => f.flowId === values.linkedFlowId)?.flowName || "", executionTiming: values.flowTiming || "after", }, }; @@ -2946,9 +3612,16 @@ function ControlManagementTab({ return true; } + // 자식 컴포넌트 처리 if (comp.children && Array.isArray(comp.children)) { if (updateButton(comp.children)) return true; } + if (comp.componentConfig?.children && Array.isArray(comp.componentConfig.children)) { + if (updateButton(comp.componentConfig.children)) return true; + } + if (comp.items && Array.isArray(comp.items)) { + if (updateButton(comp.items)) return true; + } } return false; }; @@ -2974,36 +3647,57 @@ function ControlManagementTab({ } }; - // 액션 타입 라벨 + // 액션 타입 라벨 (화면 디자이너와 동일) const getActionTypeLabel = (type: string) => { const labels: Record = { save: "저장", delete: "삭제", - refresh: "새로고침", - reset: "초기화", - submit: "제출", - cancel: "취소", - close: "닫기", - navigate: "이동", - popup: "팝업", - custom: "커스텀", + edit: "편집", + copy: "복사", + navigate: "페이지 이동", + modal: "모달 열기", + openModalWithData: "데이터+모달", + openRelatedModal: "연관모달", + transferData: "데이터전달", + quickInsert: "즉시저장", + control: "제어흐름", + view_table_history: "이력보기", + excel_download: "엑셀다운", + excel_upload: "엑셀업로드", + barcode_scan: "바코드스캔", + code_merge: "코드병합", + operation_control: "운행제어", }; return labels[type] || type; }; - // 액션 타입 색상 + // 액션 타입 색상 (화면 디자이너와 동일) - hover 상태 포함 const getActionTypeColor = (type: string) => { switch (type) { case "save": - return "bg-green-100 text-green-700"; + case "quickInsert": + return "bg-green-100 text-green-700 hover:bg-green-100 hover:text-green-700"; case "delete": - return "bg-red-100 text-red-700"; - case "refresh": - return "bg-blue-100 text-blue-700"; - case "submit": - return "bg-purple-100 text-purple-700"; + return "bg-red-100 text-red-700 hover:bg-red-100 hover:text-red-700"; + case "edit": + case "copy": + return "bg-blue-100 text-blue-700 hover:bg-blue-100 hover:text-blue-700"; + case "modal": + case "openModalWithData": + case "openRelatedModal": + return "bg-purple-100 text-purple-700 hover:bg-purple-100 hover:text-purple-700"; + case "navigate": + return "bg-cyan-100 text-cyan-700 hover:bg-cyan-100 hover:text-cyan-700"; + case "transferData": + case "control": + return "bg-amber-100 text-amber-700 hover:bg-amber-100 hover:text-amber-700"; + case "excel_download": + case "excel_upload": + return "bg-emerald-100 text-emerald-700 hover:bg-emerald-100 hover:text-emerald-700"; + case "view_table_history": + return "bg-slate-100 text-slate-700 hover:bg-slate-100 hover:text-slate-700"; default: - return "bg-gray-100 text-gray-700"; + return "bg-gray-100 text-gray-700 hover:bg-gray-100 hover:text-gray-700"; } }; @@ -3016,358 +3710,864 @@ function ControlManagementTab({ } return ( -
- {/* 버튼 액션 설정 */} -
-
- - 버튼 액션 설정 - +
+ {/* 버튼 액션 설정 - 구분된 섹션 */} +
+
+ + 버튼 액션 설정 + {buttonControls.length}개
-
+
{buttonControls.length === 0 ? ( -
- +
+

버튼이 없습니다

화면 디자이너에서 버튼을 추가하세요

) : ( -
- {buttonControls.map((btn) => ( -
- {/* 버튼 헤더 */} -
setExpandedButton(expandedButton === btn.id ? null : btn.id)} - > - {expandedButton === btn.id ? ( - - ) : ( - - )} - [{btn.label}] - - {getActionTypeLabel(btn.actionType)} - - {btn.targetTable && ( - - → {btn.targetTable} - - )} +
+ {buttonControls.map((btn) => { + // 현재 편집 중인 값 또는 기본값 + const currentLabel = editedValues[btn.id]?.label ?? btn.label; + const currentBgColor = editedValues[btn.id]?.backgroundColor ?? btn.backgroundColor ?? "#3b82f6"; + const currentTextColor = editedValues[btn.id]?.textColor ?? btn.textColor ?? "#ffffff"; + + return ( +
+ {/* 버튼 헤더: 프리뷰 + 이름 입력 + 저장 버튼 */} +
+ {/* 버튼 프리뷰 */} +
+ {currentLabel || "버튼"} +
+ + {/* 버튼 이름 입력 */} +
+ setEditedValues(prev => ({ + ...prev, + [btn.id]: { ...prev[btn.id], label: e.target.value } + }))} + className="h-7 text-sm" + placeholder="버튼 이름" + /> +
+ {btn.hasDataflowControl && ( - + - 제어 연동 + 제어 )}
- {/* 버튼 상세 (확장 시) */} - {expandedButton === btn.id && ( -
-
- {/* 대상 테이블 */} -
- - {editingButton === btn.id ? ( - - ) : ( - - {btn.targetTable || 미설정} - - )} + {/* 버튼 설정 (상시 편집) */} +
+ {/* 액션 타입 */} +
+ + +
+ + {/* 버튼 스타일 (배경색 + 글자색) */} +
+ +
+ {/* 배경색 */} +
+ 배경 + setEditedValues(prev => ({ + ...prev, + [btn.id]: { ...prev[btn.id], backgroundColor: e.target.value } + }))} + className="h-6 w-8 rounded border cursor-pointer" + />
- - {/* 확인 메시지 */} -
- - {editingButton === btn.id ? ( - setEditedValues(prev => ({ + {/* 글자색 */} +
+ 글자 + setEditedValues(prev => ({ + ...prev, + [btn.id]: { ...prev[btn.id], textColor: e.target.value } + }))} + className="h-6 w-8 rounded border cursor-pointer" + /> +
+ {/* 프리셋 색상 */} +
+ {[ + { bg: "#3b82f6", text: "#ffffff", name: "파랑" }, + { bg: "#22c55e", text: "#ffffff", name: "초록" }, + { bg: "#ef4444", text: "#ffffff", name: "빨강" }, + { bg: "#6b7280", text: "#ffffff", name: "회색" }, + { bg: "#ffffff", text: "#374151", name: "흰색" }, + ].map((preset) => ( +
- - {/* 플로우 연동 */} -
- - {editingButton === btn.id ? ( - - ) : ( - - {btn.linkedFlow ? ( - - - {btn.linkedFlow.name} - - ) : ( - 없음 - )} - - )} -
- - {/* 편집/저장 버튼 */} -
- {editingButton === btn.id ? ( - <> - - - - ) : ( - - )} + ))}
- )} + + {/* 확인 메시지 설정 (save/delete 액션에서만 표시) */} + {((editedValues[btn.id]?.actionType || btn.actionType) === "save" || + (editedValues[btn.id]?.actionType || btn.actionType) === "delete") && ( +
+ + setEditedValues(prev => ({ + ...prev, + [btn.id]: { ...prev[btn.id], confirmMessage: e.target.value } + }))} + placeholder="커스텀 메시지 (예: 정말 삭제하시겠습니까?)" + className="h-7 text-xs" + /> +
+ )} + + {/* 모달 화면 선택 (modal, openModalWithData, openRelatedModal 액션) */} + {((editedValues[btn.id]?.actionType || btn.actionType) === "modal" || + (editedValues[btn.id]?.actionType || btn.actionType) === "openModalWithData" || + (editedValues[btn.id]?.actionType || btn.actionType) === "openRelatedModal") && ( +
+ + setOpenModalScreenSearch(open ? btn.id : null)}> + + + + + + + + 화면을 찾을 수 없습니다 + { + setEditedValues(prev => ({ ...prev, [btn.id]: { ...prev[btn.id], modalScreenId: null } })); + setOpenModalScreenSearch(null); + }} + className="text-xs text-muted-foreground" + > + + 미설정 + + {screenList.filter(s => s && s.id != null && s.inGroup).length > 0 && ( + + {screenList.filter(s => s && s.id != null && s.inGroup).map((s) => ( + { + setEditedValues(prev => ({ ...prev, [btn.id]: { ...prev[btn.id], modalScreenId: s.id } })); + setOpenModalScreenSearch(null); + }} + className="text-xs" + > + + {s.name} + + ))} + + )} + {screenList.filter(s => s && s.id != null && !s.inGroup).length > 0 && ( + + {screenList.filter(s => s && s.id != null && !s.inGroup).map((s) => ( + { + setEditedValues(prev => ({ ...prev, [btn.id]: { ...prev[btn.id], modalScreenId: s.id } })); + setOpenModalScreenSearch(null); + }} + className="text-xs text-muted-foreground" + > + + {s.name} + + ))} + + )} + + + + +
+ )} + + {/* 네비게이션 화면 선택 (navigate 액션) */} + {(editedValues[btn.id]?.actionType || btn.actionType) === "navigate" && ( +
+ + setOpenNavigateScreenSearch(open ? btn.id : null)}> + + + + + + + + 화면을 찾을 수 없습니다 + { + setEditedValues(prev => ({ ...prev, [btn.id]: { ...prev[btn.id], navigateScreenId: null } })); + setOpenNavigateScreenSearch(null); + }} + className="text-xs text-muted-foreground" + > + + 미설정 + + {screenList.filter(s => s && s.id != null && s.inGroup).length > 0 && ( + + {screenList.filter(s => s && s.id != null && s.inGroup).map((s) => ( + { + setEditedValues(prev => ({ ...prev, [btn.id]: { ...prev[btn.id], navigateScreenId: s.id } })); + setOpenNavigateScreenSearch(null); + }} + className="text-xs" + > + + {s.name} + + ))} + + )} + {screenList.filter(s => s && s.id != null && !s.inGroup).length > 0 && ( + + {screenList.filter(s => s && s.id != null && !s.inGroup).map((s) => ( + { + setEditedValues(prev => ({ ...prev, [btn.id]: { ...prev[btn.id], navigateScreenId: s.id } })); + setOpenNavigateScreenSearch(null); + }} + className="text-xs text-muted-foreground" + > + + {s.name} + + ))} + + )} + + + + +
+ )} + + {/* 플로우 연동 - 세로 목록 형식 */} +
+ +
+ {/* 연동된 플로우 목록 (세로 형식, 각각 타이밍 선택) */} + {(() => { + const currentFlows = editedValues[btn.id]?.linkedFlows || btn.linkedFlows || []; + return currentFlows.length > 0 ? ( +
+ {currentFlows.map((lf: { id: number; name: string; timing?: string }, idx: number) => ( +
+ + + {lf.name} + + {/* 타이밍 선택 */} + + {/* 삭제 버튼 */} + +
+ ))} +
+ ) : null; + })()} + + {/* 플로우 추가 버튼 */} + setOpenFlowSearch(open ? btn.id : null)}> + + + + + + + + 플로우를 찾을 수 없습니다 + {/* 빠른 생성 옵션 */} + + { + setOpenFlowSearch(null); + // FlowEditor 모달 열기 (버튼 연동) + setFlowEditorTargetButtonId(btn.id); + setShowFlowEditorModal(true); + setOpenFlowSearch(null); + }} + className="text-xs text-purple-700 bg-purple-50 hover:bg-purple-100" + > + +
+ 새 플로우 생성 + 여기서 직접 만들고 자동 연동 +
+
+
+ {flows.length > 0 ? ( + + {flows.map((f) => { + const currentFlows = editedValues[btn.id]?.linkedFlows || btn.linkedFlows || []; + const isLinked = currentFlows.some((lf: any) => lf.id === f.flowId); + const tableName = typeof f.flowData === 'object' + ? f.flowData?.nodes?.find((n: any) => n.type === 'tableSource')?.data?.tableName + : null; + return ( + { + if (!isLinked) { + const newFlows = [...currentFlows, { id: f.flowId, name: f.flowName, timing: "after" }]; + setEditedValues(prev => ({ + ...prev, + [btn.id]: { ...prev[btn.id], linkedFlows: newFlows } + })); + } + setOpenFlowSearch(null); + }} + className={cn("text-xs", isLinked && "opacity-50")} + disabled={isLinked} + > + +
+ {f.flowName} + {tableName && {tableName}} +
+ {isLinked && 연동됨} +
+ ); + })} +
+ ) : ( +
+ 등록된 플로우가 없습니다 +
+ )} +
+
+
+
+ +
+
+
- ))} + ); + })}
)}
- {/* 외부 연동 */} -
-
- - 외부 연동 - - {externalCalls.filter(e => e.is_active === "Y").length}개 활성 + {/* 플로우 연동 - 구분된 섹션 */} +
+
+ + 플로우 연동 현황 + + {flows.length}개 플로우 + + {buttonControls.filter(b => (b.linkedFlows && b.linkedFlows.length > 0) || b.linkedFlow).length}개 연동 + + + +
- -
- {externalCalls.length === 0 ? ( -
- -

외부 호출 설정이 없습니다

- + +
+ {flows.length === 0 ? ( +
+ +

사용 가능한 플로우가 없습니다

) : ( -
- {externalCalls.slice(0, 5).map((call) => ( -
- + {flows.map((flow) => { + // 이 플로우가 연동된 버튼들 찾기 (다중 플로우 지원) + const linkedButtons = buttonControls.filter(b => + (b.linkedFlows && b.linkedFlows.some(lf => lf.id === flow.flowId)) || + b.linkedFlow?.id === flow.flowId + ); + const tableName = (flow as any).tableType || (flow as any).tableName; + return ( +
+ {/* 플로우 이름 - 일반 텍스트 */} + + + {flow.flowName} + + {tableName && ( + + ({tableName}) + + )} + + {linkedButtons.length > 0 ? ( +
+ {linkedButtons.map((btn, idx) => { + // 해당 버튼에서 이 플로우의 타이밍 정보 추출 + const flowInfo = btn.linkedFlows?.find(lf => lf.id === flow.flowId); + const timing = flowInfo?.timing || btn.flowTiming || "after"; + return ( +
+ {idx === 0 && } + + [{btn.label}] + + + {getActionTypeLabel(btn.actionType)} + + + ({timing === "before" ? "전" : "후"}) + +
+ ); + })} +
+ ) : ( + /* 미연동 - 보라색 뱃지 */ + + 미연동 + + )} +
+ ); + })} +
+ )} +
+
+ + {/* 플로우 빠른 생성 다이얼로그 */} + + + + 플로우 빠른 생성 + + 플로우의 기본 골격만 생성합니다. + + + + {/* 중요 안내 */} +
+
+ +
+

생성 후 제어 관리에서 추가 설정이 필요합니다

+

+ 필드 매핑, WHERE 조건 등을 설정해야 실제로 동작합니다. +

+
+
+
+ +
+ {/* 플로우 이름 */} +
+ + setQuickFlowData(prev => ({ ...prev, name: e.target.value }))} + placeholder="예: 고객정보 수정 플로우" + className="h-8 text-xs sm:h-10 sm:text-sm mt-1" + /> +
+ + {/* 테이블 선택/입력 */} +
+ + {(() => { + const availableTables = Array.from(new Set(buttonControls.filter(b => b.targetTable).map(b => b.targetTable))); + + return availableTables.length > 0 ? ( + <> + + + + + + + + + 테이블을 찾을 수 없습니다 + + {availableTables.map((table) => ( + { + setQuickFlowData(prev => ({ + ...prev, + tableName: table || "", + tableLabel: table || "", + })); + }} + className="text-xs" + > + + + {table} + + ))} + + + + + +

화면에서 사용 중인 테이블 목록입니다

+ + ) : ( + <> + setQuickFlowData(prev => ({ + ...prev, + tableName: e.target.value, + tableLabel: e.target.value, + }))} + placeholder="테이블명 입력 (예: customer_mng)" + className="h-8 text-xs sm:h-10 sm:text-sm mt-1" + /> +

테이블명을 직접 입력하세요

+ + ); + })()} +
+ + {/* 액션 타입 */} +
+ +
+ {[ + { value: "insert", label: "INSERT", color: "bg-green-100 text-green-700 border-green-300" }, + { value: "update", label: "UPDATE", color: "bg-blue-100 text-blue-700 border-blue-300" }, + { value: "delete", label: "DELETE", color: "bg-red-100 text-red-700 border-red-300" }, + ].map((action) => ( + -
- ))} - {externalCalls.length > 5 && ( -
- -
- )} + {action.label} + + ))} +
- )} -
-
-
- - 버튼에 외부 호출을 연결하려면 버튼 편집에서 설정하세요 + {/* 설명 (선택) */} +
+ +