ERP-node/PHASE_FLOW_STEP_BUTTON_VISI...

1217 lines
41 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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