1217 lines
41 KiB
Markdown
1217 lines
41 KiB
Markdown
|
|
# 플로우 단계별 버튼 표시/숨김 제어 시스템 구현 계획서
|
|||
|
|
|
|||
|
|
## 📋 목차
|
|||
|
|
1. [개요](#개요)
|
|||
|
|
2. [요구사항 분석](#요구사항-분석)
|
|||
|
|
3. [UI/UX 문제 및 해결방안](#uiux-문제-및-해결방안)
|
|||
|
|
4. [시스템 아키텍처](#시스템-아키텍처)
|
|||
|
|
5. [구현 상세](#구현-상세)
|
|||
|
|
6. [데이터 구조](#데이터-구조)
|
|||
|
|
7. [구현 단계](#구현-단계)
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 개요
|
|||
|
|
|
|||
|
|
### 목적
|
|||
|
|
플로우 위젯과 버튼 컴포넌트를 함께 사용할 때, 각 플로우 단계(Step)별로 특정 버튼만 표시하거나 숨기는 기능을 제공합니다.
|
|||
|
|
|
|||
|
|
### 핵심 시나리오
|
|||
|
|
```
|
|||
|
|
예시: 계약 프로세스 플로우
|
|||
|
|
- Step 1 (구매 등록): [설치 자재 정보 수정] 버튼만 표시
|
|||
|
|
- Step 2 (설치팀): [설치 자재 정보 수정], [수리증] 버튼 표시
|
|||
|
|
- Step 3 (사용중): [설치 자재 정보 수정], [수리증], [폐기 처리] 버튼 모두 표시
|
|||
|
|
- Step 4 (수리중): [수리증] 버튼만 표시
|
|||
|
|
- Step 5 (폐기): 모든 버튼 숨김
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 요구사항 분석
|
|||
|
|
|
|||
|
|
### 1. 기능적 요구사항
|
|||
|
|
|
|||
|
|
#### FR-1: 자동 감지 및 설정 UI 표시
|
|||
|
|
- **설명**: 화면 편집기에 플로우 위젯과 버튼이 함께 배치되면, 버튼 설정 패널에 자동으로 "플로우 단계별 표시 설정" UI가 나타남
|
|||
|
|
- **조건**:
|
|||
|
|
- 화면에 1개 이상의 플로우 위젯 존재
|
|||
|
|
- 버튼 컴포넌트 선택 시
|
|||
|
|
- **동작**:
|
|||
|
|
```
|
|||
|
|
1. 화면의 모든 플로우 위젯 감지
|
|||
|
|
2. 각 플로우의 단계 목록 조회
|
|||
|
|
3. 버튼 속성 패널에 "플로우 단계별 표시 설정" 섹션 추가
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### FR-2: 다중 단계 선택
|
|||
|
|
- **설명**: 한 버튼이 여러 플로우 단계에서 동시에 표시될 수 있음
|
|||
|
|
- **예시**:
|
|||
|
|
```
|
|||
|
|
[설치 자재 정보 수정] 버튼
|
|||
|
|
- Step 1 ✅ 표시
|
|||
|
|
- Step 2 ✅ 표시
|
|||
|
|
- Step 3 ✅ 표시
|
|||
|
|
- Step 4 ❌ 숨김
|
|||
|
|
- Step 5 ❌ 숨김
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### FR-3: 버튼별 독립 설정
|
|||
|
|
- **설명**: 각 버튼은 서로 다른 플로우 단계 표시 설정을 가질 수 있음
|
|||
|
|
- **예시**:
|
|||
|
|
```
|
|||
|
|
화면에 3개 버튼:
|
|||
|
|
- [버튼 A]: Step 1, 2에서만 표시
|
|||
|
|
- [버튼 B]: Step 2, 3, 4에서 표시
|
|||
|
|
- [버튼 C]: 모든 단계에서 표시
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### FR-4: 다중 플로우 위젯 지원
|
|||
|
|
- **설명**: 화면에 2개 이상의 플로우 위젯이 있을 경우, 버튼이 어느 플로우에 반응할지 선택 가능
|
|||
|
|
- **예시**:
|
|||
|
|
```
|
|||
|
|
화면 구성:
|
|||
|
|
- 플로우 위젯 A (계약 프로세스)
|
|||
|
|
- 플로우 위젯 B (AS 프로세스)
|
|||
|
|
- [저장] 버튼: 플로우 A의 단계에 따라 표시/숨김
|
|||
|
|
- [AS 접수] 버튼: 플로우 B의 단계에 따라 표시/숨김
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### FR-5: 실시간 반응
|
|||
|
|
- **설명**: 플로우 위젯에서 단계를 클릭하면, 버튼들이 즉시 표시/숨김 전환
|
|||
|
|
- **성능**: 50ms 이내 반응
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 2. UI/UX 요구사항
|
|||
|
|
|
|||
|
|
#### UX-1: 빈 공간 문제 해결
|
|||
|
|
**문제**: 버튼이 여러 개 배치되어 있을 때, 중간 버튼이 숨겨지면 빈 공간이 생김
|
|||
|
|
|
|||
|
|
**해결방안 옵션**:
|
|||
|
|
|
|||
|
|
##### 옵션 A: Flexbox 자동 정렬 (권장)
|
|||
|
|
```tsx
|
|||
|
|
// 버튼 컨테이너를 자동으로 감지하고 Flexbox로 변환
|
|||
|
|
<div className="flex gap-2 flex-wrap">
|
|||
|
|
{visibleButtons.map(button => <Button />)}
|
|||
|
|
</div>
|
|||
|
|
```
|
|||
|
|
**장점**:
|
|||
|
|
- 자동으로 빈 공간 채움
|
|||
|
|
- CSS 기반으로 성능 우수
|
|||
|
|
- 반응형 대응 자동
|
|||
|
|
|
|||
|
|
**단점**:
|
|||
|
|
- 기존 절대 위치(absolute positioning) 레이아웃과 충돌 가능
|
|||
|
|
|
|||
|
|
##### 옵션 B: 조건부 렌더링 (현재 구조 유지)
|
|||
|
|
```tsx
|
|||
|
|
// 각 버튼을 원래 위치에 렌더링하되, 보이지 않는 버튼은 display: none
|
|||
|
|
{buttons.map(button => (
|
|||
|
|
<div
|
|||
|
|
key={button.id}
|
|||
|
|
style={{
|
|||
|
|
display: shouldShow(button) ? 'block' : 'none',
|
|||
|
|
position: 'absolute',
|
|||
|
|
left: button.x,
|
|||
|
|
top: button.y
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<Button />
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
```
|
|||
|
|
**장점**:
|
|||
|
|
- 현재 레이아웃 시스템 유지
|
|||
|
|
- 구현 간단
|
|||
|
|
|
|||
|
|
**단점**:
|
|||
|
|
- 빈 공간 그대로 남음
|
|||
|
|
- 디자이너가 의도한 배치가 깨질 수 있음
|
|||
|
|
|
|||
|
|
##### 옵션 C: 그룹 기반 재정렬 (하이브리드)
|
|||
|
|
```tsx
|
|||
|
|
// 버튼들을 그룹으로 묶고, 그룹 내에서만 재정렬
|
|||
|
|
<ButtonGroup id="action-buttons" layout="auto-compact">
|
|||
|
|
<Button id="btn1" />
|
|||
|
|
<Button id="btn2" />
|
|||
|
|
<Button id="btn3" />
|
|||
|
|
</ButtonGroup>
|
|||
|
|
```
|
|||
|
|
**장점**:
|
|||
|
|
- 디자이너가 그룹 단위로 제어 가능
|
|||
|
|
- 빈 공간 문제 해결
|
|||
|
|
- 유연성과 일관성 균형
|
|||
|
|
|
|||
|
|
**단점**:
|
|||
|
|
- 새로운 "ButtonGroup" 개념 도입 필요
|
|||
|
|
|
|||
|
|
**🎯 권장 방안: 옵션 C (그룹 기반 재정렬)**
|
|||
|
|
|
|||
|
|
#### UX-2: 설정 UI 디자인
|
|||
|
|
|
|||
|
|
##### 2-1. 버튼 속성 패널 (ScreenDesigner)
|
|||
|
|
```
|
|||
|
|
┌─────────────────────────────────────┐
|
|||
|
|
│ 버튼 속성 │
|
|||
|
|
├─────────────────────────────────────┤
|
|||
|
|
│ 기본 설정 │
|
|||
|
|
│ ├─ 텍스트: [설치 자재 정보 수정] │
|
|||
|
|
│ ├─ 액션: [저장] │
|
|||
|
|
│ └─ 스타일: [Primary] │
|
|||
|
|
├─────────────────────────────────────┤
|
|||
|
|
│ 플로우 단계별 표시 설정 🆕 │
|
|||
|
|
│ │
|
|||
|
|
│ ⚠️ 화면에 플로우 위젯이 있습니다 │
|
|||
|
|
│ │
|
|||
|
|
│ [✓] 플로우 단계에 따라 버튼 표시 제어 │
|
|||
|
|
│ │
|
|||
|
|
│ 대상 플로우: │
|
|||
|
|
│ ┌────────────────────────────┐ │
|
|||
|
|
│ │ 계약 관리 플로우 ▼ │ │
|
|||
|
|
│ └────────────────────────────┘ │
|
|||
|
|
│ │
|
|||
|
|
│ 표시할 단계 (중복 선택 가능): │
|
|||
|
|
│ □ Step 1: 구매 등록 │
|
|||
|
|
│ ☑ Step 2: 설치팀 │
|
|||
|
|
│ ☑ Step 3: 사용중 │
|
|||
|
|
│ □ Step 4: 수리중 │
|
|||
|
|
│ □ Step 5: 폐기 │
|
|||
|
|
│ │
|
|||
|
|
│ 빠른 선택: │
|
|||
|
|
│ [모두 선택] [모두 해제] [반전] │
|
|||
|
|
│ │
|
|||
|
|
│ 레이아웃 옵션: │
|
|||
|
|
│ ○ 원래 위치 유지 (빈 공간 가능) │
|
|||
|
|
│ ● 자동 정렬 (빈 공간 제거) ⭐ │
|
|||
|
|
│ │
|
|||
|
|
│ 미리보기: │
|
|||
|
|
│ Step 2 → [버튼 표시] ✅ │
|
|||
|
|
│ Step 1 → [버튼 숨김] ❌ │
|
|||
|
|
└─────────────────────────────────────┘
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
##### 2-2. 플로우 위젯이 없을 때
|
|||
|
|
```
|
|||
|
|
┌─────────────────────────────────────┐
|
|||
|
|
│ 버튼 속성 │
|
|||
|
|
├─────────────────────────────────────┤
|
|||
|
|
│ 기본 설정 │
|
|||
|
|
│ ├─ 텍스트: [저장] │
|
|||
|
|
│ ├─ 액션: [저장] │
|
|||
|
|
│ └─ 스타일: [Primary] │
|
|||
|
|
├─────────────────────────────────────┤
|
|||
|
|
│ 플로우 단계별 표시 설정 │
|
|||
|
|
│ │
|
|||
|
|
│ ℹ️ 화면에 플로우 위젯을 추가하면 │
|
|||
|
|
│ 단계별 버튼 표시 제어가 가능합니다 │
|
|||
|
|
│ │
|
|||
|
|
│ [+ 플로우 위젯 추가하기] │
|
|||
|
|
└─────────────────────────────────────┘
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### UX-3: 시각적 피드백
|
|||
|
|
|
|||
|
|
##### 3-1. 디자이너 캔버스
|
|||
|
|
```tsx
|
|||
|
|
// 플로우 단계 선택 시, 영향받는 버튼에 시각적 표시
|
|||
|
|
<Button
|
|||
|
|
className={cn(
|
|||
|
|
shouldShowInCurrentStep ? "ring-2 ring-green-500" : "opacity-30 ring-2 ring-red-500"
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
저장
|
|||
|
|
</Button>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
##### 3-2. 버튼 컴포넌트 뱃지
|
|||
|
|
```tsx
|
|||
|
|
// 플로우 제어가 활성화된 버튼에 뱃지 표시
|
|||
|
|
<Button>
|
|||
|
|
저장
|
|||
|
|
{hasFlowControl && (
|
|||
|
|
<Badge className="ml-2">
|
|||
|
|
<Workflow className="h-3 w-3" />
|
|||
|
|
</Badge>
|
|||
|
|
)}
|
|||
|
|
</Button>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 시스템 아키텍처
|
|||
|
|
|
|||
|
|
### 1. 전역 상태 관리 (Zustand Store)
|
|||
|
|
|
|||
|
|
#### FlowStepStore
|
|||
|
|
```typescript
|
|||
|
|
// stores/flowStepStore.ts
|
|||
|
|
import create from 'zustand';
|
|||
|
|
|
|||
|
|
interface FlowStepState {
|
|||
|
|
// 현재 선택된 플로우 단계 (화면당 여러 플로우 가능)
|
|||
|
|
selectedSteps: Record<string, number | null>; // key: flowId, value: stepId
|
|||
|
|
|
|||
|
|
// 플로우 단계 선택
|
|||
|
|
setSelectedStep: (flowId: string, stepId: number | null) => void;
|
|||
|
|
|
|||
|
|
// 특정 플로우의 현재 단계 가져오기
|
|||
|
|
getCurrentStep: (flowId: string) => number | null;
|
|||
|
|
|
|||
|
|
// 초기화
|
|||
|
|
reset: () => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export const useFlowStepStore = create<FlowStepState>((set, get) => ({
|
|||
|
|
selectedSteps: {},
|
|||
|
|
|
|||
|
|
setSelectedStep: (flowId, stepId) =>
|
|||
|
|
set((state) => ({
|
|||
|
|
selectedSteps: { ...state.selectedSteps, [flowId]: stepId },
|
|||
|
|
})),
|
|||
|
|
|
|||
|
|
getCurrentStep: (flowId) => get().selectedSteps[flowId] || null,
|
|||
|
|
|
|||
|
|
reset: () => set({ selectedSteps: {} }),
|
|||
|
|
}));
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2. 데이터 흐름
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
┌─────────────────┐
|
|||
|
|
│ FlowWidget │
|
|||
|
|
│ │
|
|||
|
|
│ 단계 클릭 │ ─────┐
|
|||
|
|
└─────────────────┘ │
|
|||
|
|
│ setSelectedStep(flowId, stepId)
|
|||
|
|
▼
|
|||
|
|
┌──────────────────┐
|
|||
|
|
│ FlowStepStore │
|
|||
|
|
│ (Zustand) │
|
|||
|
|
│ │
|
|||
|
|
│ selectedSteps │
|
|||
|
|
└──────────────────┘
|
|||
|
|
│
|
|||
|
|
│ useFlowStepStore()
|
|||
|
|
│
|
|||
|
|
┌────────────────┼────────────────┐
|
|||
|
|
▼ ▼ ▼
|
|||
|
|
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
|||
|
|
│ Button A │ │ Button B │ │ Button C │
|
|||
|
|
│ │ │ │ │ │
|
|||
|
|
│ Step 1,2,3 │ │ Step 2,3,4 │ │ All Steps │
|
|||
|
|
│ │ │ │ │ │
|
|||
|
|
│ 현재: 표시 │ │ 현재: 표시 │ │ 현재: 표시 │
|
|||
|
|
└──────────────┘ └──────────────┘ └──────────────┘
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 3. 컴포넌트 계층 구조
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
ScreenDesigner
|
|||
|
|
├── FlowWidget (컴포넌트 ID: "flow-1")
|
|||
|
|
│ ├── Step 1 카드
|
|||
|
|
│ ├── Step 2 카드 ← 클릭됨
|
|||
|
|
│ └── Step 3 카드
|
|||
|
|
│
|
|||
|
|
├── ButtonGroup (레이아웃 컨테이너)
|
|||
|
|
│ ├── Button A (flowVisibilityConfig: { flowId: "flow-1", visibleSteps: [1,2] })
|
|||
|
|
│ │ └── 현재 숨김 (Step 2 ∉ [1,2] ❌)
|
|||
|
|
│ │
|
|||
|
|
│ ├── Button B (flowVisibilityConfig: { flowId: "flow-1", visibleSteps: [2,3,4] })
|
|||
|
|
│ │ └── 현재 표시 (Step 2 ∈ [2,3,4] ✅)
|
|||
|
|
│ │
|
|||
|
|
│ └── Button C (flowVisibilityConfig: null)
|
|||
|
|
│ └── 항상 표시 ✅
|
|||
|
|
│
|
|||
|
|
└── PropertiesPanel
|
|||
|
|
└── FlowVisibilityConfigPanel (버튼 선택 시)
|
|||
|
|
├── 대상 플로우 선택 Dropdown
|
|||
|
|
├── 단계 체크박스 목록
|
|||
|
|
└── 레이아웃 옵션
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 데이터 구조
|
|||
|
|
|
|||
|
|
### 1. ButtonTypeConfig 확장
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// types/screen-management.ts
|
|||
|
|
|
|||
|
|
export interface ButtonTypeConfig {
|
|||
|
|
// 기존 설정
|
|||
|
|
actionType: ButtonActionType;
|
|||
|
|
text?: string;
|
|||
|
|
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
|
|||
|
|
size?: "sm" | "md" | "lg";
|
|||
|
|
icon?: string;
|
|||
|
|
|
|||
|
|
// ... 기존 필드들 ...
|
|||
|
|
|
|||
|
|
// 🆕 플로우 단계별 표시 제어
|
|||
|
|
flowVisibilityConfig?: FlowVisibilityConfig;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 플로우 단계별 버튼 표시 설정
|
|||
|
|
*/
|
|||
|
|
export interface FlowVisibilityConfig {
|
|||
|
|
// 활성화 여부
|
|||
|
|
enabled: boolean;
|
|||
|
|
|
|||
|
|
// 대상 플로우 (컴포넌트 ID)
|
|||
|
|
targetFlowComponentId: string; // 예: "component-123"
|
|||
|
|
targetFlowId?: number; // 플로우 정의 ID (선택사항, 검증용)
|
|||
|
|
targetFlowName?: string; // 플로우 이름 (표시용)
|
|||
|
|
|
|||
|
|
// 표시 조건
|
|||
|
|
mode: "whitelist" | "blacklist" | "all";
|
|||
|
|
// - whitelist: visibleSteps에 포함된 단계에서만 표시
|
|||
|
|
// - blacklist: hiddenSteps에 포함된 단계에서 숨김
|
|||
|
|
// - all: 모든 단계에서 표시 (기본값)
|
|||
|
|
|
|||
|
|
visibleSteps?: number[]; // mode="whitelist"일 때 사용
|
|||
|
|
hiddenSteps?: number[]; // mode="blacklist"일 때 사용
|
|||
|
|
|
|||
|
|
// 레이아웃 옵션
|
|||
|
|
layoutBehavior: "preserve-position" | "auto-compact";
|
|||
|
|
// - preserve-position: 원래 위치 유지 (display: none)
|
|||
|
|
// - auto-compact: 빈 공간 자동 제거 (Flexbox)
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2. ComponentData 확장
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// types/screen.ts
|
|||
|
|
|
|||
|
|
export interface ComponentData {
|
|||
|
|
// 기존 필드들...
|
|||
|
|
|
|||
|
|
// 🆕 버튼 그룹 정보 (옵션 C 구현 시)
|
|||
|
|
buttonGroupId?: string; // 버튼 그룹 ID
|
|||
|
|
buttonGroupLayout?: "horizontal" | "vertical" | "grid";
|
|||
|
|
buttonGroupAlign?: "start" | "center" | "end" | "space-between";
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 3. 데이터베이스 스키마 (선택사항)
|
|||
|
|
|
|||
|
|
플로우 설정을 DB에 저장하려면:
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
-- screen_components 테이블에 컬럼 추가
|
|||
|
|
ALTER TABLE screen_components
|
|||
|
|
ADD COLUMN flow_visibility_config JSONB NULL;
|
|||
|
|
|
|||
|
|
-- 인덱스 추가 (쿼리 성능)
|
|||
|
|
CREATE INDEX idx_screen_components_flow_visibility
|
|||
|
|
ON screen_components USING GIN (flow_visibility_config);
|
|||
|
|
|
|||
|
|
-- 예시 데이터
|
|||
|
|
{
|
|||
|
|
"enabled": true,
|
|||
|
|
"targetFlowComponentId": "component-123",
|
|||
|
|
"targetFlowId": 5,
|
|||
|
|
"targetFlowName": "계약 관리 플로우",
|
|||
|
|
"mode": "whitelist",
|
|||
|
|
"visibleSteps": [1, 2, 3],
|
|||
|
|
"layoutBehavior": "auto-compact"
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 구현 상세
|
|||
|
|
|
|||
|
|
### Phase 1: 전역 상태 관리
|
|||
|
|
|
|||
|
|
#### 파일: `frontend/stores/flowStepStore.ts`
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
"use client";
|
|||
|
|
|
|||
|
|
import { create } from "zustand";
|
|||
|
|
import { devtools } from "zustand/middleware";
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 플로우 단계 전역 상태 관리
|
|||
|
|
*/
|
|||
|
|
interface FlowStepState {
|
|||
|
|
// 화면당 여러 플로우의 현재 선택된 단계
|
|||
|
|
// key: flowComponentId (예: "component-123")
|
|||
|
|
selectedSteps: Record<string, number | null>;
|
|||
|
|
|
|||
|
|
// 플로우 단계 선택
|
|||
|
|
setSelectedStep: (flowComponentId: string, stepId: number | null) => void;
|
|||
|
|
|
|||
|
|
// 현재 단계 조회
|
|||
|
|
getCurrentStep: (flowComponentId: string) => number | null;
|
|||
|
|
|
|||
|
|
// 모든 플로우 초기화
|
|||
|
|
reset: () => void;
|
|||
|
|
|
|||
|
|
// 특정 플로우만 초기화
|
|||
|
|
resetFlow: (flowComponentId: string) => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export const useFlowStepStore = create<FlowStepState>()(
|
|||
|
|
devtools(
|
|||
|
|
(set, get) => ({
|
|||
|
|
selectedSteps: {},
|
|||
|
|
|
|||
|
|
setSelectedStep: (flowComponentId, stepId) => {
|
|||
|
|
console.log("🔄 플로우 단계 변경:", { flowComponentId, stepId });
|
|||
|
|
set((state) => ({
|
|||
|
|
selectedSteps: {
|
|||
|
|
...state.selectedSteps,
|
|||
|
|
[flowComponentId]: stepId,
|
|||
|
|
},
|
|||
|
|
}));
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
getCurrentStep: (flowComponentId) => {
|
|||
|
|
return get().selectedSteps[flowComponentId] || null;
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
reset: () => {
|
|||
|
|
console.log("🔄 모든 플로우 단계 초기화");
|
|||
|
|
set({ selectedSteps: {} });
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
resetFlow: (flowComponentId) => {
|
|||
|
|
console.log("🔄 플로우 단계 초기화:", flowComponentId);
|
|||
|
|
set((state) => {
|
|||
|
|
const { [flowComponentId]: _, ...rest } = state.selectedSteps;
|
|||
|
|
return { selectedSteps: rest };
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
}),
|
|||
|
|
{ name: "FlowStepStore" }
|
|||
|
|
)
|
|||
|
|
);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Phase 2: FlowWidget 수정
|
|||
|
|
|
|||
|
|
#### 파일: `frontend/components/screen/widgets/FlowWidget.tsx`
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// 기존 imports...
|
|||
|
|
import { useFlowStepStore } from "@/stores/flowStepStore";
|
|||
|
|
|
|||
|
|
interface FlowWidgetProps {
|
|||
|
|
component: FlowComponent;
|
|||
|
|
onStepClick?: (stepId: number, stepName: string) => void;
|
|||
|
|
onSelectedDataChange?: (selectedData: any[], stepId: number | null) => void;
|
|||
|
|
flowRefreshKey?: number;
|
|||
|
|
onFlowRefresh?: () => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function FlowWidget({ component, onStepClick, ... }: FlowWidgetProps) {
|
|||
|
|
// 🆕 전역 상태 관리
|
|||
|
|
const setSelectedStep = useFlowStepStore((state) => state.setSelectedStep);
|
|||
|
|
const resetFlow = useFlowStepStore((state) => state.resetFlow);
|
|||
|
|
|
|||
|
|
// 기존 states...
|
|||
|
|
const [selectedStepId, setSelectedStepId] = useState<number | null>(null);
|
|||
|
|
|
|||
|
|
// componentId (플로우 컴포넌트 고유 ID)
|
|||
|
|
const flowComponentId = component.id;
|
|||
|
|
|
|||
|
|
// 🆕 단계 클릭 핸들러 수정
|
|||
|
|
const handleStepClick = async (stepId: number, stepName: string) => {
|
|||
|
|
// 외부 콜백 실행
|
|||
|
|
if (onStepClick) {
|
|||
|
|
onStepClick(stepId, stepName);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 같은 스텝 클릭 시 해제
|
|||
|
|
if (selectedStepId === stepId) {
|
|||
|
|
setSelectedStepId(null);
|
|||
|
|
setSelectedStep(flowComponentId, null); // 🆕 전역 상태 업데이트
|
|||
|
|
setStepData([]);
|
|||
|
|
setStepDataColumns([]);
|
|||
|
|
setSelectedRows(new Set());
|
|||
|
|
onSelectedDataChange?.([], null);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 새로운 스텝 선택
|
|||
|
|
setSelectedStepId(stepId);
|
|||
|
|
setSelectedStep(flowComponentId, stepId); // 🆕 전역 상태 업데이트
|
|||
|
|
|
|||
|
|
// 기존 데이터 로드 로직...
|
|||
|
|
setStepDataLoading(true);
|
|||
|
|
setSelectedRows(new Set());
|
|||
|
|
onSelectedDataChange?.([], stepId);
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
// 데이터 로딩...
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error("Failed to load step data:", err);
|
|||
|
|
toast.error(err.message || "데이터를 불러오는데 실패했습니다");
|
|||
|
|
} finally {
|
|||
|
|
setStepDataLoading(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 🆕 언마운트 시 상태 초기화
|
|||
|
|
useEffect(() => {
|
|||
|
|
return () => {
|
|||
|
|
resetFlow(flowComponentId);
|
|||
|
|
};
|
|||
|
|
}, [flowComponentId, resetFlow]);
|
|||
|
|
|
|||
|
|
// 기존 렌더링 로직...
|
|||
|
|
return (
|
|||
|
|
<div className="@container min-h-full w-full p-2 sm:p-4 lg:p-6">
|
|||
|
|
{/* ... */}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Phase 3: 버튼 표시/숨김 로직
|
|||
|
|
|
|||
|
|
#### 파일: `frontend/components/screen/OptimizedButtonComponent.tsx`
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// 기존 imports...
|
|||
|
|
import { useFlowStepStore } from "@/stores/flowStepStore";
|
|||
|
|
import { useMemo } from "react";
|
|||
|
|
|
|||
|
|
export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
|
|||
|
|
component,
|
|||
|
|
...props
|
|||
|
|
}) => {
|
|||
|
|
const config = component.webTypeConfig || {};
|
|||
|
|
const flowConfig = config.flowVisibilityConfig;
|
|||
|
|
|
|||
|
|
// 🆕 현재 플로우 단계 구독
|
|||
|
|
const currentStep = useFlowStepStore((state) => {
|
|||
|
|
if (!flowConfig?.enabled || !flowConfig.targetFlowComponentId) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
return state.getCurrentStep(flowConfig.targetFlowComponentId);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 🆕 버튼 표시 여부 계산
|
|||
|
|
const shouldShowButton = useMemo(() => {
|
|||
|
|
// 플로우 제어 비활성화 시 항상 표시
|
|||
|
|
if (!flowConfig?.enabled) {
|
|||
|
|
console.log("🔍 버튼 표시 체크 (플로우 제어 비활성):", component.id, "→ 항상 표시");
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 플로우 단계가 선택되지 않은 경우
|
|||
|
|
if (currentStep === null) {
|
|||
|
|
console.log("🔍 버튼 표시 체크 (단계 미선택):", component.id, "→ 항상 표시");
|
|||
|
|
return true; // 기본값: 표시
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { mode, visibleSteps = [], hiddenSteps = [] } = flowConfig;
|
|||
|
|
|
|||
|
|
let result = true;
|
|||
|
|
if (mode === "whitelist") {
|
|||
|
|
result = visibleSteps.includes(currentStep);
|
|||
|
|
} else if (mode === "blacklist") {
|
|||
|
|
result = !hiddenSteps.includes(currentStep);
|
|||
|
|
} else if (mode === "all") {
|
|||
|
|
result = true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log("🔍 버튼 표시 체크:", {
|
|||
|
|
buttonId: component.id,
|
|||
|
|
currentStep,
|
|||
|
|
mode,
|
|||
|
|
visibleSteps,
|
|||
|
|
hiddenSteps,
|
|||
|
|
result: result ? "표시" : "숨김",
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return result;
|
|||
|
|
}, [flowConfig, currentStep, component.id]);
|
|||
|
|
|
|||
|
|
// 🆕 숨김 처리
|
|||
|
|
if (!shouldShowButton) {
|
|||
|
|
// 레이아웃 동작에 따라 다르게 처리
|
|||
|
|
if (flowConfig?.layoutBehavior === "preserve-position") {
|
|||
|
|
// 위치 유지 (빈 공간)
|
|||
|
|
return <div style={{ display: "none" }} />;
|
|||
|
|
} else {
|
|||
|
|
// 완전히 렌더링하지 않음 (auto-compact)
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 기존 버튼 렌더링 로직...
|
|||
|
|
return (
|
|||
|
|
<div className="relative">
|
|||
|
|
<Button
|
|||
|
|
onClick={handleClick}
|
|||
|
|
disabled={isExecuting || disabled}
|
|||
|
|
variant={config?.variant || "default"}
|
|||
|
|
// ...
|
|||
|
|
>
|
|||
|
|
{/* ... */}
|
|||
|
|
</Button>
|
|||
|
|
|
|||
|
|
{/* 🆕 플로우 제어 활성화 표시 */}
|
|||
|
|
{flowConfig?.enabled && (
|
|||
|
|
<div className="absolute -right-1 -top-1">
|
|||
|
|
<Badge variant="outline" className="h-4 bg-white px-1 text-xs">
|
|||
|
|
<Workflow className="h-3 w-3" />
|
|||
|
|
</Badge>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Phase 4: 설정 UI
|
|||
|
|
|
|||
|
|
#### 파일: `frontend/components/screen/config-panels/FlowVisibilityConfigPanel.tsx`
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
"use client";
|
|||
|
|
|
|||
|
|
import React, { useState, useEffect, useMemo } from "react";
|
|||
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|||
|
|
import { Label } from "@/components/ui/label";
|
|||
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|||
|
|
import { Button } from "@/components/ui/button";
|
|||
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|||
|
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|||
|
|
import { Badge } from "@/components/ui/badge";
|
|||
|
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|||
|
|
import { Workflow, Info, CheckCircle, XCircle } from "lucide-react";
|
|||
|
|
import { ComponentData } from "@/types/screen";
|
|||
|
|
import { FlowVisibilityConfig } from "@/types/screen-management";
|
|||
|
|
import { getFlowById } from "@/lib/api/flow";
|
|||
|
|
import type { FlowDefinition, FlowStep } from "@/types/flow";
|
|||
|
|
import { toast } from "sonner";
|
|||
|
|
|
|||
|
|
interface FlowVisibilityConfigPanelProps {
|
|||
|
|
component: ComponentData; // 현재 선택된 버튼
|
|||
|
|
allComponents: ComponentData[]; // 화면의 모든 컴포넌트
|
|||
|
|
onUpdateProperty: (path: string, value: any) => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps> = ({
|
|||
|
|
component,
|
|||
|
|
allComponents,
|
|||
|
|
onUpdateProperty,
|
|||
|
|
}) => {
|
|||
|
|
// 현재 설정
|
|||
|
|
const currentConfig: FlowVisibilityConfig | undefined = (component as any).webTypeConfig?.flowVisibilityConfig;
|
|||
|
|
|
|||
|
|
// 화면의 모든 플로우 위젯 찾기
|
|||
|
|
const flowWidgets = useMemo(() => {
|
|||
|
|
return allComponents.filter((comp) => {
|
|||
|
|
const isFlowWidget =
|
|||
|
|
comp.type === "flow" ||
|
|||
|
|
(comp.type === "component" && (comp as any).componentConfig?.type === "flow-widget");
|
|||
|
|
return isFlowWidget;
|
|||
|
|
});
|
|||
|
|
}, [allComponents]);
|
|||
|
|
|
|||
|
|
// State
|
|||
|
|
const [enabled, setEnabled] = useState(currentConfig?.enabled || false);
|
|||
|
|
const [selectedFlowComponentId, setSelectedFlowComponentId] = useState<string | null>(
|
|||
|
|
currentConfig?.targetFlowComponentId || null
|
|||
|
|
);
|
|||
|
|
const [mode, setMode] = useState<"whitelist" | "blacklist" | "all">(currentConfig?.mode || "whitelist");
|
|||
|
|
const [visibleSteps, setVisibleSteps] = useState<number[]>(currentConfig?.visibleSteps || []);
|
|||
|
|
const [hiddenSteps, setHiddenSteps] = useState<number[]>(currentConfig?.hiddenSteps || []);
|
|||
|
|
const [layoutBehavior, setLayoutBehavior] = useState<"preserve-position" | "auto-compact">(
|
|||
|
|
currentConfig?.layoutBehavior || "auto-compact"
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// 선택된 플로우의 스텝 목록
|
|||
|
|
const [flowSteps, setFlowSteps] = useState<FlowStep[]>([]);
|
|||
|
|
const [flowInfo, setFlowInfo] = useState<FlowDefinition | null>(null);
|
|||
|
|
const [loading, setLoading] = useState(false);
|
|||
|
|
|
|||
|
|
// 플로우가 없을 때
|
|||
|
|
if (flowWidgets.length === 0) {
|
|||
|
|
return (
|
|||
|
|
<Alert>
|
|||
|
|
<Info className="h-4 w-4" />
|
|||
|
|
<AlertDescription>
|
|||
|
|
화면에 플로우 위젯을 추가하면 단계별 버튼 표시 제어가 가능합니다.
|
|||
|
|
</AlertDescription>
|
|||
|
|
</Alert>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 선택된 플로우의 스텝 로드
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (!selectedFlowComponentId) {
|
|||
|
|
setFlowSteps([]);
|
|||
|
|
setFlowInfo(null);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const loadFlowSteps = async () => {
|
|||
|
|
try {
|
|||
|
|
setLoading(true);
|
|||
|
|
|
|||
|
|
// 선택된 플로우 위젯 찾기
|
|||
|
|
const flowWidget = flowWidgets.find((fw) => fw.id === selectedFlowComponentId);
|
|||
|
|
if (!flowWidget) return;
|
|||
|
|
|
|||
|
|
// flowId 추출
|
|||
|
|
const flowConfig = (flowWidget as any).componentConfig || {};
|
|||
|
|
const flowId = flowConfig.flowId;
|
|||
|
|
if (!flowId) {
|
|||
|
|
toast.error("플로우 ID를 찾을 수 없습니다");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 플로우 정보 조회
|
|||
|
|
const flowResponse = await getFlowById(flowId);
|
|||
|
|
if (!flowResponse.success || !flowResponse.data) {
|
|||
|
|
throw new Error("플로우를 찾을 수 없습니다");
|
|||
|
|
}
|
|||
|
|
setFlowInfo(flowResponse.data);
|
|||
|
|
|
|||
|
|
// 스텝 목록 조회
|
|||
|
|
const stepsResponse = await fetch(`/api/flow/definitions/${flowId}/steps`);
|
|||
|
|
if (!stepsResponse.ok) {
|
|||
|
|
throw new Error("스텝 목록을 불러올 수 없습니다");
|
|||
|
|
}
|
|||
|
|
const stepsData = await stepsResponse.json();
|
|||
|
|
if (stepsData.success && stepsData.data) {
|
|||
|
|
const sortedSteps = stepsData.data.sort((a: FlowStep, b: FlowStep) => a.stepOrder - b.stepOrder);
|
|||
|
|
setFlowSteps(sortedSteps);
|
|||
|
|
}
|
|||
|
|
} catch (error: any) {
|
|||
|
|
console.error("플로우 스텝 로딩 실패:", error);
|
|||
|
|
toast.error(error.message || "플로우 정보를 불러오는데 실패했습니다");
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
loadFlowSteps();
|
|||
|
|
}, [selectedFlowComponentId, flowWidgets]);
|
|||
|
|
|
|||
|
|
// 설정 저장
|
|||
|
|
const handleSave = () => {
|
|||
|
|
const config: FlowVisibilityConfig = {
|
|||
|
|
enabled,
|
|||
|
|
targetFlowComponentId: selectedFlowComponentId || "",
|
|||
|
|
targetFlowId: flowInfo?.id,
|
|||
|
|
targetFlowName: flowInfo?.name,
|
|||
|
|
mode,
|
|||
|
|
visibleSteps: mode === "whitelist" ? visibleSteps : undefined,
|
|||
|
|
hiddenSteps: mode === "blacklist" ? hiddenSteps : undefined,
|
|||
|
|
layoutBehavior,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
onUpdateProperty("webTypeConfig.flowVisibilityConfig", config);
|
|||
|
|
toast.success("플로우 단계별 표시 설정이 저장되었습니다");
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 체크박스 토글
|
|||
|
|
const toggleStep = (stepId: number) => {
|
|||
|
|
if (mode === "whitelist") {
|
|||
|
|
setVisibleSteps((prev) =>
|
|||
|
|
prev.includes(stepId) ? prev.filter((id) => id !== stepId) : [...prev, stepId]
|
|||
|
|
);
|
|||
|
|
} else if (mode === "blacklist") {
|
|||
|
|
setHiddenSteps((prev) =>
|
|||
|
|
prev.includes(stepId) ? prev.filter((id) => id !== stepId) : [...prev, stepId]
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 빠른 선택
|
|||
|
|
const selectAll = () => {
|
|||
|
|
if (mode === "whitelist") {
|
|||
|
|
setVisibleSteps(flowSteps.map((s) => s.id));
|
|||
|
|
} else if (mode === "blacklist") {
|
|||
|
|
setHiddenSteps([]);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const selectNone = () => {
|
|||
|
|
if (mode === "whitelist") {
|
|||
|
|
setVisibleSteps([]);
|
|||
|
|
} else if (mode === "blacklist") {
|
|||
|
|
setHiddenSteps(flowSteps.map((s) => s.id));
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const invertSelection = () => {
|
|||
|
|
if (mode === "whitelist") {
|
|||
|
|
const allStepIds = flowSteps.map((s) => s.id);
|
|||
|
|
setVisibleSteps(allStepIds.filter((id) => !visibleSteps.includes(id)));
|
|||
|
|
} else if (mode === "blacklist") {
|
|||
|
|
const allStepIds = flowSteps.map((s) => s.id);
|
|||
|
|
setHiddenSteps(allStepIds.filter((id) => !hiddenSteps.includes(id)));
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<Card>
|
|||
|
|
<CardHeader>
|
|||
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|||
|
|
<Workflow className="h-4 w-4" />
|
|||
|
|
플로우 단계별 표시 설정
|
|||
|
|
</CardTitle>
|
|||
|
|
<CardDescription className="text-xs">
|
|||
|
|
플로우의 특정 단계에서만 이 버튼을 표시하거나 숨길 수 있습니다
|
|||
|
|
</CardDescription>
|
|||
|
|
</CardHeader>
|
|||
|
|
|
|||
|
|
<CardContent className="space-y-4">
|
|||
|
|
{/* 활성화 체크박스 */}
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<Checkbox id="flow-control-enabled" checked={enabled} onCheckedChange={(checked) => setEnabled(!!checked)} />
|
|||
|
|
<Label htmlFor="flow-control-enabled" className="text-sm font-medium">
|
|||
|
|
플로우 단계에 따라 버튼 표시 제어
|
|||
|
|
</Label>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{enabled && (
|
|||
|
|
<>
|
|||
|
|
{/* 대상 플로우 선택 */}
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
<Label className="text-sm font-medium">대상 플로우</Label>
|
|||
|
|
<Select value={selectedFlowComponentId || ""} onValueChange={setSelectedFlowComponentId}>
|
|||
|
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
|||
|
|
<SelectValue placeholder="플로우 위젯 선택" />
|
|||
|
|
</SelectTrigger>
|
|||
|
|
<SelectContent>
|
|||
|
|
{flowWidgets.map((fw) => {
|
|||
|
|
const flowConfig = (fw as any).componentConfig || {};
|
|||
|
|
const flowName = flowConfig.flowName || `플로우 ${fw.id}`;
|
|||
|
|
return (
|
|||
|
|
<SelectItem key={fw.id} value={fw.id}>
|
|||
|
|
{flowName}
|
|||
|
|
</SelectItem>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</SelectContent>
|
|||
|
|
</Select>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* 플로우가 선택되면 스텝 목록 표시 */}
|
|||
|
|
{selectedFlowComponentId && flowSteps.length > 0 && (
|
|||
|
|
<>
|
|||
|
|
{/* 모드 선택 */}
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
<Label className="text-sm font-medium">표시 모드</Label>
|
|||
|
|
<RadioGroup value={mode} onValueChange={(value: any) => setMode(value)}>
|
|||
|
|
<div className="flex items-center space-x-2">
|
|||
|
|
<RadioGroupItem value="whitelist" id="mode-whitelist" />
|
|||
|
|
<Label htmlFor="mode-whitelist" className="text-sm font-normal">
|
|||
|
|
화이트리스트 (선택한 단계에서만 표시)
|
|||
|
|
</Label>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex items-center space-x-2">
|
|||
|
|
<RadioGroupItem value="blacklist" id="mode-blacklist" />
|
|||
|
|
<Label htmlFor="mode-blacklist" className="text-sm font-normal">
|
|||
|
|
블랙리스트 (선택한 단계에서 숨김)
|
|||
|
|
</Label>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex items-center space-x-2">
|
|||
|
|
<RadioGroupItem value="all" id="mode-all" />
|
|||
|
|
<Label htmlFor="mode-all" className="text-sm font-normal">
|
|||
|
|
모든 단계에서 표시
|
|||
|
|
</Label>
|
|||
|
|
</div>
|
|||
|
|
</RadioGroup>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* 단계 선택 (all 모드가 아닐 때만) */}
|
|||
|
|
{mode !== "all" && (
|
|||
|
|
<div className="space-y-3">
|
|||
|
|
<div className="flex items-center justify-between">
|
|||
|
|
<Label className="text-sm font-medium">
|
|||
|
|
{mode === "whitelist" ? "표시할 단계" : "숨길 단계"}
|
|||
|
|
</Label>
|
|||
|
|
<div className="flex gap-1">
|
|||
|
|
<Button variant="ghost" size="sm" onClick={selectAll} className="h-7 px-2 text-xs">
|
|||
|
|
모두 선택
|
|||
|
|
</Button>
|
|||
|
|
<Button variant="ghost" size="sm" onClick={selectNone} className="h-7 px-2 text-xs">
|
|||
|
|
모두 해제
|
|||
|
|
</Button>
|
|||
|
|
<Button variant="ghost" size="sm" onClick={invertSelection} className="h-7 px-2 text-xs">
|
|||
|
|
반전
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* 스텝 체크박스 목록 */}
|
|||
|
|
<div className="space-y-2 rounded-lg border bg-muted/30 p-3">
|
|||
|
|
{flowSteps.map((step) => {
|
|||
|
|
const isChecked =
|
|||
|
|
mode === "whitelist"
|
|||
|
|
? visibleSteps.includes(step.id)
|
|||
|
|
: hiddenSteps.includes(step.id);
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div key={step.id} className="flex items-center gap-2">
|
|||
|
|
<Checkbox
|
|||
|
|
id={`step-${step.id}`}
|
|||
|
|
checked={isChecked}
|
|||
|
|
onCheckedChange={() => toggleStep(step.id)}
|
|||
|
|
/>
|
|||
|
|
<Label htmlFor={`step-${step.id}`} className="flex flex-1 items-center gap-2 text-sm">
|
|||
|
|
<Badge variant="outline" className="text-xs">
|
|||
|
|
Step {step.stepOrder}
|
|||
|
|
</Badge>
|
|||
|
|
<span>{step.stepName}</span>
|
|||
|
|
{isChecked && (
|
|||
|
|
<CheckCircle className={`ml-auto h-4 w-4 ${mode === "whitelist" ? "text-green-500" : "text-red-500"}`} />
|
|||
|
|
)}
|
|||
|
|
</Label>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* 레이아웃 옵션 */}
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
<Label className="text-sm font-medium">레이아웃 동작</Label>
|
|||
|
|
<RadioGroup value={layoutBehavior} onValueChange={(value: any) => setLayoutBehavior(value)}>
|
|||
|
|
<div className="flex items-center space-x-2">
|
|||
|
|
<RadioGroupItem value="preserve-position" id="layout-preserve" />
|
|||
|
|
<Label htmlFor="layout-preserve" className="text-sm font-normal">
|
|||
|
|
원래 위치 유지 (빈 공간 가능)
|
|||
|
|
</Label>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex items-center space-x-2">
|
|||
|
|
<RadioGroupItem value="auto-compact" id="layout-compact" />
|
|||
|
|
<Label htmlFor="layout-compact" className="text-sm font-normal">
|
|||
|
|
자동 정렬 (빈 공간 제거) ⭐ 권장
|
|||
|
|
</Label>
|
|||
|
|
</div>
|
|||
|
|
</RadioGroup>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* 미리보기 */}
|
|||
|
|
<Alert>
|
|||
|
|
<Info className="h-4 w-4" />
|
|||
|
|
<AlertDescription className="text-xs">
|
|||
|
|
{mode === "whitelist" && visibleSteps.length > 0 && (
|
|||
|
|
<div>
|
|||
|
|
<p className="font-medium">표시 단계:</p>
|
|||
|
|
<div className="mt-1 flex flex-wrap gap-1">
|
|||
|
|
{visibleSteps.map((stepId) => {
|
|||
|
|
const step = flowSteps.find((s) => s.id === stepId);
|
|||
|
|
return (
|
|||
|
|
<Badge key={stepId} variant="secondary" className="text-xs">
|
|||
|
|
{step?.stepName || `Step ${stepId}`}
|
|||
|
|
</Badge>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
{mode === "blacklist" && hiddenSteps.length > 0 && (
|
|||
|
|
<div>
|
|||
|
|
<p className="font-medium">숨김 단계:</p>
|
|||
|
|
<div className="mt-1 flex flex-wrap gap-1">
|
|||
|
|
{hiddenSteps.map((stepId) => {
|
|||
|
|
const step = flowSteps.find((s) => s.id === stepId);
|
|||
|
|
return (
|
|||
|
|
<Badge key={stepId} variant="destructive" className="text-xs">
|
|||
|
|
{step?.stepName || `Step ${stepId}`}
|
|||
|
|
</Badge>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
{mode === "all" && <p>이 버튼은 모든 단계에서 표시됩니다.</p>}
|
|||
|
|
</AlertDescription>
|
|||
|
|
</Alert>
|
|||
|
|
|
|||
|
|
{/* 저장 버튼 */}
|
|||
|
|
<Button onClick={handleSave} className="w-full">
|
|||
|
|
설정 저장
|
|||
|
|
</Button>
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* 플로우 선택 안내 */}
|
|||
|
|
{selectedFlowComponentId && flowSteps.length === 0 && !loading && (
|
|||
|
|
<Alert variant="destructive">
|
|||
|
|
<XCircle className="h-4 w-4" />
|
|||
|
|
<AlertDescription>선택한 플로우에 단계가 없습니다.</AlertDescription>
|
|||
|
|
</Alert>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{loading && (
|
|||
|
|
<div className="flex items-center justify-center py-4">
|
|||
|
|
<div className="text-muted-foreground text-sm">플로우 정보를 불러오는 중...</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
</CardContent>
|
|||
|
|
</Card>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Phase 5: PropertiesPanel에 통합
|
|||
|
|
|
|||
|
|
#### 파일: `frontend/components/screen/panels/PropertiesPanel.tsx`
|
|||
|
|
|
|||
|
|
기존 PropertiesPanel에 FlowVisibilityConfigPanel 추가:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// 기존 imports...
|
|||
|
|
import { FlowVisibilityConfigPanel } from "../config-panels/FlowVisibilityConfigPanel";
|
|||
|
|
|
|||
|
|
// PropertiesPanel 컴포넌트 내부
|
|||
|
|
const renderButtonProperties = () => {
|
|||
|
|
return (
|
|||
|
|
<div className="space-y-6">
|
|||
|
|
{/* 기존 버튼 속성 UI */}
|
|||
|
|
{/* ... */}
|
|||
|
|
|
|||
|
|
{/* 🆕 플로우 단계별 표시 설정 */}
|
|||
|
|
<FlowVisibilityConfigPanel
|
|||
|
|
component={selectedComponent}
|
|||
|
|
allComponents={layout.components}
|
|||
|
|
onUpdateProperty={handleUpdateProperty}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 구현 단계
|
|||
|
|
|
|||
|
|
### Phase 1: 기반 구조 (1-2일)
|
|||
|
|
- [x] 요구사항 분석 및 계획 수립
|
|||
|
|
- [ ] Zustand Store 생성 (`flowStepStore.ts`)
|
|||
|
|
- [ ] 타입 정의 추가 (`FlowVisibilityConfig`)
|
|||
|
|
- [ ] FlowWidget에 전역 상태 연동
|
|||
|
|
|
|||
|
|
### Phase 2: 핵심 기능 (2-3일)
|
|||
|
|
- [ ] OptimizedButtonComponent 수정 (표시/숨김 로직)
|
|||
|
|
- [ ] 버튼 렌더링 테스트 (단계별)
|
|||
|
|
- [ ] 성능 최적화 (useMemo, useCallback)
|
|||
|
|
|
|||
|
|
### Phase 3: 설정 UI (2-3일)
|
|||
|
|
- [ ] FlowVisibilityConfigPanel 컴포넌트 생성
|
|||
|
|
- [ ] PropertiesPanel에 통합
|
|||
|
|
- [ ] UX 개선 (로딩, 에러 처리, 미리보기)
|
|||
|
|
|
|||
|
|
### Phase 4: 고급 기능 (선택사항, 2-3일)
|
|||
|
|
- [ ] ButtonGroup 컴포넌트 (auto-compact 레이아웃)
|
|||
|
|
- [ ] 드래그앤드롭으로 버튼 그룹화
|
|||
|
|
- [ ] 다중 플로우 동시 제어
|
|||
|
|
|
|||
|
|
### Phase 5: 테스트 및 문서화 (1-2일)
|
|||
|
|
- [ ] 단위 테스트
|
|||
|
|
- [ ] 통합 테스트
|
|||
|
|
- [ ] 사용자 가이드 작성
|
|||
|
|
- [ ] 성능 벤치마크
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 예상 이슈 및 해결방안
|
|||
|
|
|
|||
|
|
### 이슈 1: 플로우 위젯이 여러 개일 때 어떤 플로우를 따를지 모호함
|
|||
|
|
**해결**: 버튼 설정에서 명시적으로 대상 플로우 선택 (Dropdown)
|
|||
|
|
|
|||
|
|
### 이슈 2: 버튼이 숨겨질 때 레이아웃이 깨짐
|
|||
|
|
**해결**: `layoutBehavior` 옵션으로 사용자가 선택 (preserve-position vs auto-compact)
|
|||
|
|
|
|||
|
|
### 이슈 3: 플로우 단계가 선택되지 않았을 때 버튼 표시 기준
|
|||
|
|
**해결**: 기본값은 "모든 버튼 표시" (단계 선택 전까지는 정상 동작)
|
|||
|
|
|
|||
|
|
### 이슈 4: 성능 문제 (버튼이 많을 경우)
|
|||
|
|
**해결**:
|
|||
|
|
- `useMemo`로 표시 여부 캐싱
|
|||
|
|
- Zustand의 selector로 필요한 데이터만 구독
|
|||
|
|
- 가상화 (React Virtuoso) 고려
|
|||
|
|
|
|||
|
|
### 이슈 5: 데이터베이스 저장 시 JSON 필드 크기
|
|||
|
|
**해결**:
|
|||
|
|
- visibleSteps/hiddenSteps는 배열로 저장 (효율적)
|
|||
|
|
- 불필요한 메타데이터 제거
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 테스트 시나리오
|
|||
|
|
|
|||
|
|
### 시나리오 1: 기본 동작
|
|||
|
|
1. 플로우 위젯 추가 (3단계)
|
|||
|
|
2. 버튼 3개 추가 (A, B, C)
|
|||
|
|
3. 버튼 A: Step 1, 2에서만 표시
|
|||
|
|
4. 버튼 B: Step 2, 3에서 표시
|
|||
|
|
5. 버튼 C: 모든 단계 표시
|
|||
|
|
6. 플로우 Step 1 클릭 → A, C만 표시
|
|||
|
|
7. 플로우 Step 2 클릭 → A, B, C 모두 표시
|
|||
|
|
8. 플로우 Step 3 클릭 → B, C만 표시
|
|||
|
|
|
|||
|
|
### 시나리오 2: 다중 플로우
|
|||
|
|
1. 플로우 위젯 A 추가 (계약 프로세스)
|
|||
|
|
2. 플로우 위젯 B 추가 (AS 프로세스)
|
|||
|
|
3. [저장] 버튼: 플로우 A Step 1, 2에서만
|
|||
|
|
4. [AS 접수] 버튼: 플로우 B Step 1에서만
|
|||
|
|
5. 플로우 A Step 1 클릭 → [저장] 표시
|
|||
|
|
6. 플로우 B Step 1 클릭 → [AS 접수] 표시
|
|||
|
|
|
|||
|
|
### 시나리오 3: 레이아웃 동작
|
|||
|
|
1. 버튼 5개 가로 배치 (A, B, C, D, E)
|
|||
|
|
2. B, D만 Step 1에서 숨김
|
|||
|
|
3. `preserve-position` 모드: A [빈공간] C [빈공간] E
|
|||
|
|
4. `auto-compact` 모드: A C E (자동 정렬)
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 성능 고려사항
|
|||
|
|
|
|||
|
|
### 최적화 포인트
|
|||
|
|
1. **Zustand Selector**: 필요한 플로우만 구독
|
|||
|
|
2. **useMemo**: 표시 여부 계산 결과 캐싱
|
|||
|
|
3. **조건부 렌더링**: `display: none` vs `null` 선택
|
|||
|
|
4. **Debounce**: 빠른 단계 전환 시 렌더링 throttle
|
|||
|
|
|
|||
|
|
### 예상 성능
|
|||
|
|
- 버튼 10개 기준: < 10ms 반응 시간
|
|||
|
|
- 버튼 100개 기준: < 50ms 반응 시간
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 결론
|
|||
|
|
|
|||
|
|
이 계획서는 **플로우 단계별 버튼 표시/숨김 제어** 기능의 전체 구현 방향을 제시합니다.
|
|||
|
|
|
|||
|
|
**핵심 설계 원칙**:
|
|||
|
|
1. ✅ 사용자 편의성 (자동 감지, 직관적 UI)
|
|||
|
|
2. ✅ 유연성 (다중 플로우, 다중 버튼 지원)
|
|||
|
|
3. ✅ 성능 (전역 상태 관리, 최적화)
|
|||
|
|
4. ✅ 확장성 (추후 조건부 표시 등 추가 가능)
|
|||
|
|
|
|||
|
|
**다음 단계**: Phase 1부터 순차적으로 구현 시작
|
|||
|
|
|