ERP-node/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx

550 lines
19 KiB
TypeScript
Raw Normal View History

2025-09-11 18:38:28 +09:00
"use client";
import React, { useState, useRef, useEffect } from "react";
2025-09-11 18:38:28 +09:00
import { ComponentRendererProps } from "@/types/component";
import { ButtonPrimaryConfig } from "./types";
2025-09-12 14:24:25 +09:00
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";
2025-09-11 18:38:28 +09:00
export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
config?: ButtonPrimaryConfig;
2025-09-12 14:24:25 +09:00
// 추가 props
screenId?: number;
tableName?: string;
onRefresh?: () => void;
onClose?: () => void;
2025-09-18 18:49:30 +09:00
// 폼 데이터 관련
originalData?: Record<string, any>; // 부분 업데이트용 원본 데이터
// 테이블 선택된 행 정보 (다중 선택 액션용)
selectedRows?: any[];
selectedRowsData?: any[];
2025-09-11 18:38:28 +09:00
}
/**
* ButtonPrimary
* button-primary
*/
export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
component,
isDesignMode = false,
isSelected = false,
2025-09-12 14:24:25 +09:00
isInteractive = false,
2025-09-11 18:38:28 +09:00
onClick,
onDragStart,
onDragEnd,
config,
className,
style,
2025-09-12 14:24:25 +09:00
formData,
2025-09-18 18:49:30 +09:00
originalData,
2025-09-12 14:24:25 +09:00
onFormDataChange,
screenId,
tableName,
onRefresh,
onClose,
2025-09-18 18:49:30 +09:00
selectedRows,
selectedRowsData,
2025-09-11 18:38:28 +09:00
...props
}) => {
2025-09-12 14:24:25 +09:00
// 확인 다이얼로그 상태
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [pendingAction, setPendingAction] = useState<{
type: ButtonActionType;
config: any;
context: ButtonActionContext;
} | null>(null);
// 토스트 정리를 위한 ref
const currentLoadingToastRef = useRef<string | number | undefined>();
// 컴포넌트 언마운트 시 토스트 정리
useEffect(() => {
return () => {
if (currentLoadingToastRef.current !== undefined) {
console.log("🧹 컴포넌트 언마운트 시 토스트 정리");
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]);
2025-09-11 18:38:28 +09:00
// 컴포넌트 설정
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,
});
2025-09-12 14:24:25 +09:00
// 액션 설정 처리 - 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,
2025-09-12 14:24:25 +09:00
};
}
// 디버그 로그 (필요시 주석 해제)
// console.log("🔧 버튼 컴포넌트 설정:", {
// originalConfig: componentConfig,
// processedConfig,
// actionConfig: processedConfig.action,
// webTypeConfig: component.webTypeConfig,
// enableDataflowControl: component.webTypeConfig?.enableDataflowControl,
// dataflowConfig: component.webTypeConfig?.dataflowConfig,
// screenId,
// tableName,
// onRefresh,
// onClose,
// selectedRows,
// selectedRowsData,
// });
2025-09-12 14:24:25 +09:00
2025-09-11 18:38:28 +09:00
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외)
const componentStyle: React.CSSProperties = {
width: "100%",
height: "100%",
...component.style,
...style,
};
// 디자인 모드 스타일
if (isDesignMode) {
componentStyle.border = "1px dashed #cbd5e1";
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
}
2025-09-12 14:24:25 +09:00
// 확인 다이얼로그가 필요한 액션 타입들
const confirmationRequiredActions: ButtonActionType[] = ["save", "submit", "delete"];
// 실제 액션 실행 함수
const executeAction = async (actionConfig: any, context: ButtonActionContext) => {
// console.log("🚀 executeAction 시작:", { actionConfig, context });
2025-09-12 14:24:25 +09:00
try {
// 기존 토스트가 있다면 먼저 제거
if (currentLoadingToastRef.current !== undefined) {
console.log("📱 기존 토스트 제거");
toast.dismiss(currentLoadingToastRef.current);
currentLoadingToastRef.current = undefined;
}
// 추가 안전장치: 모든 로딩 토스트 제거
toast.dismiss();
2025-09-18 18:49:30 +09:00
// edit 액션을 제외하고만 로딩 토스트 표시
if (actionConfig.type !== "edit") {
console.log("📱 로딩 토스트 표시 시작");
currentLoadingToastRef.current = toast.loading(
2025-09-18 18:49:30 +09:00
actionConfig.type === "save"
? "저장 중..."
: actionConfig.type === "delete"
? "삭제 중..."
: actionConfig.type === "submit"
? "제출 중..."
: "처리 중...",
{
duration: Infinity, // 명시적으로 무한대로 설정
},
2025-09-18 18:49:30 +09:00
);
console.log("📱 로딩 토스트 ID:", currentLoadingToastRef.current);
2025-09-18 18:49:30 +09:00
}
2025-09-12 14:24:25 +09:00
console.log("⚡ ButtonActionExecutor.executeAction 호출 시작");
const success = await ButtonActionExecutor.executeAction(actionConfig, context);
console.log("⚡ ButtonActionExecutor.executeAction 완료, success:", success);
2025-09-18 18:49:30 +09:00
// 로딩 토스트 제거 (있는 경우에만)
if (currentLoadingToastRef.current !== undefined) {
console.log("📱 로딩 토스트 제거 시도, ID:", currentLoadingToastRef.current);
toast.dismiss(currentLoadingToastRef.current);
currentLoadingToastRef.current = undefined;
2025-09-18 18:49:30 +09:00
}
2025-09-12 14:24:25 +09:00
// 실패한 경우 오류 처리
if (!success) {
console.log("❌ 액션 실패, 오류 토스트 표시");
const errorMessage =
actionConfig.errorMessage ||
(actionConfig.type === "save"
? "저장 중 오류가 발생했습니다."
: actionConfig.type === "delete"
? "삭제 중 오류가 발생했습니다."
: actionConfig.type === "submit"
? "제출 중 오류가 발생했습니다."
: "처리 중 오류가 발생했습니다.");
toast.error(errorMessage);
return;
}
// 성공한 경우에만 성공 토스트 표시
2025-09-18 18:49:30 +09:00
// edit 액션은 조용히 처리 (모달 열기만 하므로 토스트 불필요)
if (actionConfig.type !== "edit") {
const successMessage =
actionConfig.successMessage ||
(actionConfig.type === "save"
? "저장되었습니다."
: actionConfig.type === "delete"
? "삭제되었습니다."
: actionConfig.type === "submit"
? "제출되었습니다."
: "완료되었습니다.");
2025-09-12 14:24:25 +09:00
2025-09-18 18:49:30 +09:00
console.log("🎉 성공 토스트 표시:", successMessage);
toast.success(successMessage);
} else {
console.log("🔕 edit 액션은 조용히 처리 (토스트 없음)");
}
2025-09-12 14:24:25 +09:00
console.log("✅ 버튼 액션 실행 성공:", actionConfig.type);
// 저장/수정 성공 시 자동 처리
if (actionConfig.type === "save" || actionConfig.type === "edit") {
if (typeof window !== "undefined") {
// 1. 테이블 새로고침 이벤트 먼저 발송 (모달이 닫히기 전에)
console.log("🔄 저장/수정 후 테이블 새로고침 이벤트 발송");
window.dispatchEvent(new CustomEvent("refreshTable"));
// 2. 모달 닫기 (약간의 딜레이)
setTimeout(() => {
// EditModal 내부인지 확인 (isInModal prop 사용)
const isInEditModal = (props as any).isInModal;
if (isInEditModal) {
console.log("🚪 EditModal 닫기 이벤트 발송");
window.dispatchEvent(new CustomEvent("closeEditModal"));
}
// ScreenModal은 항상 닫기
console.log("🚪 ScreenModal 닫기 이벤트 발송");
window.dispatchEvent(new CustomEvent("closeSaveModal"));
}, 100);
}
}
2025-09-12 14:24:25 +09:00
} catch (error) {
console.log("❌ executeAction catch 블록 진입:", error);
// 로딩 토스트 제거
if (currentLoadingToastRef.current !== undefined) {
console.log("📱 오류 시 로딩 토스트 제거, ID:", currentLoadingToastRef.current);
toast.dismiss(currentLoadingToastRef.current);
currentLoadingToastRef.current = undefined;
2025-09-12 14:24:25 +09:00
}
console.error("❌ 버튼 액션 실행 오류:", error);
// 오류 토스트는 buttonActions.ts에서 이미 표시되므로 여기서는 제거
// (중복 토스트 방지)
2025-09-12 14:24:25 +09:00
}
};
2025-09-11 18:38:28 +09:00
// 이벤트 핸들러
2025-09-12 14:24:25 +09:00
const handleClick = async (e: React.MouseEvent) => {
2025-09-11 18:38:28 +09:00
e.stopPropagation();
console.log("🖱️ 버튼 클릭 이벤트 발생", {
isDesignMode,
isInteractive,
hasAction: !!processedConfig.action,
processedConfig,
});
2025-09-12 14:24:25 +09:00
// 디자인 모드에서는 기본 onClick만 실행
if (isDesignMode) {
onClick?.();
return;
}
console.log("🔍 조건 체크:", {
isInteractive,
hasProcessedConfig: !!processedConfig,
hasAction: !!processedConfig.action,
actionType: processedConfig.action?.type,
});
2025-09-12 14:24:25 +09:00
// 인터랙티브 모드에서 액션 실행
if (isInteractive && processedConfig.action) {
console.log("✅ 액션 실행 조건 통과", {
actionType: processedConfig.action.type,
requiresConfirmation: confirmationRequiredActions.includes(processedConfig.action.type),
});
2025-09-12 14:24:25 +09:00
const context: ButtonActionContext = {
formData: formData || {},
2025-09-18 18:49:30 +09:00
originalData: originalData || {}, // 부분 업데이트용 원본 데이터 추가
2025-09-12 14:24:25 +09:00
screenId,
tableName,
onFormDataChange,
onRefresh,
onClose,
2025-09-18 18:49:30 +09:00
// 테이블 선택된 행 정보 추가
selectedRows,
selectedRowsData,
2025-09-12 14:24:25 +09:00
};
// 확인이 필요한 액션인지 확인
if (confirmationRequiredActions.includes(processedConfig.action.type)) {
console.log("📋 확인 다이얼로그 표시 중...");
2025-09-12 14:24:25 +09:00
// 확인 다이얼로그 표시
setPendingAction({
type: processedConfig.action.type,
config: processedConfig.action,
context,
});
setShowConfirmDialog(true);
} else {
console.log("🚀 액션 바로 실행 중...");
2025-09-12 14:24:25 +09:00
// 확인이 필요하지 않은 액션은 바로 실행
await executeAction(processedConfig.action, context);
}
} else {
console.log("⚠️ 액션 실행 조건 불만족:", {
isInteractive,
hasAction: !!processedConfig.action,
: !isInteractive ? "인터랙티브 모드 아님" : "액션 없음",
});
2025-09-12 14:24:25 +09:00
// 액션이 설정되지 않은 경우 기본 onClick 실행
onClick?.();
}
};
// 확인 다이얼로그에서 확인 버튼 클릭 시
const handleConfirmAction = async () => {
if (pendingAction) {
await executeAction(pendingAction.config, pendingAction.context);
}
setShowConfirmDialog(false);
setPendingAction(null);
};
// 확인 다이얼로그에서 취소 버튼 클릭 시
const handleCancelAction = () => {
setShowConfirmDialog(false);
setPendingAction(null);
2025-09-11 18:38:28 +09:00
};
// 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,
2025-09-12 14:24:25 +09:00
screenId: _screenId,
tableName: _tableName,
onRefresh: _onRefresh,
onClose: _onClose,
2025-09-18 18:49:30 +09:00
selectedRows: _selectedRows,
selectedRowsData: _selectedRowsData,
onSelectedRowsChange: _onSelectedRowsChange,
originalData: _originalData, // 부분 업데이트용 원본 데이터 필터링
refreshKey: _refreshKey, // 필터링 추가
isInModal: _isInModal, // 필터링 추가
mode: _mode, // 필터링 추가
2025-09-11 18:38:28 +09:00
...domProps
} = props;
2025-09-12 14:24:25 +09:00
// 다이얼로그 메시지 생성
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);
2025-09-11 18:38:28 +09:00
return (
2025-09-12 14:24:25 +09:00
<>
<div style={componentStyle} className={className} {...safeDomProps}>
2025-09-12 14:24:25 +09:00
<button
type={componentConfig.actionType || "button"}
disabled={componentConfig.disabled || false}
2025-10-17 16:21:08 +09:00
className="transition-all duration-200"
2025-09-12 14:24:25 +09:00
style={{
width: "100%",
height: "100%",
minHeight: "100%",
maxHeight: "100%",
border: "none",
2025-10-17 16:21:08 +09:00
borderRadius: "0.5rem",
background: componentConfig.disabled
? "linear-gradient(135deg, #e5e7eb 0%, #d1d5db 100%)"
: `linear-gradient(135deg, ${buttonColor} 0%, ${buttonDarkColor} 100%)`,
color: componentConfig.disabled ? "#9ca3af" : "white",
2025-10-17 16:21:08 +09:00
fontSize: "0.875rem",
fontWeight: "600",
2025-09-12 14:24:25 +09:00
cursor: componentConfig.disabled ? "not-allowed" : "pointer",
outline: "none",
boxSizing: "border-box",
display: "flex",
alignItems: "center",
justifyContent: "center",
2025-10-17 16:21:08 +09:00
padding: "0 1rem",
margin: "0",
2025-10-17 16:21:08 +09:00
lineHeight: "1.25",
minHeight: "2.25rem",
boxShadow: componentConfig.disabled ? "0 1px 2px 0 rgba(0, 0, 0, 0.05)" : `0 1px 3px 0 ${buttonColor}40`,
2025-09-12 14:24:25 +09:00
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
onClick={handleClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
{processedConfig.text || component.label || "버튼"}
</button>
</div>
{/* 확인 다이얼로그 - EditModal보다 위에 표시하도록 z-index 최상위로 설정 */}
2025-09-12 14:24:25 +09:00
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<AlertDialogContent className="z-[99999]">
2025-09-12 14:24:25 +09:00
<AlertDialogHeader>
<AlertDialogTitle>{getConfirmTitle()}</AlertDialogTitle>
<AlertDialogDescription>{getConfirmMessage()}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancelAction}></AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmAction}>
{pendingAction?.type === "save"
? "저장"
: pendingAction?.type === "delete"
? "삭제"
: pendingAction?.type === "submit"
? "제출"
: "확인"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
2025-09-11 18:38:28 +09:00
);
};
/**
* ButtonPrimary
*
*/
export const ButtonPrimaryWrapper: React.FC<ButtonPrimaryComponentProps> = (props) => {
return <ButtonPrimaryComponent {...props} />;
};