버튼 정렬기능 수정

This commit is contained in:
kjs 2025-10-24 17:27:22 +09:00
parent addff4769b
commit 31bd9c26b7
5 changed files with 843 additions and 105 deletions

View File

@ -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<ApiResponse<YourType>> {
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)

View File

@ -0,0 +1,343 @@
# 고정 헤더 테이블 표준 가이드
## 개요
스크롤 가능한 테이블에서 헤더를 상단에 고정하는 표준 구조입니다.
플로우 위젯의 스텝 데이터 리스트 테이블을 참조 기준으로 합니다.
## 필수 구조
### 1. 기본 HTML 구조
```tsx
<div className="relative overflow-auto" style={{ height: "450px" }}>
<Table noWrapper>
<TableHeader>
<TableRow>
<TableHead className="bg-background sticky top-0 z-10 border-b shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
헤더 1
</TableHead>
<TableHead className="bg-background sticky top-0 z-10 border-b shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
헤더 2
</TableHead>
</TableRow>
</TableHeader>
<TableBody>{/* 데이터 행들 */}</TableBody>
</Table>
</div>
```
### 2. 필수 클래스 설명
#### 스크롤 컨테이너 (외부 div)
```tsx
className="relative overflow-auto"
style={{ height: "450px" }}
```
**필수 요소:**
- `relative`: sticky positioning의 기준점
- `overflow-auto`: 스크롤 활성화
- `height`: 고정 높이 (인라인 스타일 또는 Tailwind 클래스)
#### Table 컴포넌트
```tsx
<Table noWrapper>
```
**필수 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
<TableHead className="bg-background sticky top-0 left-0 z-20 border-b shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
<Checkbox />
</TableHead>
```
**z-index 규칙:**
- 왼쪽+상단 고정: `z-20`
- 상단만 고정: `z-10`
- 왼쪽만 고정: `z-10`
- 일반 셀: z-index 없음
### 4. 완전한 예제 (체크박스 포함)
```tsx
<div className="relative overflow-auto" style={{ height: "450px" }}>
<Table noWrapper>
<TableHeader>
<TableRow>
{/* 왼쪽 고정 체크박스 열 */}
<TableHead className="bg-background sticky top-0 left-0 z-20 w-12 border-b px-3 py-2 text-center shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
<Checkbox checked={allSelected} onCheckedChange={handleSelectAll} />
</TableHead>
{/* 일반 헤더 열들 */}
{columns.map((col) => (
<TableHead
key={col}
className="bg-background sticky top-0 z-10 border-b px-3 py-2 text-xs font-semibold whitespace-nowrap shadow-[0_1px_0_0_rgb(0,0,0,0.1)] sm:text-sm"
>
{col}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{data.map((row, index) => (
<TableRow key={index}>
{/* 왼쪽 고정 체크박스 */}
<TableCell className="bg-background sticky left-0 z-10 border-b px-3 py-2 text-center">
<Checkbox
checked={selectedRows.has(index)}
onCheckedChange={() => toggleRow(index)}
/>
</TableCell>
{/* 데이터 셀들 */}
{columns.map((col) => (
<TableCell key={col} className="border-b px-3 py-2">
{row[col]}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
```
## 반응형 대응
### 모바일: 카드 뷰
```tsx
{
/* 모바일: 카드 뷰 */
}
<div className="overflow-y-auto sm:hidden" style={{ height: "450px" }}>
<div className="space-y-2 p-3">
{data.map((item, index) => (
<div key={index} className="bg-card rounded-md border p-3">
{/* 카드 내용 */}
</div>
))}
</div>
</div>;
{
/* 데스크톱: 테이블 뷰 */
}
<div
className="relative hidden overflow-auto sm:block"
style={{ height: "450px" }}
>
<Table noWrapper>{/* 위의 테이블 구조 */}</Table>
</div>;
```
## 자주하는 실수
### ❌ 잘못된 예시
```tsx
{
/* 1. noWrapper 없음 - sticky 작동 안함 */
}
<Table>
<TableHeader>...</TableHeader>
</Table>;
{
/* 2. 배경색 없음 - 스크롤 시 데이터가 보임 */
}
<TableHead className="sticky top-0">헤더</TableHead>;
{
/* 3. relative 없음 - sticky 기준점 없음 */
}
<div className="overflow-auto">
<Table noWrapper>...</Table>
</div>;
{
/* 4. 고정 높이 없음 - 스크롤 발생 안함 */
}
<div className="relative overflow-auto">
<Table noWrapper>...</Table>
</div>;
```
### ✅ 올바른 예시
```tsx
{
/* 모든 필수 요소 포함 */
}
<div className="relative overflow-auto" style={{ height: "450px" }}>
<Table noWrapper>
<TableHeader>
<TableRow>
<TableHead className="bg-background sticky top-0 z-10 border-b shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
헤더
</TableHead>
</TableRow>
</TableHeader>
<TableBody>...</TableBody>
</Table>
</div>;
```
## 높이 설정 가이드
### 권장 높이값
- **소형 리스트**: `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<HTMLDivElement>(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
<div
className="relative overflow-auto"
style={{ height: "450px" }}
role="region"
aria-label="스크롤 가능한 데이터 테이블"
tabIndex={0}
>
<Table noWrapper aria-label="데이터 목록">
{/* 테이블 내용 */}
</Table>
</div>
```
### 키보드 네비게이션
```tsx
<TableRow
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
handleRowClick();
}
}}
>
{/* 행 내용 */}
</TableRow>
```
## 다크 모드 대응
### 배경색
```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에 고정 높이 설정
- [ ] `<Table noWrapper>` 사용
- [ ] TableHead에 `bg-background sticky top-0 z-10` 적용
- [ ] TableHead에 `border-b shadow-[...]` 적용
- [ ] 왼쪽 고정 열은 `z-20` 사용
- [ ] 모바일 반응형 대응 (카드 뷰)
- [ ] 다크 모드 호환 색상 사용

View File

@ -147,6 +147,14 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
flowData.nodes || [], flowData.nodes || [],
flowData.edges || [], 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) { } catch (error) {
console.error("플로우 로드 실패:", error); console.error("플로우 로드 실패:", error);
@ -155,7 +163,7 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
}; };
fetchAndLoadFlow(); fetchAndLoadFlow();
}, [initialFlowId]); }, [initialFlowId, loadFlow, selectNodes]);
/** /**
* *

View File

@ -3076,7 +3076,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}) })
: null; : null;
// 일반 컴포넌트만 격자 스냅 적용 (그룹 제외) // 일반 컴포넌트 및 플로우 버튼 그룹에 격자 스냅 적용 (일반 그룹 제외)
if (draggedComponent?.type !== "group" && layout.gridSettings?.snapToGrid && currentGridInfo) { if (draggedComponent?.type !== "group" && layout.gridSettings?.snapToGrid && currentGridInfo) {
finalPosition = snapToGrid( finalPosition = snapToGrid(
{ {
@ -3094,6 +3094,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
); );
console.log("🎯 격자 스냅 적용됨:", { console.log("🎯 격자 스냅 적용됨:", {
componentType: draggedComponent?.type,
resolution: `${screenResolution.width}x${screenResolution.height}`, resolution: `${screenResolution.width}x${screenResolution.height}`,
originalPosition: dragState.currentPosition, originalPosition: dragState.currentPosition,
snappedPosition: finalPosition, snappedPosition: finalPosition,
@ -3516,12 +3517,79 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 고유한 그룹 ID 생성 // 고유한 그룹 ID 생성
const newGroupId = generateGroupId(); 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 groupedButtons = selectedComponents.map((button) => {
const currentConfig = (button as any).webTypeConfig?.flowVisibilityConfig || {}; const currentConfig = (button as any).webTypeConfig?.flowVisibilityConfig || {};
// 모든 버튼을 그룹 시작점에 배치
// FlexBox가 자동으로 정렬하여 기준 버튼의 위치가 유지됨
const newPosition = {
x: groupX,
y: groupY,
z: button.position.z || 1,
};
return { return {
...button, ...button,
position: newPosition,
webTypeConfig: { webTypeConfig: {
...(button as any).webTypeConfig, ...(button as any).webTypeConfig,
flowVisibilityConfig: { flowVisibilityConfig: {
@ -3558,7 +3626,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
console.log("✅ 플로우 버튼 그룹 생성 완료:", { console.log("✅ 플로우 버튼 그룹 생성 완료:", {
groupId: newGroupId, groupId: newGroupId,
buttonCount: selectedComponents.length, 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, settings,
}); });
}, },
@ -4316,86 +4385,109 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
<div className="bg-card text-foreground border-border pointer-events-none fixed right-6 bottom-6 z-50 rounded-lg border px-4 py-2 text-sm font-medium shadow-md"> <div className="bg-card text-foreground border-border pointer-events-none fixed right-6 bottom-6 z-50 rounded-lg border px-4 py-2 text-sm font-medium shadow-md">
🔍 {Math.round(zoomLevel * 100)}% 🔍 {Math.round(zoomLevel * 100)}%
</div> </div>
{/* 🆕 플로우 버튼 그룹 제어 (다중 선택 시 표시) */} {/* 🆕 플로우 버튼 그룹 제어 (버튼 선택 시 표시) */}
{groupState.selectedComponents.length >= 2 && ( {(() => {
<div className="bg-card border-border fixed right-6 bottom-20 z-50 rounded-lg border shadow-lg"> // 선택된 컴포넌트들
<div className="flex flex-col gap-2 p-3"> const selectedComps = layout.components.filter((c) => groupState.selectedComponents.includes(c.id));
<div className="mb-1 flex items-center gap-2 text-xs text-gray-600">
<svg // 버튼 컴포넌트만 필터링
xmlns="http://www.w3.org/2000/svg" const selectedButtons = selectedComps.filter((comp) => areAllButtons([comp]));
width="14"
height="14" // 플로우 그룹에 속한 버튼이 있는지 확인
viewBox="0 0 24 24" const hasFlowGroupButton = selectedButtons.some((btn) => {
fill="none" const flowConfig = (btn as any).webTypeConfig?.flowVisibilityConfig;
stroke="currentColor" return flowConfig?.enabled && flowConfig.layoutBehavior === "auto-compact" && flowConfig.groupId;
strokeWidth="2" });
strokeLinecap="round"
strokeLinejoin="round" // 버튼이 선택되었거나 플로우 그룹 버튼이 있으면 표시
> const shouldShow = selectedButtons.length >= 1 && (selectedButtons.length >= 2 || hasFlowGroupButton);
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
<polyline points="3.29 7 12 12 20.71 7"></polyline> if (!shouldShow) return null;
<line x1="12" y1="22" x2="12" y2="12"></line>
</svg> return (
<span className="font-medium">{groupState.selectedComponents.length} </span> <div className="bg-card border-border fixed right-6 bottom-20 z-50 rounded-lg border shadow-lg">
<div className="flex flex-col gap-2 p-3">
<div className="mb-1 flex items-center gap-2 text-xs text-gray-600">
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
<polyline points="3.29 7 12 12 20.71 7"></polyline>
<line x1="12" y1="22" x2="12" y2="12"></line>
</svg>
<span className="font-medium">{selectedButtons.length} </span>
</div>
{/* 그룹 생성 버튼 (2개 이상 선택 시) */}
{selectedButtons.length >= 2 && (
<Button
size="sm"
variant="default"
onClick={handleFlowButtonGroup}
disabled={selectedButtons.length < 2}
className="flex items-center gap-2 text-xs"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="9" y1="3" x2="9" y2="21"></line>
<line x1="15" y1="3" x2="15" y2="21"></line>
</svg>
</Button>
)}
{/* 그룹 해제 버튼 (플로우 그룹 버튼이 있으면 항상 표시) */}
{hasFlowGroupButton && (
<Button
size="sm"
variant="outline"
onClick={handleFlowButtonUngroup}
className="flex items-center gap-2 text-xs"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
</Button>
)}
{/* 상태 표시 */}
{hasFlowGroupButton && <p className="mt-1 text-[10px] text-blue-600"> </p>}
</div> </div>
<Button
size="sm"
variant="default"
onClick={handleFlowButtonGroup}
disabled={
!areAllButtons(layout.components.filter((c) => groupState.selectedComponents.includes(c.id)))
}
className="flex items-center gap-2 text-xs"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="9" y1="3" x2="9" y2="21"></line>
<line x1="15" y1="3" x2="15" y2="21"></line>
</svg>
</Button>
<Button
size="sm"
variant="outline"
onClick={handleFlowButtonUngroup}
className="flex items-center gap-2 text-xs"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
</Button>
{areAllButtons(layout.components.filter((c) => groupState.selectedComponents.includes(c.id))) ? (
<p className="mt-1 text-[10px] text-green-600"> </p>
) : (
<p className="mt-1 text-[10px] text-orange-600"> </p>
)}
</div> </div>
</div> );
)} })()}
{/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 */} {/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 */}
<div <div
className="flex justify-center" className="flex justify-center"
@ -4707,20 +4799,20 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const groupConfig = (firstButton as any).webTypeConfig const groupConfig = (firstButton as any).webTypeConfig
?.flowVisibilityConfig as FlowVisibilityConfig; ?.flowVisibilityConfig as FlowVisibilityConfig;
// 🔧 그룹의 위치는 모든 버튼 중 가장 왼쪽/위쪽 버튼의 위치 사용 // 🔧 그룹의 위치 및 크기 계산
const groupPosition = buttons.reduce( // 모든 버튼이 같은 위치(groupX, groupY)에 배치되어 있으므로
(min, button) => ({ // 첫 번째 버튼의 위치를 그룹 시작점으로 사용
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 },
);
// 🆕 그룹의 크기 계산: 버튼들의 실제 크기 + 간격을 기준으로 계산
const direction = groupConfig.groupDirection || "horizontal"; const direction = groupConfig.groupDirection || "horizontal";
const gap = groupConfig.groupGap ?? 8; 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 groupWidth = 0;
let groupHeight = 0; let groupHeight = 0;
@ -4731,12 +4823,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const gapWidth = index < buttons.length - 1 ? gap : 0; const gapWidth = index < buttons.length - 1 ? gap : 0;
return total + buttonWidth + gapWidth; return total + buttonWidth + gapWidth;
}, 0); }, 0);
// 세로는 가장 큰 버튼의 높이
groupHeight = Math.max(...buttons.map((b) => b.size?.height || 40)); groupHeight = Math.max(...buttons.map((b) => b.size?.height || 40));
} else { } else {
// 세로 정렬: 가로는 가장 큰 버튼의 너비 // 세로 정렬
groupWidth = Math.max(...buttons.map((b) => b.size?.width || 100)); groupWidth = Math.max(...buttons.map((b) => b.size?.width || 100));
// 세로는 모든 버튼의 높이 + 간격
groupHeight = buttons.reduce((total, button, index) => { groupHeight = buttons.reduce((total, button, index) => {
const buttonHeight = button.size?.height || 40; const buttonHeight = button.size?.height || 40;
const gapHeight = index < buttons.length - 1 ? gap : 0; const gapHeight = index < buttons.length - 1 ? gap : 0;
@ -4744,6 +4834,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}, 0); }, 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 ( return (
<div <div
key={`flow-button-group-${groupId}`} key={`flow-button-group-${groupId}`}
@ -4754,7 +4852,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
zIndex: groupPosition.z, zIndex: groupPosition.z,
width: `${groupWidth}px`, // 🆕 명시적 너비 width: `${groupWidth}px`, // 🆕 명시적 너비
height: `${groupHeight}px`, // 🆕 명시적 높이 height: `${groupHeight}px`, // 🆕 명시적 높이
pointerEvents: "none", // 그룹 컨테이너는 이벤트 차단하여 개별 버튼 클릭 가능
}} }}
className={hasAnySelected ? "rounded outline-2 outline-offset-2 outline-blue-500" : ""}
> >
<FlowButtonGroup <FlowButtonGroup
buttons={buttons} buttons={buttons}
@ -4805,10 +4905,62 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
display: "inline-block", display: "inline-block",
width: button.size?.width || 100, width: button.size?.width || 100,
height: button.size?.height || 40, height: button.size?.height || 40,
pointerEvents: "auto", // 개별 버튼은 이벤트 활성화
cursor: "pointer",
}} }}
onClick={(e) => { onMouseDown={(e) => {
// 클릭이 아닌 드래그인 경우에만 드래그 시작
e.preventDefault();
e.stopPropagation(); 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) => { onDoubleClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@ -4817,12 +4969,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
className={ className={
selectedComponent?.id === button.id || selectedComponent?.id === button.id ||
groupState.selectedComponents.includes(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로 직접 렌더링 */} {/* 그룹 내부에서는 DynamicComponentRenderer로 직접 렌더링 */}
<div style={{ width: "100%", height: "100%" }}> <div style={{ width: "100%", height: "100%", pointerEvents: "none" }}>
<DynamicComponentRenderer <DynamicComponentRenderer
component={relativeButton} component={relativeButton}
isDesignMode={true} isDesignMode={true}

View File

@ -195,6 +195,32 @@ export function FlowWidget({
setStepCounts(countsMap); setStepCounts(countsMap);
} }
} }
// 🆕 플로우 로드 후 첫 번째 스텝 자동 선택
if (sortedSteps.length > 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) { } catch (err: any) {
console.error("Failed to load flow data:", err); console.error("Failed to load flow data:", err);
@ -732,12 +758,12 @@ export function FlowWidget({
</div> </div>
{/* 데스크톱: 테이블 뷰 - 고정 높이 + 스크롤 */} {/* 데스크톱: 테이블 뷰 - 고정 높이 + 스크롤 */}
<div className="hidden overflow-auto @sm:block" style={{ height: "450px" }}> <div className="relative hidden overflow-auto @sm:block" style={{ height: "450px" }}>
<Table> <Table noWrapper>
<TableHeader> <TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50"> <TableRow className="hover:bg-muted/50">
{allowDataMove && ( {allowDataMove && (
<TableHead className="bg-muted/50 sticky top-0 left-0 z-20 w-12 border-b px-3 py-2 text-center shadow-sm"> <TableHead className="bg-background sticky top-0 left-0 z-20 w-12 border-b px-3 py-2 text-center shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
<Checkbox <Checkbox
checked={selectedRows.size === stepData.length && stepData.length > 0} checked={selectedRows.size === stepData.length && stepData.length > 0}
onCheckedChange={toggleAllRows} onCheckedChange={toggleAllRows}
@ -747,7 +773,7 @@ export function FlowWidget({
{stepDataColumns.map((col) => ( {stepDataColumns.map((col) => (
<TableHead <TableHead
key={col} key={col}
className="bg-muted/50 sticky top-0 z-10 border-b px-3 py-2 text-xs font-semibold whitespace-nowrap sm:text-sm" className="bg-background sticky top-0 z-10 border-b px-3 py-2 text-xs font-semibold whitespace-nowrap shadow-[0_1px_0_0_rgb(0,0,0,0.1)] sm:text-sm"
> >
{col} {col}
</TableHead> </TableHead>