diff --git a/PHASE_FLOW_STEP_BUTTON_VISIBILITY_CONTROL.md b/PHASE_FLOW_STEP_BUTTON_VISIBILITY_CONTROL.md new file mode 100644 index 00000000..39e9a364 --- /dev/null +++ b/PHASE_FLOW_STEP_BUTTON_VISIBILITY_CONTROL.md @@ -0,0 +1,1216 @@ +# 플로우 단계별 버튼 표시/숨김 제어 시스템 구현 계획서 + +## 📋 목차 +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로 변환 +
+ {visibleButtons.map(button =>
+``` +**장점**: +- 자동으로 빈 공간 채움 +- CSS 기반으로 성능 우수 +- 반응형 대응 자동 + +**단점**: +- 기존 절대 위치(absolute positioning) 레이아웃과 충돌 가능 + +##### 옵션 B: 조건부 렌더링 (현재 구조 유지) +```tsx +// 각 버튼을 원래 위치에 렌더링하되, 보이지 않는 버튼은 display: none +{buttons.map(button => ( +
+
+))} +``` +**장점**: +- 현재 레이아웃 시스템 유지 +- 구현 간단 + +**단점**: +- 빈 공간 그대로 남음 +- 디자이너가 의도한 배치가 깨질 수 있음 + +##### 옵션 C: 그룹 기반 재정렬 (하이브리드) +```tsx +// 버튼들을 그룹으로 묶고, 그룹 내에서만 재정렬 + + +``` + +##### 3-2. 버튼 컴포넌트 뱃지 +```tsx +// 플로우 제어가 활성화된 버튼에 뱃지 표시 + +``` + +--- + +## 시스템 아키텍처 + +### 1. 전역 상태 관리 (Zustand Store) + +#### FlowStepStore +```typescript +// stores/flowStepStore.ts +import create from 'zustand'; + +interface FlowStepState { + // 현재 선택된 플로우 단계 (화면당 여러 플로우 가능) + selectedSteps: Record; // key: flowId, value: stepId + + // 플로우 단계 선택 + setSelectedStep: (flowId: string, stepId: number | null) => void; + + // 특정 플로우의 현재 단계 가져오기 + getCurrentStep: (flowId: string) => number | null; + + // 초기화 + reset: () => void; +} + +export const useFlowStepStore = create((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; + + // 플로우 단계 선택 + setSelectedStep: (flowComponentId: string, stepId: number | null) => void; + + // 현재 단계 조회 + getCurrentStep: (flowComponentId: string) => number | null; + + // 모든 플로우 초기화 + reset: () => void; + + // 특정 플로우만 초기화 + resetFlow: (flowComponentId: string) => void; +} + +export const useFlowStepStore = create()( + 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(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 ( +
+ {/* ... */} +
+ ); +} +``` + +--- + +### Phase 3: 버튼 표시/숨김 로직 + +#### 파일: `frontend/components/screen/OptimizedButtonComponent.tsx` + +```typescript +// 기존 imports... +import { useFlowStepStore } from "@/stores/flowStepStore"; +import { useMemo } from "react"; + +export const OptimizedButtonComponent: React.FC = ({ + 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
; + } else { + // 완전히 렌더링하지 않음 (auto-compact) + return null; + } + } + + // 기존 버튼 렌더링 로직... + return ( +
+ + + {/* 🆕 플로우 제어 활성화 표시 */} + {flowConfig?.enabled && ( +
+ + + +
+ )} +
+ ); +}; +``` + +--- + +### 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 = ({ + 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( + currentConfig?.targetFlowComponentId || null + ); + const [mode, setMode] = useState<"whitelist" | "blacklist" | "all">(currentConfig?.mode || "whitelist"); + const [visibleSteps, setVisibleSteps] = useState(currentConfig?.visibleSteps || []); + const [hiddenSteps, setHiddenSteps] = useState(currentConfig?.hiddenSteps || []); + const [layoutBehavior, setLayoutBehavior] = useState<"preserve-position" | "auto-compact">( + currentConfig?.layoutBehavior || "auto-compact" + ); + + // 선택된 플로우의 스텝 목록 + const [flowSteps, setFlowSteps] = useState([]); + const [flowInfo, setFlowInfo] = useState(null); + const [loading, setLoading] = useState(false); + + // 플로우가 없을 때 + if (flowWidgets.length === 0) { + return ( + + + + 화면에 플로우 위젯을 추가하면 단계별 버튼 표시 제어가 가능합니다. + + + ); + } + + // 선택된 플로우의 스텝 로드 + 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 ( + + + + + 플로우 단계별 표시 설정 + + + 플로우의 특정 단계에서만 이 버튼을 표시하거나 숨길 수 있습니다 + + + + + {/* 활성화 체크박스 */} +
+ setEnabled(!!checked)} /> + +
+ + {enabled && ( + <> + {/* 대상 플로우 선택 */} +
+ + +
+ + {/* 플로우가 선택되면 스텝 목록 표시 */} + {selectedFlowComponentId && flowSteps.length > 0 && ( + <> + {/* 모드 선택 */} +
+ + setMode(value)}> +
+ + +
+
+ + +
+
+ + +
+
+
+ + {/* 단계 선택 (all 모드가 아닐 때만) */} + {mode !== "all" && ( +
+
+ +
+ + + +
+
+ + {/* 스텝 체크박스 목록 */} +
+ {flowSteps.map((step) => { + const isChecked = + mode === "whitelist" + ? visibleSteps.includes(step.id) + : hiddenSteps.includes(step.id); + + return ( +
+ toggleStep(step.id)} + /> + +
+ ); + })} +
+
+ )} + + {/* 레이아웃 옵션 */} +
+ + setLayoutBehavior(value)}> +
+ + +
+
+ + +
+
+
+ + {/* 미리보기 */} + + + + {mode === "whitelist" && visibleSteps.length > 0 && ( +
+

표시 단계:

+
+ {visibleSteps.map((stepId) => { + const step = flowSteps.find((s) => s.id === stepId); + return ( + + {step?.stepName || `Step ${stepId}`} + + ); + })} +
+
+ )} + {mode === "blacklist" && hiddenSteps.length > 0 && ( +
+

숨김 단계:

+
+ {hiddenSteps.map((stepId) => { + const step = flowSteps.find((s) => s.id === stepId); + return ( + + {step?.stepName || `Step ${stepId}`} + + ); + })} +
+
+ )} + {mode === "all" &&

이 버튼은 모든 단계에서 표시됩니다.

} +
+
+ + {/* 저장 버튼 */} + + + )} + + {/* 플로우 선택 안내 */} + {selectedFlowComponentId && flowSteps.length === 0 && !loading && ( + + + 선택한 플로우에 단계가 없습니다. + + )} + + {loading && ( +
+
플로우 정보를 불러오는 중...
+
+ )} + + )} +
+
+ ); +}; +``` + +--- + +### Phase 5: PropertiesPanel에 통합 + +#### 파일: `frontend/components/screen/panels/PropertiesPanel.tsx` + +기존 PropertiesPanel에 FlowVisibilityConfigPanel 추가: + +```typescript +// 기존 imports... +import { FlowVisibilityConfigPanel } from "../config-panels/FlowVisibilityConfigPanel"; + +// PropertiesPanel 컴포넌트 내부 +const renderButtonProperties = () => { + return ( +
+ {/* 기존 버튼 속성 UI */} + {/* ... */} + + {/* 🆕 플로우 단계별 표시 설정 */} + +
+ ); +}; +``` + +--- + +## 구현 단계 + +### 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부터 순차적으로 구현 시작 + diff --git a/frontend/components/screen/OptimizedButtonComponent.tsx b/frontend/components/screen/OptimizedButtonComponent.tsx index a30ce66a..a030109e 100644 --- a/frontend/components/screen/OptimizedButtonComponent.tsx +++ b/frontend/components/screen/OptimizedButtonComponent.tsx @@ -1,9 +1,9 @@ "use client"; -import React, { useState, useCallback, useEffect } from "react"; +import React, { useState, useCallback, useEffect, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { toast } from "react-hot-toast"; -import { Loader2, CheckCircle2, AlertCircle, Clock } from "lucide-react"; +import { Loader2, CheckCircle2, AlertCircle, Clock, Workflow } from "lucide-react"; import { ComponentData, ButtonActionType } from "@/types/screen"; import { optimizedButtonDataflowService, @@ -14,6 +14,7 @@ import { dataflowJobQueue } from "@/lib/services/dataflowJobQueue"; import { cn } from "@/lib/utils"; import { Badge } from "@/components/ui/badge"; import { executeButtonWithFlow, handleFlowExecutionResult } from "@/lib/utils/nodeFlowButtonExecutor"; +import { useCurrentFlowStep } from "@/stores/flowStepStore"; interface OptimizedButtonProps { component: ComponentData; @@ -59,6 +60,54 @@ export const OptimizedButtonComponent: React.FC = ({ const config = component.webTypeConfig; const buttonLabel = component.label || "버튼"; + const flowConfig = config?.flowVisibilityConfig; + + // 🆕 현재 플로우 단계 구독 + const currentStep = useCurrentFlowStep(flowConfig?.targetFlowComponentId); + + // 🆕 버튼 표시 여부 계산 + const shouldShowButton = useMemo(() => { + // 플로우 제어 비활성화 시 항상 표시 + if (!flowConfig?.enabled) { + return true; + } + + // 플로우 단계가 선택되지 않은 경우 처리 + if (currentStep === null) { + // 🔧 화이트리스트 모드일 때는 단계 미선택 시 숨김 + if (flowConfig.mode === "whitelist") { + console.log("🔍 [OptimizedButton] 화이트리스트 모드 + 단계 미선택 → 숨김"); + return false; + } + // 블랙리스트나 all 모드는 표시 + 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("🔍 [OptimizedButton] 표시 체크:", { + buttonId: component.id, + buttonLabel, + flowComponentId: flowConfig.targetFlowComponentId, + currentStep, + mode, + visibleSteps, + hiddenSteps, + result: result ? "표시 ✅" : "숨김 ❌", + }); + + return result; + }, [flowConfig, currentStep, component.id, buttonLabel]); // 🔥 디바운싱된 클릭 핸들러 (300ms) const handleClick = useCallback(async () => { @@ -514,6 +563,18 @@ export const OptimizedButtonComponent: React.FC = ({ ); }; + // 🆕 플로우 단계별 표시 제어 + if (!shouldShowButton) { + // 레이아웃 동작에 따라 다르게 처리 + if (flowConfig?.layoutBehavior === "preserve-position") { + // 위치 유지 (빈 공간, display: none) + return
; + } else { + // 완전히 렌더링하지 않음 (auto-compact, 빈 공간 제거) + return null; + } + } + return (
diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index a7949431..5cf9f4c3 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -13,10 +13,12 @@ import { ComponentData } from "@/types/screen"; import { apiClient } from "@/lib/api/client"; import { ButtonDataflowConfigPanel } from "./ButtonDataflowConfigPanel"; import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel"; +import { FlowVisibilityConfigPanel } from "./FlowVisibilityConfigPanel"; interface ButtonConfigPanelProps { component: ComponentData; onUpdateProperty: (path: string, value: any) => void; + allComponents?: ComponentData[]; // 🆕 플로우 위젯 감지용 } interface ScreenOption { @@ -25,7 +27,11 @@ interface ScreenOption { description?: string; } -export const ButtonConfigPanel: React.FC = ({ component, onUpdateProperty }) => { +export const ButtonConfigPanel: React.FC = ({ + component, + onUpdateProperty, + allComponents = [], // 🆕 기본값 빈 배열 +}) => { console.log("🎨 ButtonConfigPanel 렌더링:", { componentId: component.id, "component.componentConfig?.action?.type": component.componentConfig?.action?.type, @@ -571,6 +577,15 @@ export const ButtonConfigPanel: React.FC = ({ component,
+ + {/* 🆕 플로우 단계별 표시 제어 섹션 */} +
+ +
); }; diff --git a/frontend/components/screen/config-panels/FlowVisibilityConfigPanel.tsx b/frontend/components/screen/config-panels/FlowVisibilityConfigPanel.tsx new file mode 100644 index 00000000..23388e65 --- /dev/null +++ b/frontend/components/screen/config-panels/FlowVisibilityConfigPanel.tsx @@ -0,0 +1,404 @@ +"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, Loader2 } from "lucide-react"; +import { ComponentData } from "@/types/screen"; +import { FlowVisibilityConfig } from "@/types/control-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 = ({ + 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( + currentConfig?.targetFlowComponentId || null + ); + const [mode, setMode] = useState<"whitelist" | "blacklist" | "all">(currentConfig?.mode || "whitelist"); + const [visibleSteps, setVisibleSteps] = useState(currentConfig?.visibleSteps || []); + const [hiddenSteps, setHiddenSteps] = useState(currentConfig?.hiddenSteps || []); + const [layoutBehavior, setLayoutBehavior] = useState<"preserve-position" | "auto-compact">( + currentConfig?.layoutBehavior || "auto-compact" + ); + + // 선택된 플로우의 스텝 목록 + const [flowSteps, setFlowSteps] = useState([]); + const [flowInfo, setFlowInfo] = useState(null); + const [loading, setLoading] = useState(false); + + // 플로우가 없을 때 + if (flowWidgets.length === 0) { + return ( + + + + + 플로우 단계별 표시 설정 + + + + + + + 화면에 플로우 위젯을 추가하면 단계별 버튼 표시 제어가 가능합니다. + + + + + ); + } + + // 선택된 플로우의 스텝 로드 + 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 ( + + + + + 플로우 단계별 표시 설정 + + + 플로우의 특정 단계에서만 이 버튼을 표시하거나 숨길 수 있습니다 + + + + + {/* 활성화 체크박스 */} +
+ setEnabled(!!checked)} /> + +
+ + {enabled && ( + <> + {/* 대상 플로우 선택 */} +
+ + +
+ + {/* 플로우가 선택되면 스텝 목록 표시 */} + {selectedFlowComponentId && flowSteps.length > 0 && ( + <> + {/* 모드 선택 */} +
+ + setMode(value)}> +
+ + +
+
+ + +
+
+ + +
+
+
+ + {/* 단계 선택 (all 모드가 아닐 때만) */} + {mode !== "all" && ( +
+
+ +
+ + + +
+
+ + {/* 스텝 체크박스 목록 */} +
+ {flowSteps.map((step) => { + const isChecked = + mode === "whitelist" + ? visibleSteps.includes(step.id) + : hiddenSteps.includes(step.id); + + return ( +
+ toggleStep(step.id)} + /> + +
+ ); + })} +
+
+ )} + + {/* 레이아웃 옵션 */} +
+ + setLayoutBehavior(value)}> +
+ + +
+
+ + +
+
+
+ + {/* 미리보기 */} + + + + {mode === "whitelist" && visibleSteps.length > 0 && ( +
+

표시 단계:

+
+ {visibleSteps.map((stepId) => { + const step = flowSteps.find((s) => s.id === stepId); + return ( + + {step?.stepName || `Step ${stepId}`} + + ); + })} +
+
+ )} + {mode === "blacklist" && hiddenSteps.length > 0 && ( +
+

숨김 단계:

+
+ {hiddenSteps.map((stepId) => { + const step = flowSteps.find((s) => s.id === stepId); + return ( + + {step?.stepName || `Step ${stepId}`} + + ); + })} +
+
+ )} + {mode === "all" &&

이 버튼은 모든 단계에서 표시됩니다.

} + {mode === "whitelist" && visibleSteps.length === 0 &&

표시할 단계를 선택해주세요.

} +
+
+ + {/* 저장 버튼 */} + + + )} + + {/* 플로우 선택 안내 */} + {selectedFlowComponentId && flowSteps.length === 0 && !loading && ( + + + 선택한 플로우에 단계가 없습니다. + + )} + + {loading && ( +
+ + 플로우 정보를 불러오는 중... +
+ )} + + )} +
+
+ ); +}; + diff --git a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx index 0d6c3fda..237a94a8 100644 --- a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx +++ b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx @@ -67,6 +67,8 @@ interface UnifiedPropertiesPanelProps { // 해상도 관련 currentResolution?: { name: string; width: number; height: number }; onResolutionChange?: (resolution: { name: string; width: number; height: number }) => void; + // 🆕 플로우 위젯 감지용 + allComponents?: ComponentData[]; } export const UnifiedPropertiesPanel: React.FC = ({ @@ -81,6 +83,7 @@ export const UnifiedPropertiesPanel: React.FC = ({ onStyleChange, currentResolution, onResolutionChange, + allComponents = [], // 🆕 기본값 빈 배열 }) => { const { webTypes } = useWebTypes({ active: "Y" }); const [localComponentDetailType, setLocalComponentDetailType] = useState(""); @@ -153,7 +156,7 @@ export const UnifiedPropertiesPanel: React.FC = ({ case "button-primary": case "button-secondary": // 🔧 component.id만 key로 사용 (unmount 방지) - return ; + return ; case "card": return ; diff --git a/frontend/components/screen/widgets/FlowWidget.tsx b/frontend/components/screen/widgets/FlowWidget.tsx index c232e2a2..8a08f30e 100644 --- a/frontend/components/screen/widgets/FlowWidget.tsx +++ b/frontend/components/screen/widgets/FlowWidget.tsx @@ -27,6 +27,7 @@ import { PaginationNext, PaginationPrevious, } from "@/components/ui/pagination"; +import { useFlowStepStore } from "@/stores/flowStepStore"; interface FlowWidgetProps { component: FlowComponent; @@ -37,6 +38,10 @@ interface FlowWidgetProps { } export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowRefreshKey, onFlowRefresh }: FlowWidgetProps) { + // 🆕 전역 상태 관리 + const setSelectedStep = useFlowStepStore((state) => state.setSelectedStep); + const resetFlow = useFlowStepStore((state) => state.resetFlow); + const [flowData, setFlowData] = useState(null); const [steps, setSteps] = useState([]); const [stepCounts, setStepCounts] = useState>({}); @@ -68,6 +73,9 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR const showStepCount = config.showStepCount !== false && component.showStepCount !== false; // 기본값 true const allowDataMove = config.allowDataMove || component.allowDataMove || false; + // 🆕 플로우 컴포넌트 ID (버튼이 이 플로우를 참조할 때 사용) + const flowComponentId = component.id; + // 선택된 스텝의 데이터를 다시 로드하는 함수 const refreshStepData = async () => { @@ -197,31 +205,43 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR } }, [flowRefreshKey]); - // 스텝 클릭 핸들러 + // 🆕 언마운트 시 전역 상태 초기화 + useEffect(() => { + return () => { + console.log("🧹 [FlowWidget] 언마운트 - 전역 상태 초기화:", flowComponentId); + resetFlow(flowComponentId); + }; + }, [flowComponentId, resetFlow]); + + // 🆕 스텝 클릭 핸들러 (전역 상태 업데이트 추가) const handleStepClick = async (stepId: number, stepName: string) => { + // 외부 콜백 실행 if (onStepClick) { onStepClick(stepId, stepName); - return; } // 같은 스텝을 다시 클릭하면 접기 if (selectedStepId === stepId) { setSelectedStepId(null); + setSelectedStep(flowComponentId, null); // 🆕 전역 상태 업데이트 setStepData([]); setStepDataColumns([]); setSelectedRows(new Set()); - // 선택 초기화 전달 onSelectedDataChange?.([], null); + + console.log("🔄 [FlowWidget] 단계 선택 해제:", { flowComponentId, stepId }); return; } // 새로운 스텝 선택 - 데이터 로드 setSelectedStepId(stepId); + setSelectedStep(flowComponentId, stepId); // 🆕 전역 상태 업데이트 setStepDataLoading(true); setSelectedRows(new Set()); - // 선택 초기화 전달 onSelectedDataChange?.([], stepId); + console.log("✅ [FlowWidget] 단계 선택:", { flowComponentId, stepId, stepName }); + try { const response = await getStepDataList(flowId!, stepId, 1, 100); diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index fe715aa7..681eb590 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useRef, useEffect } from "react"; +import React, { useState, useRef, useEffect, useMemo } from "react"; import { ComponentRendererProps } from "@/types/component"; import { ButtonPrimaryConfig } from "./types"; import { @@ -21,6 +21,7 @@ import { } from "@/components/ui/alert-dialog"; import { toast } from "sonner"; import { filterDOMProps } from "@/lib/utils/domPropsFilter"; +import { useCurrentFlowStep } from "@/stores/flowStepStore"; export interface ButtonPrimaryComponentProps extends ComponentRendererProps { config?: ButtonPrimaryConfig; @@ -85,6 +86,54 @@ export const ButtonPrimaryComponent: React.FC = ({ screenId, }); + // 🆕 플로우 단계별 표시 제어 + const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig; + const currentStep = useCurrentFlowStep(flowConfig?.targetFlowComponentId); + + // 🆕 버튼 표시 여부 계산 + const shouldShowButton = useMemo(() => { + // 플로우 제어 비활성화 시 항상 표시 + if (!flowConfig?.enabled) { + return true; + } + + // 플로우 단계가 선택되지 않은 경우 처리 + if (currentStep === null) { + // 🔧 화이트리스트 모드일 때는 단계 미선택 시 숨김 + if (flowConfig.mode === "whitelist") { + console.log("🔍 [ButtonPrimary] 화이트리스트 모드 + 단계 미선택 → 숨김"); + return false; + } + // 블랙리스트나 all 모드는 표시 + 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("🔍 [ButtonPrimary] 표시 체크:", { + buttonId: component.id, + buttonLabel: component.label, + flowComponentId: flowConfig.targetFlowComponentId, + currentStep, + mode, + visibleSteps, + hiddenSteps, + result: result ? "표시 ✅" : "숨김 ❌", + }); + + return result; + }, [flowConfig, currentStep, component.id, component.label]); + // 확인 다이얼로그 상태 const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [pendingAction, setPendingAction] = useState<{ @@ -571,6 +620,18 @@ export const ButtonPrimaryComponent: React.FC = ({ // DOM 안전한 props만 필터링 const safeDomProps = filterDOMProps(domProps); + // 🆕 플로우 단계별 표시 제어 + if (!shouldShowButton) { + // 레이아웃 동작에 따라 다르게 처리 + if (flowConfig?.layoutBehavior === "preserve-position") { + // 위치 유지 (빈 공간, display: none) + return
; + } else { + // 완전히 렌더링하지 않음 (auto-compact, 빈 공간 제거) + return null; + } + } + return ( <>
diff --git a/frontend/stores/flowStepStore.ts b/frontend/stores/flowStepStore.ts new file mode 100644 index 00000000..838baf6c --- /dev/null +++ b/frontend/stores/flowStepStore.ts @@ -0,0 +1,135 @@ +"use client"; + +import { create } from "zustand"; +import { devtools } from "zustand/middleware"; + +/** + * 플로우 단계 전역 상태 관리 + * + * 플로우 위젯에서 단계를 선택하면 이 스토어에 저장되고, + * 버튼 컴포넌트들이 현재 선택된 단계를 구독하여 표시/숨김을 결정합니다. + */ +interface FlowStepState { + /** + * 화면당 여러 플로우의 현재 선택된 단계 + * key: flowComponentId (예: "component-123") + * value: stepId (플로우 단계 ID) 또는 null (선택 해제) + */ + selectedSteps: Record; + + /** + * 플로우 단계 선택 + * @param flowComponentId 플로우 컴포넌트 고유 ID + * @param stepId 선택된 단계 ID (null이면 선택 해제) + */ + setSelectedStep: (flowComponentId: string, stepId: number | null) => void; + + /** + * 현재 단계 조회 + * @param flowComponentId 플로우 컴포넌트 고유 ID + * @returns 현재 선택된 단계 ID 또는 null + */ + getCurrentStep: (flowComponentId: string) => number | null; + + /** + * 모든 플로우 초기화 (화면 전환 시 사용) + */ + reset: () => void; + + /** + * 특정 플로우만 초기화 (플로우 위젯 언마운트 시 사용) + * @param flowComponentId 플로우 컴포넌트 고유 ID + */ + resetFlow: (flowComponentId: string) => void; +} + +export const useFlowStepStore = create()( + devtools( + (set, get) => ({ + selectedSteps: {}, + + setSelectedStep: (flowComponentId, stepId) => { + console.log("🔄 [FlowStepStore] 플로우 단계 변경:", { + flowComponentId, + stepId, + stepName: stepId ? `Step ${stepId}` : "선택 해제", + }); + + set((state) => ({ + selectedSteps: { + ...state.selectedSteps, + [flowComponentId]: stepId, + }, + })); + + // 개발 모드에서 현재 상태 출력 + if (process.env.NODE_ENV === "development") { + const currentState = get().selectedSteps; + console.log("📊 [FlowStepStore] 현재 상태:", currentState); + } + }, + + getCurrentStep: (flowComponentId) => { + const stepId = get().selectedSteps[flowComponentId] || null; + + if (process.env.NODE_ENV === "development") { + console.log("🔍 [FlowStepStore] 현재 단계 조회:", { + flowComponentId, + stepId, + }); + } + + return stepId; + }, + + reset: () => { + console.log("🔄 [FlowStepStore] 모든 플로우 단계 초기화"); + set({ selectedSteps: {} }); + }, + + resetFlow: (flowComponentId) => { + console.log("🔄 [FlowStepStore] 플로우 단계 초기화:", flowComponentId); + + set((state) => { + const { [flowComponentId]: _, ...rest } = state.selectedSteps; + return { selectedSteps: rest }; + }); + }, + }), + { name: "FlowStepStore" } + ) +); + +/** + * 특정 플로우의 현재 단계를 구독하는 Hook + * + * @example + * const currentStep = useCurrentFlowStep("component-123"); + * if (currentStep === null) { + * // 단계가 선택되지 않음 + * } + */ +export const useCurrentFlowStep = (flowComponentId: string | null | undefined) => { + return useFlowStepStore((state) => { + if (!flowComponentId) return null; + return state.getCurrentStep(flowComponentId); + }); +}; + +/** + * 여러 플로우의 현재 단계를 한 번에 구독하는 Hook + * + * @example + * const steps = useMultipleFlowSteps(["component-123", "component-456"]); + * // { "component-123": 1, "component-456": null } + */ +export const useMultipleFlowSteps = (flowComponentIds: string[]) => { + return useFlowStepStore((state) => { + const result: Record = {}; + flowComponentIds.forEach((id) => { + result[id] = state.getCurrentStep(id); + }); + return result; + }); +}; + diff --git a/frontend/types/control-management.ts b/frontend/types/control-management.ts index ca0e5c94..aba4c87e 100644 --- a/frontend/types/control-management.ts +++ b/frontend/types/control-management.ts @@ -49,12 +49,29 @@ export interface ExtendedButtonTypeConfig { dataflowConfig?: ButtonDataflowConfig; dataflowTiming?: "before" | "after" | "replace"; + // 🆕 플로우 단계별 표시 제어 + flowVisibilityConfig?: FlowVisibilityConfig; + // 스타일 설정 backgroundColor?: string; textColor?: string; borderColor?: string; } +/** + * 플로우 단계별 버튼 표시 설정 + */ +export interface FlowVisibilityConfig { + enabled: boolean; + targetFlowComponentId: string; + targetFlowId?: number; + targetFlowName?: string; + mode: "whitelist" | "blacklist" | "all"; + visibleSteps?: number[]; + hiddenSteps?: number[]; + layoutBehavior: "preserve-position" | "auto-compact"; +} + /** * 🔥 단순화된 버튼 데이터플로우 설정 */ diff --git a/frontend/types/screen-management.ts b/frontend/types/screen-management.ts index 2d1e6015..cb9ec6af 100644 --- a/frontend/types/screen-management.ts +++ b/frontend/types/screen-management.ts @@ -289,6 +289,59 @@ export interface ButtonTypeConfig { // ButtonActionType과 관련된 설정은 control-management.ts에서 정의 } +/** + * 플로우 단계별 버튼 표시 설정 + * + * 플로우 위젯과 버튼을 함께 사용할 때, 특정 플로우 단계에서만 버튼을 표시하거나 숨길 수 있습니다. + */ +export interface FlowVisibilityConfig { + /** + * 플로우 단계별 표시 제어 활성화 여부 + */ + enabled: boolean; + + /** + * 대상 플로우 컴포넌트 ID + * 화면에 여러 플로우 위젯이 있을 경우, 어떤 플로우에 반응할지 지정 + */ + targetFlowComponentId: string; + + /** + * 대상 플로우 정의 ID (선택사항, 검증용) + */ + targetFlowId?: number; + + /** + * 대상 플로우 이름 (표시용) + */ + targetFlowName?: string; + + /** + * 표시 조건 모드 + * - whitelist: visibleSteps에 포함된 단계에서만 표시 + * - blacklist: hiddenSteps에 포함된 단계에서 숨김 + * - all: 모든 단계에서 표시 (기본값) + */ + mode: "whitelist" | "blacklist" | "all"; + + /** + * 표시할 단계 ID 목록 (mode="whitelist"일 때 사용) + */ + visibleSteps?: number[]; + + /** + * 숨길 단계 ID 목록 (mode="blacklist"일 때 사용) + */ + hiddenSteps?: number[]; + + /** + * 레이아웃 동작 방식 + * - preserve-position: 원래 위치 유지 (display: none, 빈 공간 유지) + * - auto-compact: 빈 공간 자동 제거 (Flexbox, 렌더링하지 않음) + */ + layoutBehavior: "preserve-position" | "auto-compact"; +} + // ===== 데이터 테이블 관련 ===== /**