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

931 lines
35 KiB
TypeScript
Raw Normal View History

2025-09-11 18:38:28 +09:00
"use client";
import React, { useState, useRef, useEffect, useMemo } 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";
import { useCurrentFlowStep } from "@/stores/flowStepStore";
2025-10-28 15:39:22 +09:00
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
2025-11-27 12:54:57 +09:00
import { useScreenContextOptional } from "@/contexts/ScreenContext";
2025-11-28 14:56:11 +09:00
import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext";
2025-11-27 12:54:57 +09:00
import { applyMappingRules } from "@/lib/utils/dataMapping";
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;
2025-10-29 11:26:00 +09:00
userId?: string; // 🆕 현재 사용자 ID
userName?: string; // 🆕 현재 사용자 이름
companyCode?: string; // 🆕 현재 사용자의 회사 코드
2025-09-12 14:24:25 +09:00
onRefresh?: () => void;
onClose?: () => void;
2025-10-23 17:55:04 +09:00
onFlowRefresh?: () => void;
onSave?: () => Promise<void>; // 🆕 EditModal의 handleSave 콜백
2025-09-18 18:49:30 +09:00
// 폼 데이터 관련
originalData?: Record<string, any>; // 부분 업데이트용 원본 데이터
// 테이블 선택된 행 정보 (다중 선택 액션용)
selectedRows?: any[];
selectedRowsData?: any[];
// 테이블 정렬 정보 (엑셀 다운로드용)
sortBy?: string;
sortOrder?: "asc" | "desc";
columnOrder?: string[];
2025-11-05 10:23:00 +09:00
tableDisplayData?: any[]; // 화면에 표시된 데이터 (컬럼 순서 포함)
2025-10-27 11:11:08 +09:00
// 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용)
flowSelectedData?: any[];
flowSelectedStepId?: number | null;
// 🆕 같은 화면의 모든 컴포넌트 (TableList 자동 감지용)
allComponents?: 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,
2025-10-29 11:26:00 +09:00
userId, // 🆕 사용자 ID
userName, // 🆕 사용자 이름
companyCode, // 🆕 회사 코드
2025-09-12 14:24:25 +09:00
onRefresh,
onClose,
2025-10-23 17:55:04 +09:00
onFlowRefresh,
onSave, // 🆕 EditModal의 handleSave 콜백
sortBy, // 🆕 정렬 컬럼
sortOrder, // 🆕 정렬 방향
columnOrder, // 🆕 컬럼 순서
2025-11-05 10:23:00 +09:00
tableDisplayData, // 🆕 화면에 표시된 데이터
2025-09-18 18:49:30 +09:00
selectedRows,
selectedRowsData,
flowSelectedData,
flowSelectedStepId,
allComponents, // 🆕 같은 화면의 모든 컴포넌트
2025-09-11 18:38:28 +09:00
...props
}) => {
2025-10-28 15:39:22 +09:00
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
2025-11-27 12:54:57 +09:00
const screenContext = useScreenContextOptional(); // 화면 컨텍스트
2025-11-28 14:56:11 +09:00
const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트
// 🆕 ScreenContext에서 splitPanelPosition 가져오기 (중첩 화면에서도 작동)
const splitPanelPosition = screenContext?.splitPanelPosition;
// 🆕 tableName이 props로 전달되지 않으면 ScreenContext에서 가져오기
const effectiveTableName = tableName || screenContext?.tableName;
const effectiveScreenId = screenId || screenContext?.screenId;
// 🆕 props에서 onSave 추출 (명시적으로 선언되지 않은 경우 ...props에서 추출)
const propsOnSave = (props as any).onSave as (() => Promise<void>) | undefined;
const finalOnSave = onSave || propsOnSave;
2025-10-28 15:39:22 +09:00
// 🆕 플로우 단계별 표시 제어
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]);
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>(undefined);
// 컴포넌트 언마운트 시 토스트 정리
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]);
2025-09-11 18:38:28 +09:00
// 컴포넌트 설정
// 🔥 component.componentConfig도 병합해야 함 (화면 디자이너에서 저장된 설정)
2025-09-11 18:38:28 +09:00
const componentConfig = {
...config,
...component.config,
...component.componentConfig, // 🔥 화면 디자이너에서 저장된 action 등 포함
2025-09-11 18:38:28 +09:00
} 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();
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
};
}
2025-10-22 17:19:47 +09:00
// 스타일 계산
// height: 100%로 부모(RealtimePreviewDynamic의 내부 div)의 높이를 따라감
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
2025-09-11 18:38:28 +09:00
const componentStyle: React.CSSProperties = {
...component.style,
...style,
width: "100%",
height: "100%",
2025-09-11 18:38:28 +09:00
};
2025-10-22 17:19:47 +09:00
// 디자인 모드 스타일 (border 속성 분리하여 충돌 방지)
2025-09-11 18:38:28 +09:00
if (isDesignMode) {
2025-10-22 17:19:47 +09:00
componentStyle.borderWidth = "1px";
componentStyle.borderStyle = "dashed";
2025-09-11 18:38:28 +09:00
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
}
2025-09-12 14:24:25 +09:00
// 확인 다이얼로그가 필요한 액션 타입들
2025-10-23 13:15:52 +09:00
const confirmationRequiredActions: ButtonActionType[] = ["save", "delete"];
2025-09-12 14:24:25 +09:00
// 실제 액션 실행 함수
const executeAction = async (actionConfig: any, context: ButtonActionContext) => {
try {
// 기존 토스트가 있다면 먼저 제거
if (currentLoadingToastRef.current !== undefined) {
toast.dismiss(currentLoadingToastRef.current);
currentLoadingToastRef.current = undefined;
}
// 추가 안전장치: 모든 로딩 토스트 제거
toast.dismiss();
// UI 전환 액션 및 모달 액션은 로딩 토스트 표시하지 않음
const silentActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan"];
2025-10-23 13:15:52 +09:00
if (!silentActions.includes(actionConfig.type)) {
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
);
}
2025-09-12 14:24:25 +09:00
const success = await ButtonActionExecutor.executeAction(actionConfig, context);
2025-09-18 18:49:30 +09:00
// 로딩 토스트 제거 (있는 경우에만)
if (currentLoadingToastRef.current !== undefined) {
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) {
// UI 전환 액션 및 모달 액션은 에러도 조용히 처리 (모달 내부에서 자체 에러 표시)
const silentErrorActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan"];
if (silentErrorActions.includes(actionConfig.type)) {
2025-10-23 13:15:52 +09:00
return;
}
// 기본 에러 메시지 결정
2025-10-27 11:11:08 +09:00
const defaultErrorMessage =
2025-10-23 13:15:52 +09:00
actionConfig.type === "save"
? "저장 중 오류가 발생했습니다."
: actionConfig.type === "delete"
? "삭제 중 오류가 발생했습니다."
: actionConfig.type === "submit"
? "제출 중 오류가 발생했습니다."
2025-10-23 13:15:52 +09:00
: "처리 중 오류가 발생했습니다.";
2025-10-27 11:11:08 +09:00
2025-10-23 13:15:52 +09:00
// 커스텀 메시지 사용 조건:
// 1. 커스텀 메시지가 있고
// 2. (액션 타입이 save이거나 OR 메시지에 "저장"이 포함되지 않은 경우)
2025-10-27 11:11:08 +09:00
const useCustomMessage =
actionConfig.errorMessage && (actionConfig.type === "save" || !actionConfig.errorMessage.includes("저장"));
2025-10-23 13:15:52 +09:00
const errorMessage = useCustomMessage ? actionConfig.errorMessage : defaultErrorMessage;
2025-10-27 11:11:08 +09:00
toast.error(errorMessage);
return;
}
// 성공한 경우에만 성공 토스트 표시
// edit, modal, navigate, excel_upload, barcode_scan 액션은 조용히 처리
// (UI 전환만 하거나 모달 내부에서 자체적으로 메시지 표시)
const silentSuccessActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan"];
if (!silentSuccessActions.includes(actionConfig.type)) {
2025-10-23 13:15:52 +09:00
// 기본 성공 메시지 결정
2025-10-27 11:11:08 +09:00
const defaultSuccessMessage =
2025-10-23 13:15:52 +09:00
actionConfig.type === "save"
2025-09-18 18:49:30 +09:00
? "저장되었습니다."
: actionConfig.type === "delete"
? "삭제되었습니다."
: actionConfig.type === "submit"
? "제출되었습니다."
2025-10-23 13:15:52 +09:00
: "완료되었습니다.";
2025-10-27 11:11:08 +09:00
2025-10-23 13:15:52 +09:00
// 커스텀 메시지 사용 조건:
// 1. 커스텀 메시지가 있고
// 2. (액션 타입이 save이거나 OR 메시지에 "저장"이 포함되지 않은 경우)
2025-10-27 11:11:08 +09:00
const useCustomMessage =
2025-10-23 13:15:52 +09:00
actionConfig.successMessage &&
(actionConfig.type === "save" || !actionConfig.successMessage.includes("저장"));
2025-10-27 11:11:08 +09:00
2025-10-23 13:15:52 +09:00
const successMessage = useCustomMessage ? actionConfig.successMessage : defaultSuccessMessage;
2025-09-12 14:24:25 +09:00
2025-09-18 18:49:30 +09:00
toast.success(successMessage);
}
2025-09-12 14:24:25 +09:00
// 저장/수정 성공 시 자동 처리
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"));
}
2025-11-06 17:32:29 +09:00
// ScreenModal은 연속 등록 모드를 지원하므로 saveSuccessInModal 이벤트 발생
window.dispatchEvent(new CustomEvent("saveSuccessInModal"));
}, 100);
}
}
2025-09-12 14:24:25 +09:00
} catch (error) {
// 로딩 토스트 제거
if (currentLoadingToastRef.current !== undefined) {
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-11-27 12:54:57 +09:00
/**
* transferData
*/
const handleTransferDataAction = async (actionConfig: any) => {
const dataTransferConfig = actionConfig.dataTransfer;
if (!dataTransferConfig) {
toast.error("데이터 전달 설정이 없습니다.");
return;
}
if (!screenContext) {
toast.error("화면 컨텍스트를 찾을 수 없습니다.");
return;
}
try {
// 1. 소스 컴포넌트에서 데이터 가져오기
2025-11-28 14:56:11 +09:00
let sourceProvider = screenContext.getDataProvider(dataTransferConfig.sourceComponentId);
2025-11-27 12:54:57 +09:00
2025-11-28 14:56:11 +09:00
// 🆕 소스 컴포넌트를 찾을 수 없으면, 현재 화면에서 테이블 리스트 자동 탐색
// (조건부 컨테이너의 다른 섹션으로 전환했을 때 이전 컴포넌트 ID가 남아있는 경우 대응)
2025-11-27 12:54:57 +09:00
if (!sourceProvider) {
2025-11-28 14:56:11 +09:00
console.log(`⚠️ [ButtonPrimary] 지정된 소스 컴포넌트를 찾을 수 없음: ${dataTransferConfig.sourceComponentId}`);
console.log(`🔍 [ButtonPrimary] 현재 화면에서 DataProvider 자동 탐색...`);
const allProviders = screenContext.getAllDataProviders();
// 테이블 리스트 우선 탐색
for (const [id, provider] of allProviders) {
if (provider.componentType === "table-list") {
sourceProvider = provider;
console.log(`✅ [ButtonPrimary] 테이블 리스트 자동 발견: ${id}`);
break;
}
}
// 테이블 리스트가 없으면 첫 번째 DataProvider 사용
if (!sourceProvider && allProviders.size > 0) {
const firstEntry = allProviders.entries().next().value;
if (firstEntry) {
sourceProvider = firstEntry[1];
console.log(`✅ [ButtonPrimary] 첫 번째 DataProvider 사용: ${firstEntry[0]} (${sourceProvider.componentType})`);
}
}
if (!sourceProvider) {
toast.error("데이터를 제공할 수 있는 컴포넌트를 찾을 수 없습니다.");
return;
}
2025-11-27 12:54:57 +09:00
}
2025-11-28 14:56:11 +09:00
const rawSourceData = sourceProvider.getSelectedData();
// 🆕 배열이 아닌 경우 배열로 변환
const sourceData = Array.isArray(rawSourceData) ? rawSourceData : (rawSourceData ? [rawSourceData] : []);
console.log("📦 소스 데이터:", { rawSourceData, sourceData, isArray: Array.isArray(rawSourceData) });
2025-11-27 12:54:57 +09:00
if (!sourceData || sourceData.length === 0) {
toast.warning("선택된 데이터가 없습니다.");
return;
}
2025-11-28 14:56:11 +09:00
// 1.5. 추가 데이터 소스 처리 (예: 조건부 컨테이너의 카테고리 값)
let additionalData: Record<string, any> = {};
// 방법 1: additionalSources 설정에서 가져오기
if (dataTransferConfig.additionalSources && Array.isArray(dataTransferConfig.additionalSources)) {
for (const additionalSource of dataTransferConfig.additionalSources) {
const additionalProvider = screenContext.getDataProvider(additionalSource.componentId);
if (additionalProvider) {
const additionalValues = additionalProvider.getSelectedData();
if (additionalValues && additionalValues.length > 0) {
// 첫 번째 값 사용 (조건부 컨테이너는 항상 1개)
const firstValue = additionalValues[0];
// fieldName이 지정되어 있으면 그 필드만 추출
if (additionalSource.fieldName) {
additionalData[additionalSource.fieldName] = firstValue[additionalSource.fieldName] || firstValue.condition || firstValue;
} else {
// fieldName이 없으면 전체 객체 병합
additionalData = { ...additionalData, ...firstValue };
}
console.log("📦 추가 데이터 수집 (additionalSources):", {
sourceId: additionalSource.componentId,
fieldName: additionalSource.fieldName,
value: additionalData[additionalSource.fieldName || 'all'],
});
}
}
}
}
// 방법 2: formData에서 조건부 컨테이너 값 가져오기 (자동)
// ConditionalSectionViewer가 __conditionalContainerValue, __conditionalContainerControlField를 formData에 포함시킴
if (formData && formData.__conditionalContainerValue) {
// includeConditionalValue 설정이 true이거나 설정이 없으면 자동 포함
if (dataTransferConfig.includeConditionalValue !== false) {
const conditionalValue = formData.__conditionalContainerValue;
const conditionalLabel = formData.__conditionalContainerLabel;
const controlField = formData.__conditionalContainerControlField; // 🆕 제어 필드명 직접 사용
// 🆕 controlField가 있으면 그것을 필드명으로 사용 (자동 매핑!)
if (controlField) {
additionalData[controlField] = conditionalValue;
console.log("📦 조건부 컨테이너 값 자동 매핑:", {
controlField,
value: conditionalValue,
label: conditionalLabel,
});
} else {
// controlField가 없으면 기존 방식: formData에서 같은 값을 가진 키 찾기
for (const [key, value] of Object.entries(formData)) {
if (value === conditionalValue && !key.startsWith('__')) {
additionalData[key] = conditionalValue;
console.log("📦 조건부 컨테이너 값 자동 포함:", {
fieldName: key,
value: conditionalValue,
label: conditionalLabel,
});
break;
}
}
// 못 찾았으면 기본 필드명 사용
if (!Object.keys(additionalData).some(k => !k.startsWith('__'))) {
additionalData['condition_type'] = conditionalValue;
console.log("📦 조건부 컨테이너 값 (기본 필드명):", {
fieldName: 'condition_type',
value: conditionalValue,
});
}
}
}
}
2025-11-27 12:54:57 +09:00
// 2. 검증
const validation = dataTransferConfig.validation;
if (validation) {
if (validation.minSelection && sourceData.length < validation.minSelection) {
toast.error(`최소 ${validation.minSelection}개 이상 선택해야 합니다.`);
return;
}
if (validation.maxSelection && sourceData.length > validation.maxSelection) {
toast.error(`최대 ${validation.maxSelection}개까지 선택할 수 있습니다.`);
return;
}
}
// 3. 확인 메시지
if (dataTransferConfig.confirmBeforeTransfer) {
const confirmMessage = dataTransferConfig.confirmMessage || `${sourceData.length}개 항목을 전달하시겠습니까?`;
if (!window.confirm(confirmMessage)) {
return;
}
}
2025-11-28 14:56:11 +09:00
// 4. 매핑 규칙 적용 + 추가 데이터 병합
2025-11-27 12:54:57 +09:00
const mappedData = sourceData.map((row) => {
2025-11-28 14:56:11 +09:00
const mappedRow = applyMappingRules(row, dataTransferConfig.mappingRules || []);
// 추가 데이터를 모든 행에 포함
return {
...mappedRow,
...additionalData,
};
2025-11-27 12:54:57 +09:00
});
console.log("📦 데이터 전달:", {
sourceData,
mappedData,
targetType: dataTransferConfig.targetType,
targetComponentId: dataTransferConfig.targetComponentId,
targetScreenId: dataTransferConfig.targetScreenId,
});
// 5. 타겟으로 데이터 전달
if (dataTransferConfig.targetType === "component") {
// 같은 화면의 컴포넌트로 전달
const targetReceiver = screenContext.getDataReceiver(dataTransferConfig.targetComponentId);
if (!targetReceiver) {
toast.error(`타겟 컴포넌트를 찾을 수 없습니다: ${dataTransferConfig.targetComponentId}`);
return;
}
await targetReceiver.receiveData(mappedData, {
targetComponentId: dataTransferConfig.targetComponentId,
targetComponentType: targetReceiver.componentType,
mode: dataTransferConfig.mode || "append",
mappingRules: dataTransferConfig.mappingRules || [],
});
2025-11-28 14:56:11 +09:00
toast.success(`${sourceData.length}개 항목이 전달되었습니다.`);
} else if (dataTransferConfig.targetType === "splitPanel") {
// 🆕 분할 패널의 반대편 화면으로 전달
if (!splitPanelContext) {
toast.error("분할 패널 컨텍스트를 찾을 수 없습니다. 이 버튼이 분할 패널 내부에 있는지 확인하세요.");
return;
}
// 🆕 useSplitPanelPosition 훅으로 위치 가져오기 (중첩된 화면에서도 작동)
// screenId로 찾는 것은 직접 임베드된 화면에서만 작동하므로,
// SplitPanelPositionProvider로 전달된 위치를 우선 사용
const currentPosition = splitPanelPosition || (screenId ? splitPanelContext.getPositionByScreenId(screenId) : null);
if (!currentPosition) {
toast.error("분할 패널 내 위치를 확인할 수 없습니다. screenId: " + screenId);
return;
}
console.log("📦 분할 패널 데이터 전달:", {
currentPosition,
splitPanelPositionFromHook: splitPanelPosition,
screenId,
leftScreenId: splitPanelContext.leftScreenId,
rightScreenId: splitPanelContext.rightScreenId,
});
const result = await splitPanelContext.transferToOtherSide(
currentPosition,
mappedData,
dataTransferConfig.targetComponentId, // 특정 컴포넌트 지정 (선택사항)
dataTransferConfig.mode || "append"
);
if (result.success) {
toast.success(result.message);
} else {
toast.error(result.message);
return;
}
2025-11-27 12:54:57 +09:00
} else if (dataTransferConfig.targetType === "screen") {
// 다른 화면으로 전달 (구현 예정)
toast.info("다른 화면으로의 데이터 전달은 추후 구현 예정입니다.");
2025-11-28 14:56:11 +09:00
return;
} else {
toast.success(`${sourceData.length}개 항목이 전달되었습니다.`);
2025-11-27 12:54:57 +09:00
}
// 6. 전달 후 정리
if (dataTransferConfig.clearAfterTransfer) {
sourceProvider.clearSelection();
}
} catch (error: any) {
console.error("❌ 데이터 전달 실패:", error);
toast.error(error.message || "데이터 전달 중 오류가 발생했습니다.");
}
};
2025-09-12 14:24:25 +09:00
const handleClick = async (e: React.MouseEvent) => {
2025-09-11 18:38:28 +09:00
e.stopPropagation();
2025-09-12 14:24:25 +09:00
2025-10-28 15:39:22 +09:00
// 프리뷰 모드에서는 버튼 동작 차단
if (isPreviewMode) {
return;
}
2025-09-12 14:24:25 +09:00
// 디자인 모드에서는 기본 onClick만 실행
if (isDesignMode) {
onClick?.();
return;
}
// 인터랙티브 모드에서 액션 실행
if (isInteractive && processedConfig.action) {
2025-11-27 12:54:57 +09:00
// transferData 액션 처리 (화면 컨텍스트 필요)
if (processedConfig.action.type === "transferData") {
await handleTransferDataAction(processedConfig.action);
return;
}
2025-10-23 13:15:52 +09:00
// 삭제 액션인데 선택된 데이터가 없으면 경고 메시지 표시하고 중단
2025-10-23 17:55:04 +09:00
const hasDataToDelete =
(selectedRowsData && selectedRowsData.length > 0) || (flowSelectedData && flowSelectedData.length > 0);
if (processedConfig.action.type === "delete" && !hasDataToDelete) {
2025-10-23 13:15:52 +09:00
toast.warning("삭제할 항목을 먼저 선택해주세요.");
return;
}
// 🆕 모든 컴포넌트의 설정 수집 (parentDataMapping 등)
const componentConfigs: Record<string, any> = {};
if (allComponents && Array.isArray(allComponents)) {
for (const comp of allComponents) {
if (comp.id && comp.componentConfig) {
componentConfigs[comp.id] = comp.componentConfig;
}
}
}
// 🆕 디버깅: tableName 확인
console.log("🔍 [ButtonPrimaryComponent] context 생성:", {
propsTableName: tableName,
contextTableName: screenContext?.tableName,
effectiveTableName,
propsScreenId: screenId,
contextScreenId: screenContext?.screenId,
effectiveScreenId,
});
2025-09-12 14:24:25 +09:00
const context: ButtonActionContext = {
formData: formData || {},
originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용)
screenId: effectiveScreenId, // 🆕 ScreenContext에서 가져온 값 사용
tableName: effectiveTableName, // 🆕 ScreenContext에서 가져온 값 사용
2025-10-29 11:26:00 +09:00
userId, // 🆕 사용자 ID
userName, // 🆕 사용자 이름
companyCode, // 🆕 회사 코드
2025-09-12 14:24:25 +09:00
onFormDataChange,
onRefresh,
onClose,
2025-10-23 17:55:04 +09:00
onFlowRefresh, // 플로우 새로고침 콜백 추가
onSave: finalOnSave, // 🆕 EditModal의 handleSave 콜백 (props에서도 추출)
2025-09-18 18:49:30 +09:00
// 테이블 선택된 행 정보 추가
selectedRows,
selectedRowsData,
// 테이블 정렬 정보 추가
sortBy, // 🆕 정렬 컬럼
sortOrder, // 🆕 정렬 방향
columnOrder, // 🆕 컬럼 순서
2025-11-05 10:23:00 +09:00
tableDisplayData, // 🆕 화면에 표시된 데이터
// 🆕 같은 화면의 모든 컴포넌트 (TableList 자동 감지용)
allComponents,
// 플로우 선택된 데이터 정보 추가
flowSelectedData,
flowSelectedStepId,
// 🆕 컴포넌트별 설정 (parentDataMapping 등)
componentConfigs,
} as ButtonActionContext;
2025-09-12 14:24:25 +09:00
// 확인이 필요한 액션인지 확인
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);
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,
flowSelectedData: _flowSelectedData, // 플로우 선택 데이터 필터링
flowSelectedStepId: _flowSelectedStepId, // 플로우 선택 스텝 ID 필터링
2025-10-23 17:55:04 +09:00
onFlowRefresh: _onFlowRefresh, // 플로우 새로고침 콜백 필터링
2025-09-18 18:49:30 +09:00
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);
// 🆕 플로우 단계별 표시 제어
if (!shouldShowButton) {
// 레이아웃 동작에 따라 다르게 처리
if (flowConfig?.layoutBehavior === "preserve-position") {
// 위치 유지 (빈 공간, display: none)
return <div style={{ display: "none" }} />;
} else {
// 완전히 렌더링하지 않음 (auto-compact, 빈 공간 제거)
return null;
}
}
// 공통 버튼 스타일
const buttonElementStyle: React.CSSProperties = {
width: "100%",
height: "100%",
minHeight: "40px",
border: "none",
borderRadius: "0.5rem",
background: componentConfig.disabled ? "#e5e7eb" : buttonColor,
color: componentConfig.disabled ? "#9ca3af" : "white",
// 🔧 크기 설정 적용 (sm/md/lg)
fontSize: componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem",
fontWeight: "600",
cursor: componentConfig.disabled ? "not-allowed" : "pointer",
outline: "none",
boxSizing: "border-box",
display: "flex",
alignItems: "center",
justifyContent: "center",
// 🔧 크기에 따른 패딩 조정
padding:
componentConfig.size === "sm" ? "0 0.75rem" : componentConfig.size === "lg" ? "0 1.25rem" : "0 1rem",
margin: "0",
lineHeight: "1.25",
boxShadow: componentConfig.disabled ? "none" : "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
// 디자인 모드와 인터랙티브 모드 모두에서 사용자 스타일 적용 (width/height 제외)
...(component.style ? Object.fromEntries(
Object.entries(component.style).filter(([key]) => key !== 'width' && key !== 'height')
) : {}),
};
const buttonContent = processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼";
2025-09-11 18:38:28 +09:00
return (
2025-09-12 14:24:25 +09:00
<>
<div style={componentStyle} className={className} {...safeDomProps}>
{isDesignMode ? (
// 디자인 모드: div로 렌더링하여 선택 가능하게 함
<div
className="transition-colors duration-150 hover:opacity-90"
style={buttonElementStyle}
onClick={handleClick}
>
{buttonContent}
</div>
) : (
// 일반 모드: button으로 렌더링
<button
type={componentConfig.actionType || "button"}
disabled={componentConfig.disabled || false}
className="transition-colors duration-150 hover:opacity-90 active:scale-95 transition-transform"
style={buttonElementStyle}
onClick={handleClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
{buttonContent}
</button>
)}
2025-09-12 14:24:25 +09:00
</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} />;
};