ERP-node/PHASE_FLOW_STEP_BUTTON_VISI...

1217 lines
41 KiB
Markdown
Raw Permalink Normal View History

# 플로우 단계별 버튼 표시/숨김 제어 시스템 구현 계획서
## 📋 목차
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부터 순차적으로 구현 시작