"use client"; import React, { useState, useRef, useEffect, useMemo } from "react"; import { ComponentRendererProps } from "@/types/component"; import { ButtonPrimaryConfig } from "./types"; import { ButtonActionExecutor, ButtonActionContext, ButtonActionType, DEFAULT_BUTTON_ACTIONS, } from "@/lib/utils/buttonActions"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } 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; // 추가 props screenId?: number; tableName?: string; onRefresh?: () => void; onClose?: () => void; onFlowRefresh?: () => void; // 폼 데이터 관련 originalData?: Record; // 부분 업데이트용 원본 데이터 // 테이블 선택된 행 정보 (다중 선택 액션용) selectedRows?: any[]; selectedRowsData?: any[]; // 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용) flowSelectedData?: any[]; flowSelectedStepId?: number | null; } /** * ButtonPrimary 컴포넌트 * button-primary 컴포넌트입니다 */ export const ButtonPrimaryComponent: React.FC = ({ component, isDesignMode = false, isSelected = false, isInteractive = false, onClick, onDragStart, onDragEnd, config, className, style, formData, originalData, onFormDataChange, screenId, tableName, onRefresh, onClose, onFlowRefresh, selectedRows, selectedRowsData, flowSelectedData, flowSelectedStepId, ...props }) => { // 🆕 플로우 단계별 표시 제어 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") { 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; } return result; }, [flowConfig, currentStep, component.id, component.label]); // 확인 다이얼로그 상태 const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [pendingAction, setPendingAction] = useState<{ type: ButtonActionType; config: any; context: ButtonActionContext; } | null>(null); // 토스트 정리를 위한 ref const currentLoadingToastRef = useRef(); // 컴포넌트 언마운트 시 토스트 정리 useEffect(() => { return () => { if (currentLoadingToastRef.current !== undefined) { toast.dismiss(currentLoadingToastRef.current); currentLoadingToastRef.current = undefined; } }; }, []); // 삭제 액션 감지 로직 (실제 필드명 사용) const isDeleteAction = () => { const deleteKeywords = ["삭제", "delete", "remove", "제거", "del"]; return ( component.componentConfig?.action?.type === "delete" || component.config?.action?.type === "delete" || component.webTypeConfig?.actionType === "delete" || component.text?.toLowerCase().includes("삭제") || component.text?.toLowerCase().includes("delete") || component.label?.toLowerCase().includes("삭제") || component.label?.toLowerCase().includes("delete") || deleteKeywords.some( (keyword) => component.config?.buttonText?.toLowerCase().includes(keyword) || component.config?.text?.toLowerCase().includes(keyword), ) ); }; // 삭제 액션일 때 라벨 색상 자동 설정 useEffect(() => { if (isDeleteAction() && !component.style?.labelColor) { // 삭제 액션이고 라벨 색상이 설정되지 않은 경우 빨간색으로 자동 설정 if (component.style) { component.style.labelColor = "#ef4444"; } else { component.style = { labelColor: "#ef4444" }; } } }, [component.componentConfig?.action?.type, component.config?.action?.type, component.webTypeConfig?.actionType]); // 컴포넌트 설정 const componentConfig = { ...config, ...component.config, } as ButtonPrimaryConfig; // 🎨 동적 색상 설정 (속성편집 모달의 "색상" 필드와 연동) const getLabelColor = () => { if (isDeleteAction()) { return component.style?.labelColor || "#ef4444"; // 빨간색 기본값 (Tailwind red-500) } return component.style?.labelColor || "#212121"; // 검은색 기본값 (shadcn/ui primary) }; const buttonColor = getLabelColor(); // 그라데이션용 어두운 색상 계산 const getDarkColor = (baseColor: string) => { const hex = baseColor.replace("#", ""); const r = Math.max(0, parseInt(hex.substr(0, 2), 16) - 40); const g = Math.max(0, parseInt(hex.substr(2, 2), 16) - 40); const b = Math.max(0, parseInt(hex.substr(4, 2), 16) - 40); return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`; }; const buttonDarkColor = getDarkColor(buttonColor); console.log("🎨 동적 색상 연동:", { labelColor: component.style?.labelColor, buttonColor, buttonDarkColor, }); // 액션 설정 처리 - DB에서 문자열로 저장된 액션을 객체로 변환 const processedConfig = { ...componentConfig }; if (componentConfig.action && typeof componentConfig.action === "string") { const actionType = componentConfig.action as ButtonActionType; processedConfig.action = { ...DEFAULT_BUTTON_ACTIONS[actionType], type: actionType, // 🔥 제어관리 설정 추가 (webTypeConfig에서 가져옴) enableDataflowControl: component.webTypeConfig?.enableDataflowControl, dataflowConfig: component.webTypeConfig?.dataflowConfig, }; } else if (componentConfig.action && typeof componentConfig.action === "object") { // 🔥 이미 객체인 경우에도 제어관리 설정 추가 processedConfig.action = { ...componentConfig.action, enableDataflowControl: component.webTypeConfig?.enableDataflowControl, dataflowConfig: component.webTypeConfig?.dataflowConfig, }; } // 스타일 계산 // height: 100%로 부모(RealtimePreviewDynamic의 내부 div)의 높이를 따라감 const componentStyle: React.CSSProperties = { width: "100%", height: "100%", ...component.style, ...style, }; // 디자인 모드 스타일 (border 속성 분리하여 충돌 방지) if (isDesignMode) { componentStyle.borderWidth = "1px"; componentStyle.borderStyle = "dashed"; componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1"; } // 확인 다이얼로그가 필요한 액션 타입들 const confirmationRequiredActions: ButtonActionType[] = ["save", "delete"]; // 실제 액션 실행 함수 const executeAction = async (actionConfig: any, context: ButtonActionContext) => { try { // 기존 토스트가 있다면 먼저 제거 if (currentLoadingToastRef.current !== undefined) { toast.dismiss(currentLoadingToastRef.current); currentLoadingToastRef.current = undefined; } // 추가 안전장치: 모든 로딩 토스트 제거 toast.dismiss(); // UI 전환 액션(edit, modal, navigate)을 제외하고만 로딩 토스트 표시 const silentActions = ["edit", "modal", "navigate"]; if (!silentActions.includes(actionConfig.type)) { currentLoadingToastRef.current = toast.loading( actionConfig.type === "save" ? "저장 중..." : actionConfig.type === "delete" ? "삭제 중..." : actionConfig.type === "submit" ? "제출 중..." : "처리 중...", { duration: Infinity, // 명시적으로 무한대로 설정 }, ); } const success = await ButtonActionExecutor.executeAction(actionConfig, context); // 로딩 토스트 제거 (있는 경우에만) if (currentLoadingToastRef.current !== undefined) { toast.dismiss(currentLoadingToastRef.current); currentLoadingToastRef.current = undefined; } // 실패한 경우 오류 처리 if (!success) { // UI 전환 액션(edit, modal, navigate)은 에러도 조용히 처리 const silentActions = ["edit", "modal", "navigate"]; if (silentActions.includes(actionConfig.type)) { return; } // 기본 에러 메시지 결정 const defaultErrorMessage = actionConfig.type === "save" ? "저장 중 오류가 발생했습니다." : actionConfig.type === "delete" ? "삭제 중 오류가 발생했습니다." : actionConfig.type === "submit" ? "제출 중 오류가 발생했습니다." : "처리 중 오류가 발생했습니다."; // 커스텀 메시지 사용 조건: // 1. 커스텀 메시지가 있고 // 2. (액션 타입이 save이거나 OR 메시지에 "저장"이 포함되지 않은 경우) const useCustomMessage = actionConfig.errorMessage && (actionConfig.type === "save" || !actionConfig.errorMessage.includes("저장")); const errorMessage = useCustomMessage ? actionConfig.errorMessage : defaultErrorMessage; toast.error(errorMessage); return; } // 성공한 경우에만 성공 토스트 표시 // edit, modal, navigate 액션은 조용히 처리 (UI 전환만 하므로 토스트 불필요) if (actionConfig.type !== "edit" && actionConfig.type !== "modal" && actionConfig.type !== "navigate") { // 기본 성공 메시지 결정 const defaultSuccessMessage = actionConfig.type === "save" ? "저장되었습니다." : actionConfig.type === "delete" ? "삭제되었습니다." : actionConfig.type === "submit" ? "제출되었습니다." : "완료되었습니다."; // 커스텀 메시지 사용 조건: // 1. 커스텀 메시지가 있고 // 2. (액션 타입이 save이거나 OR 메시지에 "저장"이 포함되지 않은 경우) const useCustomMessage = actionConfig.successMessage && (actionConfig.type === "save" || !actionConfig.successMessage.includes("저장")); const successMessage = useCustomMessage ? actionConfig.successMessage : defaultSuccessMessage; toast.success(successMessage); } // 저장/수정 성공 시 자동 처리 if (actionConfig.type === "save" || actionConfig.type === "edit") { if (typeof window !== "undefined") { // 1. 테이블 새로고침 이벤트 먼저 발송 (모달이 닫히기 전에) window.dispatchEvent(new CustomEvent("refreshTable")); // 2. 모달 닫기 (약간의 딜레이) setTimeout(() => { // EditModal 내부인지 확인 (isInModal prop 사용) const isInEditModal = (props as any).isInModal; if (isInEditModal) { window.dispatchEvent(new CustomEvent("closeEditModal")); } // ScreenModal은 항상 닫기 window.dispatchEvent(new CustomEvent("closeSaveModal")); }, 100); } } } catch (error) { // 로딩 토스트 제거 if (currentLoadingToastRef.current !== undefined) { toast.dismiss(currentLoadingToastRef.current); currentLoadingToastRef.current = undefined; } console.error("❌ 버튼 액션 실행 오류:", error); // 오류 토스트는 buttonActions.ts에서 이미 표시되므로 여기서는 제거 // (중복 토스트 방지) } }; // 이벤트 핸들러 const handleClick = async (e: React.MouseEvent) => { e.stopPropagation(); // 디자인 모드에서는 기본 onClick만 실행 if (isDesignMode) { onClick?.(); return; } // 인터랙티브 모드에서 액션 실행 if (isInteractive && processedConfig.action) { // 삭제 액션인데 선택된 데이터가 없으면 경고 메시지 표시하고 중단 const hasDataToDelete = (selectedRowsData && selectedRowsData.length > 0) || (flowSelectedData && flowSelectedData.length > 0); if (processedConfig.action.type === "delete" && !hasDataToDelete) { toast.warning("삭제할 항목을 먼저 선택해주세요."); return; } const context: ButtonActionContext = { formData: formData || {}, originalData: originalData || {}, // 부분 업데이트용 원본 데이터 추가 screenId, tableName, onFormDataChange, onRefresh, onClose, onFlowRefresh, // 플로우 새로고침 콜백 추가 // 테이블 선택된 행 정보 추가 selectedRows, selectedRowsData, // 플로우 선택된 데이터 정보 추가 flowSelectedData, flowSelectedStepId, }; // 확인이 필요한 액션인지 확인 if (confirmationRequiredActions.includes(processedConfig.action.type)) { // 확인 다이얼로그 표시 setPendingAction({ type: processedConfig.action.type, config: processedConfig.action, context, }); setShowConfirmDialog(true); } else { // 확인이 필요하지 않은 액션은 바로 실행 await executeAction(processedConfig.action, context); } } else { // 액션이 설정되지 않은 경우 기본 onClick 실행 onClick?.(); } }; // 확인 다이얼로그에서 확인 버튼 클릭 시 const handleConfirmAction = async () => { if (pendingAction) { await executeAction(pendingAction.config, pendingAction.context); } setShowConfirmDialog(false); setPendingAction(null); }; // 확인 다이얼로그에서 취소 버튼 클릭 시 const handleCancelAction = () => { setShowConfirmDialog(false); setPendingAction(null); }; // DOM에 전달하면 안 되는 React-specific props 필터링 const { selectedScreen, onZoneComponentDrop, onZoneClick, componentConfig: _componentConfig, component: _component, isSelected: _isSelected, onClick: _onClick, onDragStart: _onDragStart, onDragEnd: _onDragEnd, size: _size, position: _position, style: _style, screenId: _screenId, tableName: _tableName, onRefresh: _onRefresh, onClose: _onClose, selectedRows: _selectedRows, selectedRowsData: _selectedRowsData, onSelectedRowsChange: _onSelectedRowsChange, flowSelectedData: _flowSelectedData, // 플로우 선택 데이터 필터링 flowSelectedStepId: _flowSelectedStepId, // 플로우 선택 스텝 ID 필터링 onFlowRefresh: _onFlowRefresh, // 플로우 새로고침 콜백 필터링 originalData: _originalData, // 부분 업데이트용 원본 데이터 필터링 refreshKey: _refreshKey, // 필터링 추가 isInModal: _isInModal, // 필터링 추가 mode: _mode, // 필터링 추가 ...domProps } = props; // 다이얼로그 메시지 생성 const getConfirmMessage = () => { if (!pendingAction) return ""; const customMessage = pendingAction.config.confirmMessage; if (customMessage) return customMessage; switch (pendingAction.type) { case "save": return "변경사항을 저장하시겠습니까?"; case "delete": return "정말로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."; case "submit": return "제출하시겠습니까?"; default: return "이 작업을 실행하시겠습니까?"; } }; const getConfirmTitle = () => { if (!pendingAction) return ""; switch (pendingAction.type) { case "save": return "저장 확인"; case "delete": return "삭제 확인"; case "submit": return "제출 확인"; default: return "작업 확인"; } }; // DOM 안전한 props만 필터링 const safeDomProps = filterDOMProps(domProps); // 🆕 플로우 단계별 표시 제어 if (!shouldShowButton) { // 레이아웃 동작에 따라 다르게 처리 if (flowConfig?.layoutBehavior === "preserve-position") { // 위치 유지 (빈 공간, display: none) return
; } else { // 완전히 렌더링하지 않음 (auto-compact, 빈 공간 제거) return null; } } return ( <>
{/* 확인 다이얼로그 - EditModal보다 위에 표시하도록 z-index 최상위로 설정 */} {getConfirmTitle()} {getConfirmMessage()} 취소 {pendingAction?.type === "save" ? "저장" : pendingAction?.type === "delete" ? "삭제" : pendingAction?.type === "submit" ? "제출" : "확인"} ); }; /** * ButtonPrimary 래퍼 컴포넌트 * 추가적인 로직이나 상태 관리가 필요한 경우 사용 */ export const ButtonPrimaryWrapper: React.FC = (props) => { return ; };