ERP-node/PHASE_FLOW_STEP_BUTTON_VISI...

41 KiB
Raw Permalink Blame History

플로우 단계별 버튼 표시/숨김 제어 시스템 구현 계획서

📋 목차

  1. 개요
  2. 요구사항 분석
  3. UI/UX 문제 및 해결방안
  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 자동 정렬 (권장)
// 버튼 컨테이너를 자동으로 감지하고 Flexbox로 변환
<div className="flex gap-2 flex-wrap">
  {visibleButtons.map(button => <Button />)}
</div>

장점:

  • 자동으로 빈 공간 채움
  • CSS 기반으로 성능 우수
  • 반응형 대응 자동

단점:

  • 기존 절대 위치(absolute positioning) 레이아웃과 충돌 가능
옵션 B: 조건부 렌더링 (현재 구조 유지)
// 각 버튼을 원래 위치에 렌더링하되, 보이지 않는 버튼은 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: 그룹 기반 재정렬 (하이브리드)
// 버튼들을 그룹으로 묶고, 그룹 내에서만 재정렬
<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. 디자이너 캔버스
// 플로우 단계 선택 시, 영향받는 버튼에 시각적 표시
<Button 
  className={cn(
    shouldShowInCurrentStep ? "ring-2 ring-green-500" : "opacity-30 ring-2 ring-red-500"
  )}
>
  저장
</Button>
3-2. 버튼 컴포넌트 뱃지
// 플로우 제어가 활성화된 버튼에 뱃지 표시
<Button>
  저장
  {hasFlowControl && (
    <Badge className="ml-2">
      <Workflow className="h-3 w-3" />
    </Badge>
  )}
</Button>

시스템 아키텍처

1. 전역 상태 관리 (Zustand Store)

FlowStepStore

// 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 확장

// 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 확장

// types/screen.ts

export interface ComponentData {
  // 기존 필드들...
  
  // 🆕 버튼 그룹 정보 (옵션 C 구현 시)
  buttonGroupId?: string; // 버튼 그룹 ID
  buttonGroupLayout?: "horizontal" | "vertical" | "grid";
  buttonGroupAlign?: "start" | "center" | "end" | "space-between";
}

3. 데이터베이스 스키마 (선택사항)

플로우 설정을 DB에 저장하려면:

-- 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

"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

// 기존 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

// 기존 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

"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 추가:

// 기존 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일)

  • 요구사항 분석 및 계획 수립
  • 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부터 순차적으로 구현 시작