"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 { 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 [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] ); // 기존 서브패널(ImprovedButtonControlConfigPanel 등)이 webTypeConfig.* 경로로 쓰므로 // 항상 config 기반 onChange로 통일 (onUpdateProperty는 V2 경로 불일치 문제 있음) const handleUpdateProperty = useCallback( (path: string, value: any) => { const normalizedPath = path .replace(/^componentConfig\./, "") .replace(/^webTypeConfig\./, ""); const parts = normalizedPath.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] ); // 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 생성 (기존 패널 재사용용) // effectiveComponent가 있어도 config 변경분을 반드시 반영해야 토글 등이 동작함 const componentData = useMemo(() => { if (effectiveComponent) { return { ...effectiveComponent, componentConfig: config, webTypeConfig: config, } as ComponentData; } return { id: "virtual", type: "widget" as const, position: { x: 0, y: 0 }, size: { width: 120, height: 40 }, componentConfig: config, webTypeConfig: 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" />