diff --git a/.cursor/rules/api-client-usage.mdc b/.cursor/rules/api-client-usage.mdc new file mode 100644 index 00000000..ff7e2e69 --- /dev/null +++ b/.cursor/rules/api-client-usage.mdc @@ -0,0 +1,209 @@ +--- +alwaysApply: true +description: API 요청 시 항상 전용 API 클라이언트를 사용하도록 강제하는 규칙 +--- + +# API 클라이언트 사용 규칙 + +## 핵심 원칙 + +**절대 `fetch`를 직접 사용하지 않고, 반드시 전용 API 클라이언트를 사용해야 합니다.** + +## 이유 + +1. **환경별 URL 자동 처리**: 프로덕션(`v1.vexplor.com`)과 개발(`localhost`) 환경에서 올바른 백엔드 서버로 요청 +2. **일관된 에러 처리**: 모든 API 호출에서 동일한 에러 핸들링 +3. **인증 토큰 자동 포함**: Authorization 헤더 자동 추가 +4. **유지보수성**: API 변경 시 한 곳에서만 수정 + +## API 클라이언트 위치 + +``` +frontend/lib/api/ +├── client.ts # Axios 기반 공통 클라이언트 +├── flow.ts # 플로우 관리 API +├── dashboard.ts # 대시보드 API +├── mail.ts # 메일 API +├── externalCall.ts # 외부 호출 API +├── company.ts # 회사 관리 API +└── file.ts # 파일 업로드/다운로드 API +``` + +## 올바른 사용법 + +### ❌ 잘못된 방법 (절대 사용 금지) + +```typescript +// 직접 fetch 사용 - 환경별 URL이 자동 처리되지 않음 +const response = await fetch("/api/flow/definitions/29/steps"); +const data = await response.json(); + +// 상대 경로 - 프로덕션에서 잘못된 도메인으로 요청 +const response = await fetch(`/api/flow/${flowId}/steps`); +``` + +### ✅ 올바른 방법 + +```typescript +// 1. API 클라이언트 함수 import +import { getFlowSteps } from "@/lib/api/flow"; + +// 2. 함수 호출 +const stepsResponse = await getFlowSteps(flowId); +if (stepsResponse.success && stepsResponse.data) { + setSteps(stepsResponse.data); +} +``` + +## 주요 API 클라이언트 함수 + +### 플로우 관리 ([flow.ts](mdc:frontend/lib/api/flow.ts)) + +```typescript +import { + getFlowDefinitions, // 플로우 목록 + getFlowById, // 플로우 상세 + createFlowDefinition, // 플로우 생성 + updateFlowDefinition, // 플로우 수정 + deleteFlowDefinition, // 플로우 삭제 + getFlowSteps, // 스텝 목록 ⭐ + createFlowStep, // 스텝 생성 + updateFlowStep, // 스텝 수정 + deleteFlowStep, // 스텝 삭제 + getFlowConnections, // 연결 목록 ⭐ + createFlowConnection, // 연결 생성 + deleteFlowConnection, // 연결 삭제 + getStepDataCount, // 스텝 데이터 카운트 + getStepDataList, // 스텝 데이터 목록 + getAllStepCounts, // 모든 스텝 카운트 + moveData, // 데이터 이동 + moveBatchData, // 배치 데이터 이동 + getAuditLogs, // 오딧 로그 +} from "@/lib/api/flow"; +``` + +### Axios 클라이언트 ([client.ts](mdc:frontend/lib/api/client.ts)) + +```typescript +import apiClient from "@/lib/api/client"; + +// GET 요청 +const response = await apiClient.get("/api/endpoint"); + +// POST 요청 +const response = await apiClient.post("/api/endpoint", { data }); + +// PUT 요청 +const response = await apiClient.put("/api/endpoint", { data }); + +// DELETE 요청 +const response = await apiClient.delete("/api/endpoint"); +``` + +## 새로운 API 함수 추가 가이드 + +기존 API 클라이언트에 함수가 없는 경우: + +```typescript +// frontend/lib/api/yourModule.ts + +// 1. API URL 동적 설정 (필수) +const getApiBaseUrl = (): string => { + if (process.env.NEXT_PUBLIC_API_URL) { + return process.env.NEXT_PUBLIC_API_URL; + } + + if (typeof window !== "undefined") { + const currentHost = window.location.hostname; + + // 프로덕션: v1.vexplor.com → api.vexplor.com + if (currentHost === "v1.vexplor.com") { + return "https://api.vexplor.com/api"; + } + + // 로컬 개발 + if (currentHost === "localhost" || currentHost === "127.0.0.1") { + return "http://localhost:8080/api"; + } + } + + return "/api"; +}; + +const API_BASE = getApiBaseUrl(); + +// 2. API 함수 작성 +export async function getYourData(id: number): Promise> { + try { + const response = await fetch(`${API_BASE}/your-endpoint/${id}`, { + credentials: "include", + }); + + return await response.json(); + } catch (error: any) { + return { + success: false, + error: error.message, + }; + } +} +``` + +## 환경별 URL 매핑 + +API 클라이언트는 자동으로 환경을 감지합니다: + +| 현재 호스트 | 백엔드 API URL | +| ---------------- | ----------------------------- | +| `v1.vexplor.com` | `https://api.vexplor.com/api` | +| `localhost:9771` | `http://localhost:8080/api` | +| `localhost:3000` | `http://localhost:8080/api` | + +## 체크리스트 + +코드 작성 시 다음을 확인하세요: + +- [ ] `fetch('/api/...')` 직접 사용하지 않음 +- [ ] 적절한 API 클라이언트 함수를 import 함 +- [ ] API 응답의 `success` 필드를 체크함 +- [ ] 에러 처리를 구현함 +- [ ] 새로운 API가 필요하면 `lib/api/` 에 함수 추가 + +## 예외 상황 + +다음 경우에만 `fetch`를 직접 사용할 수 있습니다: + +1. **외부 서비스 호출**: 다른 도메인의 API 호출 시 +2. **특수한 헤더가 필요한 경우**: FormData, Blob 등 + +이 경우에도 가능하면 전용 API 클라이언트 함수로 래핑하세요. + +## 실제 적용 예시 + +### 플로우 위젯 ([FlowWidget.tsx](mdc:frontend/components/screen/widgets/FlowWidget.tsx)) + +```typescript +// ❌ 이전 코드 +const stepsResponse = await fetch(`/api/flow/definitions/${flowId}/steps`); +const connectionsResponse = await fetch(`/api/flow/connections/${flowId}`); + +// ✅ 수정된 코드 +const stepsResponse = await getFlowSteps(flowId); +const connectionsResponse = await getFlowConnections(flowId); +``` + +### 플로우 가시성 패널 ([FlowVisibilityConfigPanel.tsx](mdc:frontend/components/screen/config-panels/FlowVisibilityConfigPanel.tsx)) + +```typescript +// ❌ 이전 코드 +const stepsResponse = await fetch(`/api/flow/definitions/${flowId}/steps`); + +// ✅ 수정된 코드 +const stepsResponse = await getFlowSteps(flowId); +``` + +## 참고 자료 + +- [API 클라이언트 공통 설정](mdc:frontend/lib/api/client.ts) +- [플로우 API 클라이언트](mdc:frontend/lib/api/flow.ts) +- [API URL 유틸리티](mdc:frontend/lib/utils/apiUrl.ts) diff --git a/.cursor/rules/table-with-sticky-header.mdc b/.cursor/rules/table-with-sticky-header.mdc new file mode 100644 index 00000000..cc4c544f --- /dev/null +++ b/.cursor/rules/table-with-sticky-header.mdc @@ -0,0 +1,343 @@ +# 고정 헤더 테이블 표준 가이드 + +## 개요 + +스크롤 가능한 테이블에서 헤더를 상단에 고정하는 표준 구조입니다. +플로우 위젯의 스텝 데이터 리스트 테이블을 참조 기준으로 합니다. + +## 필수 구조 + +### 1. 기본 HTML 구조 + +```tsx +
+ + + + + 헤더 1 + + + 헤더 2 + + + + {/* 데이터 행들 */} +
+
+``` + +### 2. 필수 클래스 설명 + +#### 스크롤 컨테이너 (외부 div) + +```tsx +className="relative overflow-auto" +style={{ height: "450px" }} +``` + +**필수 요소:** + +- `relative`: sticky positioning의 기준점 +- `overflow-auto`: 스크롤 활성화 +- `height`: 고정 높이 (인라인 스타일 또는 Tailwind 클래스) + +#### Table 컴포넌트 + +```tsx + +``` + +**필수 props:** + +- `noWrapper`: Table 컴포넌트의 내부 wrapper 제거 (매우 중요!) + - 이것이 없으면 sticky header가 작동하지 않음 + +#### TableHead (헤더 셀) + +```tsx +className = + "bg-background sticky top-0 z-10 border-b shadow-[0_1px_0_0_rgb(0,0,0,0.1)]"; +``` + +**필수 클래스:** + +- `bg-background`: 배경색 (스크롤 시 데이터가 보이지 않도록) +- `sticky top-0`: 상단 고정 +- `z-10`: 다른 요소 위에 표시 +- `border-b`: 하단 테두리 +- `shadow-[0_1px_0_0_rgb(0,0,0,0.1)]`: 얇은 그림자 (헤더와 본문 구분) + +### 3. 왼쪽 열 고정 (체크박스 등) + +첫 번째 열도 고정하려면: + +```tsx + + + +``` + +**z-index 규칙:** + +- 왼쪽+상단 고정: `z-20` +- 상단만 고정: `z-10` +- 왼쪽만 고정: `z-10` +- 일반 셀: z-index 없음 + +### 4. 완전한 예제 (체크박스 포함) + +```tsx +
+
+ + + {/* 왼쪽 고정 체크박스 열 */} + + + + + {/* 일반 헤더 열들 */} + {columns.map((col) => ( + + {col} + + ))} + + + + + {data.map((row, index) => ( + + {/* 왼쪽 고정 체크박스 */} + + toggleRow(index)} + /> + + + {/* 데이터 셀들 */} + {columns.map((col) => ( + + {row[col]} + + ))} + + ))} + +
+ +``` + +## 반응형 대응 + +### 모바일: 카드 뷰 + +```tsx +{ + /* 모바일: 카드 뷰 */ +} +
+
+ {data.map((item, index) => ( +
+ {/* 카드 내용 */} +
+ ))} +
+
; + +{ + /* 데스크톱: 테이블 뷰 */ +} +
+ {/* 위의 테이블 구조 */}
+
; +``` + +## 자주하는 실수 + +### ❌ 잘못된 예시 + +```tsx +{ + /* 1. noWrapper 없음 - sticky 작동 안함 */ +} + + ... +
; + +{ + /* 2. 배경색 없음 - 스크롤 시 데이터가 보임 */ +} +헤더; + +{ + /* 3. relative 없음 - sticky 기준점 없음 */ +} +
+ ...
+
; + +{ + /* 4. 고정 높이 없음 - 스크롤 발생 안함 */ +} +
+ ...
+
; +``` + +### ✅ 올바른 예시 + +```tsx +{ + /* 모든 필수 요소 포함 */ +} +
+ + + + + 헤더 + + + + ... +
+
; +``` + +## 높이 설정 가이드 + +### 권장 높이값 + +- **소형 리스트**: `300px` ~ `400px` +- **중형 리스트**: `450px` ~ `600px` (플로우 위젯 기준) +- **대형 리스트**: `calc(100vh - 200px)` (화면 높이 기준) + +### 동적 높이 계산 + +```tsx +// 화면 높이의 60% +style={{ height: "60vh" }} + +// 화면 높이 - 헤더/푸터 제외 +style={{ height: "calc(100vh - 250px)" }} + +// 부모 요소 기준 +className="h-full overflow-auto" +``` + +## 성능 최적화 + +### 1. 가상 스크롤 (대량 데이터) + +데이터가 1000건 이상인 경우 `react-virtual` 사용 권장: + +```tsx +import { useVirtualizer } from "@tanstack/react-virtual"; + +const parentRef = useRef(null); + +const rowVirtualizer = useVirtualizer({ + count: data.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 50, // 행 높이 +}); +``` + +### 2. 페이지네이션 + +대량 데이터는 페이지 단위로 렌더링: + +```tsx +const paginatedData = data.slice((page - 1) * pageSize, page * pageSize); +``` + +## 접근성 + +### ARIA 레이블 + +```tsx +
+ + {/* 테이블 내용 */} +
+
+``` + +### 키보드 네비게이션 + +```tsx + { + if (e.key === "Enter" || e.key === " ") { + handleRowClick(); + } + }} +> + {/* 행 내용 */} + +``` + +## 다크 모드 대응 + +### 배경색 + +```tsx +{ + /* 라이트/다크 모드 모두 대응 */ +} +className = "bg-background"; // ✅ 권장 + +{ + /* 고정 색상 - 다크 모드 문제 */ +} +className = "bg-white"; // ❌ 비권장 +``` + +### 그림자 + +```tsx +{ + /* 다크 모드에서도 보이는 그림자 */ +} +className = "shadow-[0_1px_0_0_hsl(var(--border))]"; + +{ + /* 또는 */ +} +className = "shadow-[0_1px_0_0_rgb(0,0,0,0.1)]"; +``` + +## 참조 파일 + +- **구현 예시**: `frontend/components/screen/widgets/FlowWidget.tsx` (line 760-820) +- **Table 컴포넌트**: `frontend/components/ui/table.tsx` + +## 체크리스트 + +테이블 구현 시 다음을 확인하세요: + +- [ ] 외부 div에 `relative overflow-auto` 적용 +- [ ] 외부 div에 고정 높이 설정 +- [ ] `` 사용 +- [ ] TableHead에 `bg-background sticky top-0 z-10` 적용 +- [ ] TableHead에 `border-b shadow-[...]` 적용 +- [ ] 왼쪽 고정 열은 `z-20` 사용 +- [ ] 모바일 반응형 대응 (카드 뷰) +- [ ] 다크 모드 호환 색상 사용 diff --git a/frontend/components/dataflow/node-editor/FlowEditor.tsx b/frontend/components/dataflow/node-editor/FlowEditor.tsx index f144a7a8..c87c80aa 100644 --- a/frontend/components/dataflow/node-editor/FlowEditor.tsx +++ b/frontend/components/dataflow/node-editor/FlowEditor.tsx @@ -147,6 +147,14 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) { flowData.nodes || [], flowData.edges || [], ); + + // 🆕 플로우 로드 후 첫 번째 노드 자동 선택 + if (flowData.nodes && flowData.nodes.length > 0) { + const firstNode = flowData.nodes[0]; + selectNodes([firstNode.id]); + setShowPropertiesPanelLocal(true); + console.log("✅ 첫 번째 노드 자동 선택:", firstNode.id); + } } } catch (error) { console.error("플로우 로드 실패:", error); @@ -155,7 +163,7 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) { }; fetchAndLoadFlow(); - }, [initialFlowId]); + }, [initialFlowId, loadFlow, selectNodes]); /** * 노드 선택 변경 핸들러 diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index edd5c7d0..4784fe38 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -3076,7 +3076,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD }) : null; - // 일반 컴포넌트만 격자 스냅 적용 (그룹 제외) + // 일반 컴포넌트 및 플로우 버튼 그룹에 격자 스냅 적용 (일반 그룹 제외) if (draggedComponent?.type !== "group" && layout.gridSettings?.snapToGrid && currentGridInfo) { finalPosition = snapToGrid( { @@ -3094,6 +3094,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD ); console.log("🎯 격자 스냅 적용됨:", { + componentType: draggedComponent?.type, resolution: `${screenResolution.width}x${screenResolution.height}`, originalPosition: dragState.currentPosition, snappedPosition: finalPosition, @@ -3516,12 +3517,79 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD // 고유한 그룹 ID 생성 const newGroupId = generateGroupId(); - // 버튼들을 그룹으로 묶기 (설정 포함) + // 🔧 그룹 위치 및 버튼 재배치 계산 + const align = settings.align; + const direction = settings.direction; + const gap = settings.gap; + + const groupY = Math.min(...selectedComponents.map((b) => b.position.y)); + let anchorButton; // 기준이 되는 버튼 + let groupX: number; + + // align에 따라 기준 버튼과 그룹 시작점 결정 + if (direction === "horizontal") { + if (align === "end") { + // 끝점 정렬: 가장 오른쪽 버튼이 기준 + anchorButton = selectedComponents.reduce((max, btn) => { + const rightEdge = btn.position.x + (btn.size?.width || 100); + const maxRightEdge = max.position.x + (max.size?.width || 100); + return rightEdge > maxRightEdge ? btn : max; + }); + + // 전체 그룹 너비 계산 + const totalWidth = selectedComponents.reduce((total, btn, index) => { + const buttonWidth = btn.size?.width || 100; + const gapWidth = index < selectedComponents.length - 1 ? gap : 0; + return total + buttonWidth + gapWidth; + }, 0); + + // 그룹 시작점 = 기준 버튼의 오른쪽 끝 - 전체 그룹 너비 + groupX = anchorButton.position.x + (anchorButton.size?.width || 100) - totalWidth; + } else if (align === "center") { + // 중앙 정렬: 버튼들의 중심점을 기준으로 + const minX = Math.min(...selectedComponents.map((b) => b.position.x)); + const maxX = Math.max(...selectedComponents.map((b) => b.position.x + (b.size?.width || 100))); + const centerX = (minX + maxX) / 2; + + const totalWidth = selectedComponents.reduce((total, btn, index) => { + const buttonWidth = btn.size?.width || 100; + const gapWidth = index < selectedComponents.length - 1 ? gap : 0; + return total + buttonWidth + gapWidth; + }, 0); + + groupX = centerX - totalWidth / 2; + anchorButton = selectedComponents[0]; // 중앙 정렬은 첫 번째 버튼 기준 + } else { + // 시작점 정렬: 가장 왼쪽 버튼이 기준 + anchorButton = selectedComponents.reduce((min, btn) => { + return btn.position.x < min.position.x ? btn : min; + }); + groupX = anchorButton.position.x; + } + } else { + // 세로 정렬: 가장 위쪽 버튼이 기준 + anchorButton = selectedComponents.reduce((min, btn) => { + return btn.position.y < min.position.y ? btn : min; + }); + groupX = Math.min(...selectedComponents.map((b) => b.position.x)); + } + + // 🔧 버튼들의 위치를 그룹 기준으로 재배치 + // 기준 버튼의 절대 위치를 유지하고, FlexBox가 나머지를 자동 정렬 const groupedButtons = selectedComponents.map((button) => { const currentConfig = (button as any).webTypeConfig?.flowVisibilityConfig || {}; + // 모든 버튼을 그룹 시작점에 배치 + // FlexBox가 자동으로 정렬하여 기준 버튼의 위치가 유지됨 + const newPosition = { + x: groupX, + y: groupY, + z: button.position.z || 1, + }; + return { ...button, + position: newPosition, webTypeConfig: { ...(button as any).webTypeConfig, flowVisibilityConfig: { @@ -3558,7 +3626,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD console.log("✅ 플로우 버튼 그룹 생성 완료:", { groupId: newGroupId, buttonCount: selectedComponents.length, - buttons: selectedComponents.map((b) => b.id), + buttons: selectedComponents.map((b) => ({ id: b.id, position: b.position })), + groupPosition: { x: groupX, y: groupY }, settings, }); }, @@ -4316,86 +4385,109 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
🔍 {Math.round(zoomLevel * 100)}%
- {/* 🆕 플로우 버튼 그룹 제어 (다중 선택 시 표시) */} - {groupState.selectedComponents.length >= 2 && ( -
-
-
- - - - - - {groupState.selectedComponents.length}개 선택됨 + {/* 🆕 플로우 버튼 그룹 제어 (버튼 선택 시 표시) */} + {(() => { + // 선택된 컴포넌트들 + const selectedComps = layout.components.filter((c) => groupState.selectedComponents.includes(c.id)); + + // 버튼 컴포넌트만 필터링 + const selectedButtons = selectedComps.filter((comp) => areAllButtons([comp])); + + // 플로우 그룹에 속한 버튼이 있는지 확인 + const hasFlowGroupButton = selectedButtons.some((btn) => { + const flowConfig = (btn as any).webTypeConfig?.flowVisibilityConfig; + return flowConfig?.enabled && flowConfig.layoutBehavior === "auto-compact" && flowConfig.groupId; + }); + + // 버튼이 선택되었거나 플로우 그룹 버튼이 있으면 표시 + const shouldShow = selectedButtons.length >= 1 && (selectedButtons.length >= 2 || hasFlowGroupButton); + + if (!shouldShow) return null; + + return ( +
+
+
+ + + + + + {selectedButtons.length}개 버튼 선택됨 +
+ + {/* 그룹 생성 버튼 (2개 이상 선택 시) */} + {selectedButtons.length >= 2 && ( + + )} + + {/* 그룹 해제 버튼 (플로우 그룹 버튼이 있으면 항상 표시) */} + {hasFlowGroupButton && ( + + )} + + {/* 상태 표시 */} + {hasFlowGroupButton &&

✓ 플로우 그룹 버튼

}
- - - {areAllButtons(layout.components.filter((c) => groupState.selectedComponents.includes(c.id))) ? ( -

✓ 모두 버튼 컴포넌트

- ) : ( -

⚠ 버튼만 그룹 가능

- )}
-
- )} + ); + })()} {/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 */}
({ - x: Math.min(min.x, button.position.x), - y: Math.min(min.y, button.position.y), - z: min.z, - }), - { x: buttons[0].position.x, y: buttons[0].position.y, z: buttons[0].position.z || 2 }, - ); - - // 🆕 그룹의 크기 계산: 버튼들의 실제 크기 + 간격을 기준으로 계산 + // 🔧 그룹의 위치 및 크기 계산 + // 모든 버튼이 같은 위치(groupX, groupY)에 배치되어 있으므로 + // 첫 번째 버튼의 위치를 그룹 시작점으로 사용 const direction = groupConfig.groupDirection || "horizontal"; const gap = groupConfig.groupGap ?? 8; + const align = groupConfig.groupAlign || "start"; + const groupPosition = { + x: buttons[0].position.x, + y: buttons[0].position.y, + z: buttons[0].position.z || 2, + }; + + // 버튼들의 실제 크기 계산 let groupWidth = 0; let groupHeight = 0; @@ -4731,12 +4823,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const gapWidth = index < buttons.length - 1 ? gap : 0; return total + buttonWidth + gapWidth; }, 0); - // 세로는 가장 큰 버튼의 높이 groupHeight = Math.max(...buttons.map((b) => b.size?.height || 40)); } else { - // 세로 정렬: 가로는 가장 큰 버튼의 너비 + // 세로 정렬 groupWidth = Math.max(...buttons.map((b) => b.size?.width || 100)); - // 세로는 모든 버튼의 높이 + 간격 groupHeight = buttons.reduce((total, button, index) => { const buttonHeight = button.size?.height || 40; const gapHeight = index < buttons.length - 1 ? gap : 0; @@ -4744,6 +4834,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD }, 0); } + // 🆕 그룹 전체가 선택되었는지 확인 + const isGroupSelected = buttons.every( + (btn) => selectedComponent?.id === btn.id || groupState.selectedComponents.includes(btn.id), + ); + const hasAnySelected = buttons.some( + (btn) => selectedComponent?.id === btn.id || groupState.selectedComponents.includes(btn.id), + ); + return (
{ + onMouseDown={(e) => { + // 클릭이 아닌 드래그인 경우에만 드래그 시작 + e.preventDefault(); e.stopPropagation(); - handleComponentClick(button, e); + + const startX = e.clientX; + const startY = e.clientY; + let isDragging = false; + let dragStarted = false; + + const handleMouseMove = (moveEvent: MouseEvent) => { + const deltaX = Math.abs(moveEvent.clientX - startX); + const deltaY = Math.abs(moveEvent.clientY - startY); + + // 5픽셀 이상 움직이면 드래그로 간주 + if ((deltaX > 5 || deltaY > 5) && !dragStarted) { + isDragging = true; + dragStarted = true; + + // Shift 키를 누르지 않았으면 같은 그룹의 버튼들도 모두 선택 + if (!e.shiftKey) { + const buttonIds = buttons.map((b) => b.id); + setGroupState((prev) => ({ + ...prev, + selectedComponents: buttonIds, + })); + } + + // 드래그 시작 + startComponentDrag(button, e as any); + } + }; + + const handleMouseUp = () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + + // 드래그가 아니면 클릭으로 처리 + if (!isDragging) { + // Shift 키를 누르지 않았으면 같은 그룹의 버튼들도 모두 선택 + if (!e.shiftKey) { + const buttonIds = buttons.map((b) => b.id); + setGroupState((prev) => ({ + ...prev, + selectedComponents: buttonIds, + })); + } + handleComponentClick(button, e); + } + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); }} onDoubleClick={(e) => { e.stopPropagation(); @@ -4817,12 +4969,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD className={ selectedComponent?.id === button.id || groupState.selectedComponents.includes(button.id) - ? "outline outline-2 outline-offset-2 outline-blue-500" + ? "outline-1 outline-offset-1 outline-blue-400" : "" } > {/* 그룹 내부에서는 DynamicComponentRenderer로 직접 렌더링 */} -
+
0) { + const firstStep = sortedSteps[0]; + setSelectedStepId(firstStep.id); + setSelectedStep(flowComponentId, firstStep.id); + console.log("✅ [FlowWidget] 첫 번째 단계 자동 선택:", { + flowComponentId, + stepId: firstStep.id, + stepName: firstStep.stepName, + }); + + // 첫 번째 스텝의 데이터 로드 + try { + const response = await getStepDataList(flowId!, firstStep.id, 1, 100); + if (response.success) { + const rows = response.data?.records || []; + setStepData(rows); + if (rows.length > 0) { + setStepDataColumns(Object.keys(rows[0])); + } + } + } catch (err) { + console.error("첫 번째 스텝 데이터 로드 실패:", err); + } + } } } catch (err: any) { console.error("Failed to load flow data:", err); @@ -732,12 +758,12 @@ export function FlowWidget({
{/* 데스크톱: 테이블 뷰 - 고정 높이 + 스크롤 */} -
-
+
+
- + {allowDataMove && ( - + 0} onCheckedChange={toggleAllRows} @@ -747,7 +773,7 @@ export function FlowWidget({ {stepDataColumns.map((col) => ( {col}