From 08ed6b0b53408b8701e5800a50e6b996c6b54340 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Thu, 12 Mar 2026 05:51:39 +0900 Subject: [PATCH] [agent-pipeline] pipe-20260311204151-c4wy round-1 --- .../v2/config-panels/V2ButtonConfigPanel.tsx | 1402 +++++++++++++++++ .../components/v2-button-primary/index.ts | 6 +- 2 files changed, 1404 insertions(+), 4 deletions(-) create mode 100644 frontend/components/v2/config-panels/V2ButtonConfigPanel.tsx diff --git a/frontend/components/v2/config-panels/V2ButtonConfigPanel.tsx b/frontend/components/v2/config-panels/V2ButtonConfigPanel.tsx new file mode 100644 index 00000000..703cb4d8 --- /dev/null +++ b/frontend/components/v2/config-panels/V2ButtonConfigPanel.tsx @@ -0,0 +1,1402 @@ +"use client"; + +/** + * V2Button 설정 패널 + * 토스식 단계별 UX: 액션 유형 카드 선택 -> 표시 모드 카드 -> 액션별 세부 설정 -> 고급 설정(접힘) + */ + +import React, { useState, useEffect, useMemo, useCallback } from "react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Button } from "@/components/ui/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Separator } from "@/components/ui/separator"; +import { + Save, + Trash2, + Pencil, + ArrowRight, + Maximize2, + SendHorizontal, + Download, + Upload, + Zap, + Settings, + ChevronDown, + Check, + Plus, + X, + Type, + Image, + Columns, + ScanLine, + Truck, + Send, + Copy, + FileSpreadsheet, + ChevronsUpDown, + Info, + Workflow, +} from "lucide-react"; +import { icons as allLucideIcons } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { apiClient } from "@/lib/api/client"; +import { + actionIconMap, + noIconActions, + NO_ICON_MESSAGE, + iconSizePresets, + getLucideIcon, + addToIconMap, + getDefaultIconForAction, + sanitizeSvg, +} from "@/lib/button-icon-map"; +import { ButtonDataflowConfigPanel } from "@/components/screen/config-panels/ButtonDataflowConfigPanel"; +import { ImprovedButtonControlConfigPanel } from "@/components/screen/config-panels/ImprovedButtonControlConfigPanel"; +import { FlowVisibilityConfigPanel } from "@/components/screen/config-panels/FlowVisibilityConfigPanel"; +import type { ComponentData } from "@/types/screen"; + +// ─── 액션 유형 카드 정의 ─── +const ACTION_TYPE_CARDS = [ + { + value: "save", + icon: Save, + title: "저장", + description: "데이터를 저장해요", + }, + { + value: "delete", + icon: Trash2, + title: "삭제", + description: "데이터를 삭제해요", + }, + { + value: "edit", + icon: Pencil, + title: "편집", + description: "데이터를 수정해요", + }, + { + value: "modal", + icon: Maximize2, + title: "모달 열기", + description: "팝업 화면을 열어요", + }, + { + value: "navigate", + icon: ArrowRight, + title: "페이지 이동", + description: "다른 화면으로 이동해요", + }, + { + value: "transferData", + icon: SendHorizontal, + title: "데이터 전달", + description: "다른 테이블로 전달해요", + }, + { + value: "excel_download", + icon: Download, + title: "엑셀 다운로드", + description: "데이터를 엑셀로 받아요", + }, + { + value: "excel_upload", + icon: Upload, + title: "엑셀 업로드", + description: "엑셀 파일을 올려요", + }, + { + value: "quickInsert", + icon: Zap, + title: "즉시 저장", + description: "바로 저장해요", + }, + { + value: "approval", + icon: Check, + title: "결재 요청", + description: "결재를 요청해요", + }, + { + value: "control", + icon: Settings, + title: "제어 흐름", + description: "흐름을 제어해요", + }, + { + value: "event", + icon: Send, + title: "이벤트 발송", + description: "이벤트를 보내요", + }, + { + value: "copy", + icon: Copy, + title: "복사", + description: "데이터를 복사해요", + }, + { + value: "barcode_scan", + icon: ScanLine, + title: "바코드 스캔", + description: "바코드를 스캔해요", + }, + { + value: "operation_control", + icon: Truck, + title: "운행알림/종료", + description: "운행을 관리해요", + }, + { + value: "multi_table_excel_upload", + icon: FileSpreadsheet, + title: "다중 엑셀 업로드", + description: "여러 테이블에 올려요", + }, +] as const; + +// ─── 표시 모드 카드 정의 ─── +const DISPLAY_MODE_CARDS = [ + { + value: "text" as const, + icon: Type, + title: "텍스트", + description: "텍스트만 표시", + }, + { + value: "icon" as const, + icon: Image, + title: "아이콘", + description: "아이콘만 표시", + }, + { + value: "icon-text" as const, + icon: Columns, + title: "아이콘+텍스트", + description: "둘 다 표시", + }, +] as const; + +// ─── 버튼 변형 옵션 ─── +const VARIANT_OPTIONS = [ + { value: "primary", label: "기본 (Primary)" }, + { value: "secondary", label: "보조 (Secondary)" }, + { value: "danger", label: "위험 (Danger)" }, +] as const; + +interface ScreenOption { + id: number; + name: string; + description?: string; +} + +interface V2ButtonConfigPanelProps { + config: Record; + onChange: (config: Record) => void; + component?: ComponentData; + currentComponent?: ComponentData; + onUpdateProperty?: (path: string, value: any) => void; + allComponents?: ComponentData[]; + currentTableName?: string; + screenTableName?: string; + currentScreenCompanyCode?: string; + [key: string]: any; +} + +export const V2ButtonConfigPanel: React.FC = ({ + config, + onChange, + component, + currentComponent, + onUpdateProperty, + allComponents = [], + currentTableName, + screenTableName, + currentScreenCompanyCode, +}) => { + const effectiveComponent = component || currentComponent; + const effectiveTableName = currentTableName || screenTableName; + const actionType = String(config.action?.type || "save"); + const displayMode = (config.displayMode as "text" | "icon" | "icon-text") || "text"; + const variant = config.variant || "primary"; + const buttonText = config.text !== undefined ? config.text : "버튼"; + + // 아이콘 상태 + const [selectedIcon, setSelectedIcon] = useState(config.icon?.name || ""); + const [selectedIconType, setSelectedIconType] = useState<"lucide" | "svg">( + config.icon?.type || "lucide" + ); + const [iconSize, setIconSize] = useState(config.icon?.size || "보통"); + + // UI 상태 + const [iconSectionOpen, setIconSectionOpen] = useState(false); + const [advancedOpen, setAdvancedOpen] = useState(false); + const [dataflowOpen, setDataflowOpen] = useState(false); + const [lucideSearchOpen, setLucideSearchOpen] = useState(false); + const [lucideSearchTerm, setLucideSearchTerm] = useState(""); + const [svgPasteOpen, setSvgPasteOpen] = useState(false); + const [svgInput, setSvgInput] = useState(""); + const [svgName, setSvgName] = useState(""); + const [svgError, setSvgError] = useState(""); + + // 모달 관련 + const [screens, setScreens] = useState([]); + const [screensLoading, setScreensLoading] = useState(false); + const [modalScreenOpen, setModalScreenOpen] = useState(false); + const [modalSearchTerm, setModalSearchTerm] = useState(""); + + const showIconSettings = displayMode === "icon" || displayMode === "icon-text"; + const currentActionIcons = actionIconMap[actionType] || []; + const isNoIconAction = noIconActions.has(actionType); + const customIcons: string[] = config.customIcons || []; + const customSvgIcons: Array<{ name: string; svg: string }> = config.customSvgIcons || []; + + // 플로우 위젯 존재 여부 + const hasFlowWidget = useMemo(() => { + return allComponents.some((comp: any) => { + const compType = comp.componentType || comp.widgetType || ""; + return compType === "flow-widget" || compType?.toLowerCase().includes("flow"); + }); + }, [allComponents]); + + // config 업데이트 헬퍼 + const updateConfig = useCallback( + (field: string, value: any) => { + onChange({ ...config, [field]: value }); + }, + [config, onChange] + ); + + const updateActionConfig = useCallback( + (field: string, value: any) => { + const currentAction = config.action || {}; + onChange({ + ...config, + action: { ...currentAction, [field]: value }, + }); + }, + [config, onChange] + ); + + // onUpdateProperty 래퍼 (V2 패널에서도 기존 컨트롤 패널 사용 가능하도록) + const handleUpdateProperty = useCallback( + (path: string, value: any) => { + if (onUpdateProperty) { + onUpdateProperty(path, value); + } else { + // path를 파싱해서 config에 직접 반영 + const parts = path.replace("componentConfig.", "").split("."); + const newConfig = { ...config }; + let current: any = newConfig; + for (let i = 0; i < parts.length - 1; i++) { + if (!current[parts[i]]) current[parts[i]] = {}; + current[parts[i]] = { ...current[parts[i]] }; + current = current[parts[i]]; + } + current[parts[parts.length - 1]] = value; + onChange(newConfig); + } + }, + [config, onChange, onUpdateProperty] + ); + + // prop 변경 시 아이콘 상태 동기화 + useEffect(() => { + setSelectedIcon(config.icon?.name || ""); + setSelectedIconType(config.icon?.type || "lucide"); + setIconSize(config.icon?.size || "보통"); + }, [config.icon?.name, config.icon?.type, config.icon?.size]); + + // 화면 목록 로드 (모달 액션용) + useEffect(() => { + if (actionType !== "modal" && actionType !== "navigate") return; + if (screens.length > 0) return; + + const loadScreens = async () => { + setScreensLoading(true); + try { + const response = await apiClient.get("/screen-management/screens"); + if (response.data.success && response.data.data) { + const screenList = response.data.data.map((s: any) => ({ + id: s.id || s.screenId, + name: s.name || s.screenName, + description: s.description || "", + })); + setScreens(screenList); + } + } catch { + setScreens([]); + } finally { + setScreensLoading(false); + } + }; + loadScreens(); + }, [actionType, screens.length]); + + // 아이콘 선택 핸들러 + const handleSelectIcon = (iconName: string, iconType: "lucide" | "svg" = "lucide") => { + setSelectedIcon(iconName); + setSelectedIconType(iconType); + updateConfig("icon", { + name: iconName, + type: iconType, + size: iconSize, + }); + }; + + const revertToDefaultIcon = () => { + const def = getDefaultIconForAction(actionType); + handleSelectIcon(def.name, def.type); + }; + + // 액션 유형 변경 핸들러 + const handleActionTypeChange = (newType: string) => { + const currentAction = config.action || {}; + onChange({ + ...config, + action: { ...currentAction, type: newType }, + }); + + // 아이콘이 새 액션 추천에 없으면 초기화 + const newActionIcons = actionIconMap[newType] || []; + if (selectedIcon && selectedIconType === "lucide" && !newActionIcons.includes(selectedIcon) && !customIcons.includes(selectedIcon)) { + setSelectedIcon(""); + updateConfig("icon", undefined); + } + }; + + // componentData 생성 (기존 패널 재사용용) + const componentData = useMemo(() => { + if (effectiveComponent) return effectiveComponent; + return { + id: "virtual", + type: "widget" as const, + position: { x: 0, y: 0 }, + size: { width: 120, height: 40 }, + componentConfig: config, + componentType: "v2-button-primary", + } as ComponentData; + }, [effectiveComponent, config]); + + return ( +
+ {/* ─── 1단계: 버튼 액션 유형 선택 (가장 중요) ─── */} +
+

이 버튼은 어떤 동작을 하나요?

+
+ {ACTION_TYPE_CARDS.map((card) => { + const Icon = card.icon; + const isSelected = actionType === card.value; + return ( + + ); + })} +
+
+ + + + {/* ─── 2단계: 표시 모드 선택 ─── */} +
+

버튼을 어떻게 표시할까요?

+
+ {DISPLAY_MODE_CARDS.map((card) => { + const Icon = card.icon; + const isSelected = displayMode === card.value; + return ( + + ); + })} +
+
+ + {/* ─── 버튼 텍스트 ─── */} + {(displayMode === "text" || displayMode === "icon-text") && ( +
+ + updateConfig("text", e.target.value)} + placeholder="버튼에 표시할 텍스트" + className="h-8 text-sm" + /> +
+ )} + + {/* ─── 버튼 변형 ─── */} +
+ + +
+ + + + {/* ─── 3단계: 액션별 세부 설정 ─── */} + + + {/* ─── 아이콘 설정 (접기) ─── */} + {showIconSettings && ( + + + + + +
+ { + setIconSize(preset); + if (selectedIcon) { + updateConfig("icon", { ...config.icon, size: preset }); + } + }} + onIconTextPositionChange={(pos) => updateConfig("iconTextPosition", pos)} + onIconGapChange={(gap) => updateConfig("iconGap", gap)} + onCustomIconsChange={(icons) => updateConfig("customIcons", icons)} + onCustomSvgIconsChange={(icons) => updateConfig("customSvgIcons", icons)} + /> +
+
+
+ )} + + {/* ─── 데이터플로우 설정 (접기) ─── */} + + + + + +
+ +
+
+
+ + {/* ─── 고급 설정 (접기) ─── */} + + + + + +
+ {/* 행 선택 활성화 */} +
+
+
+

행 선택 시에만 활성화

+

+ 테이블에서 행을 선택해야만 버튼이 활성화돼요 +

+
+ updateActionConfig("requireRowSelection", checked)} + /> +
+ + {config.action?.requireRowSelection && ( +
+
+ + +
+ +
+
+

다중 선택 허용

+

여러 행 선택 시에도 활성화

+
+ updateActionConfig("allowMultiRowSelection", checked)} + /> +
+
+ )} +
+ + {/* 제어 기능 */} + {actionType !== "excel_upload" && actionType !== "multi_table_excel_upload" && ( + <> + + + + )} + + {/* 플로우 단계별 표시 제어 */} + {hasFlowWidget && ( + <> + + + + )} +
+
+
+
+ ); +}; + +// ─── 액션별 세부 설정 서브 컴포넌트 ─── +const ActionDetailSection: React.FC<{ + actionType: string; + config: Record; + updateConfig: (field: string, value: any) => void; + updateActionConfig: (field: string, value: any) => void; + screens: ScreenOption[]; + screensLoading: boolean; + modalScreenOpen: boolean; + setModalScreenOpen: (open: boolean) => void; + modalSearchTerm: string; + setModalSearchTerm: (term: string) => void; + currentTableName?: string; +}> = ({ + actionType, + config, + updateConfig, + updateActionConfig, + screens, + screensLoading, + modalScreenOpen, + setModalScreenOpen, + modalSearchTerm, + setModalSearchTerm, + currentTableName, +}) => { + const action = config.action || {}; + + // 성공/에러 메시지 (모든 액션 공통) + const commonMessageSection = ( +
+
+ + updateActionConfig("successMessage", e.target.value)} + placeholder="처리되었습니다." + className="h-7 text-xs" + /> +
+
+ + updateActionConfig("errorMessage", e.target.value)} + placeholder="처리 중 오류가 발생했습니다." + className="h-7 text-xs" + /> +
+
+ ); + + switch (actionType) { + case "save": + case "delete": + case "edit": + case "quickInsert": + return ( +
+
+ + + {actionType === "save" && "저장 설정"} + {actionType === "delete" && "삭제 설정"} + {actionType === "edit" && "편집 설정"} + {actionType === "quickInsert" && "즉시 저장 설정"} + +
+ {commonMessageSection} + + {actionType === "delete" && ( +
+
+

삭제 확인 팝업

+

삭제 전 확인 대화상자를 표시해요

+
+ updateActionConfig("confirmBeforeDelete", checked)} + /> +
+ )} +
+ ); + + case "modal": + return ( +
+
+ + 모달 설정 +
+ + {/* 대상 화면 선택 */} +
+ + + + + + + + + + 화면을 찾을 수 없습니다. + + {screens + .filter((s) => + s.name.toLowerCase().includes(modalSearchTerm.toLowerCase()) || + s.description?.toLowerCase().includes(modalSearchTerm.toLowerCase()) + ) + .map((screen) => ( + { + updateActionConfig("targetScreenId", screen.id); + setModalScreenOpen(false); + setModalSearchTerm(""); + }} + className="text-xs" + > + +
+ {screen.name} + {screen.description && ( + {screen.description} + )} +
+
+ ))} +
+
+
+
+
+
+ + {/* 모달 제목/설명 */} +
+ + updateActionConfig("modalTitle", e.target.value)} + placeholder="모달 제목" + className="h-7 text-xs" + /> +
+
+ + updateActionConfig("modalDescription", e.target.value)} + placeholder="모달 설명" + className="h-7 text-xs" + /> +
+ + {/* 데이터 자동 전달 */} +
+
+

데이터 자동 전달

+

선택된 행의 데이터를 모달에 전달해요

+
+ updateActionConfig("autoDetectDataSource", checked)} + /> +
+ + {commonMessageSection} +
+ ); + + case "navigate": + return ( +
+
+ + 이동 설정 +
+
+ + updateActionConfig("targetUrl", e.target.value)} + placeholder="/admin/example" + className="h-7 text-xs" + /> +
+ {commonMessageSection} +
+ ); + + case "excel_download": + return ( +
+
+ + 엑셀 다운로드 설정 +
+ +
+
+

현재 필터 적용

+

검색 조건이 적용된 데이터만 다운로드

+
+ updateActionConfig("applyCurrentFilters", checked)} + /> +
+ +
+
+

선택된 행만

+

테이블에서 선택한 행만 다운로드

+
+ updateActionConfig("selectedRowsOnly", checked)} + /> +
+ + {commonMessageSection} +
+ ); + + case "excel_upload": + case "multi_table_excel_upload": + return ( +
+
+ + + {actionType === "multi_table_excel_upload" ? "다중 테이블 엑셀 업로드 설정" : "엑셀 업로드 설정"} + +
+ {commonMessageSection} +
+ ); + + case "event": + return ( +
+
+ + 이벤트 설정 +
+
+ + updateActionConfig("eventName", e.target.value)} + placeholder="이벤트 이름" + className="h-7 text-xs" + /> +
+ {commonMessageSection} +
+ ); + + default: + return ( +
+
+ + 기본 설정 +
+ {commonMessageSection} +
+ ); + } +}; + +// ─── 아이콘 설정 서브 컴포넌트 ─── +const IconSettingsSection: React.FC<{ + actionType: string; + isNoIconAction: boolean; + currentActionIcons: string[]; + selectedIcon: string; + selectedIconType: "lucide" | "svg"; + iconSize: string; + displayMode: string; + iconTextPosition: string; + iconGap: number; + customIcons: string[]; + customSvgIcons: Array<{ name: string; svg: string }>; + lucideSearchOpen: boolean; + setLucideSearchOpen: (open: boolean) => void; + lucideSearchTerm: string; + setLucideSearchTerm: (term: string) => void; + svgPasteOpen: boolean; + setSvgPasteOpen: (open: boolean) => void; + svgInput: string; + setSvgInput: (input: string) => void; + svgName: string; + setSvgName: (name: string) => void; + svgError: string; + setSvgError: (error: string) => void; + onSelectIcon: (name: string, type?: "lucide" | "svg") => void; + onRevertToDefault: () => void; + onIconSizeChange: (preset: string) => void; + onIconTextPositionChange: (pos: string) => void; + onIconGapChange: (gap: number) => void; + onCustomIconsChange: (icons: string[]) => void; + onCustomSvgIconsChange: (icons: Array<{ name: string; svg: string }>) => void; +}> = ({ + actionType, + isNoIconAction, + currentActionIcons, + selectedIcon, + selectedIconType, + iconSize, + displayMode, + iconTextPosition, + iconGap, + customIcons, + customSvgIcons, + lucideSearchOpen, + setLucideSearchOpen, + lucideSearchTerm, + setLucideSearchTerm, + svgPasteOpen, + setSvgPasteOpen, + svgInput, + setSvgInput, + svgName, + setSvgName, + svgError, + setSvgError, + onSelectIcon, + onRevertToDefault, + onIconSizeChange, + onIconTextPositionChange, + onIconGapChange, + onCustomIconsChange, + onCustomSvgIconsChange, +}) => { + // 추천 아이콘 영역 + const renderIconGrid = (icons: string[], type: "lucide" | "svg" = "lucide") => ( +
+ {icons.map((iconName) => { + const Icon = getLucideIcon(iconName); + if (!Icon) return null; + return ( + + ); + })} +
+ ); + + return ( +
+ {/* 추천 아이콘 */} + {isNoIconAction ? ( +
+ {NO_ICON_MESSAGE} +
+ ) : ( +
+

추천 아이콘

+ {renderIconGrid(currentActionIcons)} +
+ )} + + {/* 커스텀 아이콘 */} + {(customIcons.length > 0 || customSvgIcons.length > 0) && ( + <> +
+
+ 커스텀 아이콘 +
+
+
+ {customIcons.map((iconName) => { + const Icon = getLucideIcon(iconName); + if (!Icon) return null; + return ( +
+ + +
+ ); + })} + {customSvgIcons.map((svgIcon) => ( +
+ + +
+ ))} +
+ + )} + + {/* 커스텀 아이콘 추가 */} +
+ + + + + + + + + 아이콘을 찾을 수 없습니다. + + {Object.keys(allLucideIcons) + .filter((name) => name.toLowerCase().includes(lucideSearchTerm.toLowerCase())) + .slice(0, 30) + .map((iconName) => { + const Icon = allLucideIcons[iconName as keyof typeof allLucideIcons]; + return ( + { + const next = [...customIcons]; + if (!next.includes(iconName)) { + next.push(iconName); + onCustomIconsChange(next); + if (Icon) addToIconMap(iconName, Icon); + } + setLucideSearchOpen(false); + setLucideSearchTerm(""); + }} + className="flex items-center gap-2 text-xs" + > + {Icon ? : } + {iconName} + {customIcons.includes(iconName) && } + + ); + })} + + + + + + + + + + + + + setSvgName(e.target.value)} + placeholder="예: 회사로고" + className="h-7 text-xs" + /> + +