Repeater 컴포넌트에 하위 데이터 조회 기능 추가 (재고/단가 조회) 조건부 입력 활성화 및 최대값 제한 기능 구현 필드 정의 순서 변경 기능 추가 (드래그앤드롭, 화살표 버튼) TableListComponent의 DataProvider 클로저 문제 해결 ButtonPrimaryComponent에 modalDataStore fallback 로직 추가
1388 lines
55 KiB
TypeScript
1388 lines
55 KiB
TypeScript
"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";
|
|
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
|
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
|
import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
|
import { applyMappingRules } from "@/lib/utils/dataMapping";
|
|
import { apiClient } from "@/lib/api/client";
|
|
|
|
export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
|
|
config?: ButtonPrimaryConfig;
|
|
// 추가 props
|
|
screenId?: number;
|
|
tableName?: string;
|
|
userId?: string; // 🆕 현재 사용자 ID
|
|
userName?: string; // 🆕 현재 사용자 이름
|
|
companyCode?: string; // 🆕 현재 사용자의 회사 코드
|
|
onRefresh?: () => void;
|
|
onClose?: () => void;
|
|
onFlowRefresh?: () => void;
|
|
onSave?: () => Promise<void>; // 🆕 EditModal의 handleSave 콜백
|
|
|
|
// 폼 데이터 관련
|
|
originalData?: Record<string, any>; // 부분 업데이트용 원본 데이터
|
|
|
|
// 테이블 선택된 행 정보 (다중 선택 액션용)
|
|
selectedRows?: any[];
|
|
selectedRowsData?: any[];
|
|
|
|
// 테이블 정렬 정보 (엑셀 다운로드용)
|
|
sortBy?: string;
|
|
sortOrder?: "asc" | "desc";
|
|
columnOrder?: string[];
|
|
tableDisplayData?: any[]; // 화면에 표시된 데이터 (컬럼 순서 포함)
|
|
|
|
// 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용)
|
|
flowSelectedData?: any[];
|
|
flowSelectedStepId?: number | null;
|
|
|
|
// 🆕 같은 화면의 모든 컴포넌트 (TableList 자동 감지용)
|
|
allComponents?: any[];
|
|
|
|
// 🆕 부모창에서 전달된 그룹 데이터 (모달에서 부모 데이터 접근용)
|
|
groupedData?: Record<string, any>[];
|
|
}
|
|
|
|
/**
|
|
* ButtonPrimary 컴포넌트
|
|
* button-primary 컴포넌트입니다
|
|
*/
|
|
export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|
component,
|
|
isDesignMode = false,
|
|
isSelected = false,
|
|
isInteractive = false,
|
|
onClick,
|
|
onDragStart,
|
|
onDragEnd,
|
|
config,
|
|
className,
|
|
style,
|
|
formData,
|
|
originalData,
|
|
onFormDataChange,
|
|
screenId,
|
|
tableName,
|
|
userId, // 🆕 사용자 ID
|
|
userName, // 🆕 사용자 이름
|
|
companyCode, // 🆕 회사 코드
|
|
onRefresh,
|
|
onClose,
|
|
onFlowRefresh,
|
|
onSave, // 🆕 EditModal의 handleSave 콜백
|
|
sortBy, // 🆕 정렬 컬럼
|
|
sortOrder, // 🆕 정렬 방향
|
|
columnOrder, // 🆕 컬럼 순서
|
|
tableDisplayData, // 🆕 화면에 표시된 데이터
|
|
selectedRows,
|
|
selectedRowsData,
|
|
flowSelectedData,
|
|
flowSelectedStepId,
|
|
allComponents, // 🆕 같은 화면의 모든 컴포넌트
|
|
groupedData, // 🆕 부모창에서 전달된 그룹 데이터
|
|
...props
|
|
}) => {
|
|
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
|
const screenContext = useScreenContextOptional(); // 화면 컨텍스트
|
|
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;
|
|
|
|
// 🆕 플로우 단계별 표시 제어
|
|
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]);
|
|
|
|
// 🆕 운행알림 버튼 조건부 비활성화 (출발지/도착지 필수, 상태 체크)
|
|
// 상태는 API로 조회 (formData에 없는 경우)
|
|
const [vehicleStatus, setVehicleStatus] = useState<string | null>(null);
|
|
const [statusLoading, setStatusLoading] = useState(false);
|
|
|
|
// 상태 조회 (operation_control + enableOnStatusCheck일 때)
|
|
const actionConfig = component.componentConfig?.action;
|
|
const shouldFetchStatus = actionConfig?.type === "operation_control" && actionConfig?.enableOnStatusCheck && userId;
|
|
const statusTableName = actionConfig?.statusCheckTableName || "vehicles";
|
|
const statusKeyField = actionConfig?.statusCheckKeyField || "user_id";
|
|
const statusFieldName = actionConfig?.statusCheckField || "status";
|
|
|
|
useEffect(() => {
|
|
if (!shouldFetchStatus) return;
|
|
|
|
let isMounted = true;
|
|
|
|
const fetchStatus = async () => {
|
|
if (!isMounted) return;
|
|
|
|
try {
|
|
const response = await apiClient.post(`/table-management/tables/${statusTableName}/data`, {
|
|
page: 1,
|
|
size: 1,
|
|
search: { [statusKeyField]: userId },
|
|
autoFilter: true,
|
|
});
|
|
|
|
if (!isMounted) return;
|
|
|
|
const rows = response.data?.data?.data || response.data?.data?.rows || response.data?.rows || [];
|
|
const firstRow = Array.isArray(rows) ? rows[0] : null;
|
|
|
|
if (response.data?.success && firstRow) {
|
|
const newStatus = firstRow[statusFieldName];
|
|
if (newStatus !== vehicleStatus) {
|
|
// console.log("🔄 [ButtonPrimary] 상태 변경 감지:", { 이전: vehicleStatus, 현재: newStatus, buttonLabel: component.label });
|
|
}
|
|
setVehicleStatus(newStatus);
|
|
} else {
|
|
setVehicleStatus(null);
|
|
}
|
|
} catch (error: any) {
|
|
// console.error("❌ [ButtonPrimary] 상태 조회 오류:", error?.message);
|
|
if (isMounted) setVehicleStatus(null);
|
|
} finally {
|
|
if (isMounted) setStatusLoading(false);
|
|
}
|
|
};
|
|
|
|
// 즉시 실행
|
|
setStatusLoading(true);
|
|
fetchStatus();
|
|
|
|
// 2초마다 갱신
|
|
const interval = setInterval(fetchStatus, 2000);
|
|
|
|
return () => {
|
|
isMounted = false;
|
|
clearInterval(interval);
|
|
};
|
|
}, [shouldFetchStatus, statusTableName, statusKeyField, statusFieldName, userId, component.label]);
|
|
|
|
// 버튼 비활성화 조건 계산
|
|
const isOperationButtonDisabled = useMemo(() => {
|
|
const actionConfig = component.componentConfig?.action;
|
|
|
|
if (actionConfig?.type !== "operation_control") return false;
|
|
|
|
// 1. 출발지/도착지 필수 체크
|
|
if (actionConfig?.requireLocationFields) {
|
|
const departureField = actionConfig.trackingDepartureField || "departure";
|
|
const destinationField = actionConfig.trackingArrivalField || "destination";
|
|
|
|
const departure = formData?.[departureField];
|
|
const destination = formData?.[destinationField];
|
|
|
|
// console.log("🔍 [ButtonPrimary] 출발지/도착지 체크:", {
|
|
// departureField, destinationField, departure, destination,
|
|
// buttonLabel: component.label
|
|
// });
|
|
|
|
if (!departure || departure === "" || !destination || destination === "") {
|
|
// console.log("🚫 [ButtonPrimary] 출발지/도착지 미선택 → 비활성화:", component.label);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// 2. 상태 기반 활성화 조건 (API로 조회한 vehicleStatus 우선 사용)
|
|
if (actionConfig?.enableOnStatusCheck) {
|
|
const statusField = actionConfig.statusCheckField || "status";
|
|
// API 조회 결과를 우선 사용 (실시간 DB 상태 반영)
|
|
const currentStatus = vehicleStatus || formData?.[statusField];
|
|
|
|
const conditionType = actionConfig.statusConditionType || "enableOn";
|
|
const conditionValues = (actionConfig.statusConditionValues || "")
|
|
.split(",")
|
|
.map((v: string) => v.trim())
|
|
.filter((v: string) => v);
|
|
|
|
// console.log("🔍 [ButtonPrimary] 상태 조건 체크:", {
|
|
// statusField,
|
|
// formDataStatus: formData?.[statusField],
|
|
// apiStatus: vehicleStatus,
|
|
// currentStatus,
|
|
// conditionType,
|
|
// conditionValues,
|
|
// buttonLabel: component.label,
|
|
// });
|
|
|
|
// 상태 로딩 중이면 비활성화
|
|
if (statusLoading) {
|
|
// console.log("⏳ [ButtonPrimary] 상태 로딩 중 → 비활성화:", component.label);
|
|
return true;
|
|
}
|
|
|
|
// 상태값이 없으면 → 비활성화 (조건 확인 불가)
|
|
if (!currentStatus) {
|
|
// console.log("🚫 [ButtonPrimary] 상태값 없음 → 비활성화:", component.label);
|
|
return true;
|
|
}
|
|
|
|
if (conditionValues.length > 0) {
|
|
if (conditionType === "enableOn") {
|
|
// 이 상태일 때만 활성화
|
|
if (!conditionValues.includes(currentStatus)) {
|
|
// console.log(`🚫 [ButtonPrimary] 상태 ${currentStatus} ∉ [${conditionValues}] → 비활성화:`, component.label);
|
|
return true;
|
|
}
|
|
} else if (conditionType === "disableOn") {
|
|
// 이 상태일 때 비활성화
|
|
if (conditionValues.includes(currentStatus)) {
|
|
// console.log(`🚫 [ButtonPrimary] 상태 ${currentStatus} ∈ [${conditionValues}] → 비활성화:`, component.label);
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// console.log("✅ [ButtonPrimary] 버튼 활성화:", component.label);
|
|
return false;
|
|
}, [component.componentConfig?.action, formData, vehicleStatus, statusLoading, component.label]);
|
|
|
|
// 🆕 modalDataStore에서 선택된 데이터 확인 (분할 패널 등에서 저장됨)
|
|
const [modalStoreData, setModalStoreData] = useState<Record<string, any[]>>({});
|
|
|
|
// 🆕 splitPanelContext?.selectedLeftData를 로컬 상태로 추적 (리렌더링 보장)
|
|
const [trackedSelectedLeftData, setTrackedSelectedLeftData] = useState<Record<string, any> | null>(null);
|
|
|
|
// splitPanelContext?.selectedLeftData 변경 감지 및 로컬 상태 동기화
|
|
useEffect(() => {
|
|
const newData = splitPanelContext?.selectedLeftData ?? null;
|
|
setTrackedSelectedLeftData(newData);
|
|
// console.log("🔄 [ButtonPrimary] selectedLeftData 변경 감지:", {
|
|
// label: component.label,
|
|
// hasData: !!newData,
|
|
// dataKeys: newData ? Object.keys(newData) : [],
|
|
// });
|
|
}, [splitPanelContext?.selectedLeftData, component.label]);
|
|
|
|
// modalDataStore 상태 구독 (실시간 업데이트)
|
|
useEffect(() => {
|
|
const actionConfig = component.componentConfig?.action;
|
|
if (!actionConfig?.requireRowSelection) return;
|
|
|
|
// 동적 import로 modalDataStore 구독
|
|
let unsubscribe: (() => void) | undefined;
|
|
|
|
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
|
// 초기값 설정
|
|
setModalStoreData(useModalDataStore.getState().dataRegistry);
|
|
|
|
// 상태 변경 구독
|
|
unsubscribe = useModalDataStore.subscribe((state) => {
|
|
setModalStoreData(state.dataRegistry);
|
|
});
|
|
});
|
|
|
|
return () => {
|
|
unsubscribe?.();
|
|
};
|
|
}, [component.componentConfig?.action?.requireRowSelection]);
|
|
|
|
// 🆕 행 선택 기반 비활성화 조건 계산
|
|
const isRowSelectionDisabled = useMemo(() => {
|
|
const actionConfig = component.componentConfig?.action;
|
|
|
|
// requireRowSelection이 활성화되어 있지 않으면 비활성화하지 않음
|
|
if (!actionConfig?.requireRowSelection) {
|
|
return false;
|
|
}
|
|
|
|
const rowSelectionSource = actionConfig.rowSelectionSource || "auto";
|
|
const allowMultiRowSelection = actionConfig.allowMultiRowSelection ?? true;
|
|
|
|
// 선택된 데이터 확인
|
|
let hasSelection = false;
|
|
let selectionCount = 0;
|
|
let selectionSource = "";
|
|
|
|
// 1. 자동 감지 모드 또는 테이블 리스트 모드
|
|
if (rowSelectionSource === "auto" || rowSelectionSource === "tableList") {
|
|
// TableList에서 선택된 행 확인 (props로 전달됨)
|
|
if (selectedRowsData && selectedRowsData.length > 0) {
|
|
hasSelection = true;
|
|
selectionCount = selectedRowsData.length;
|
|
selectionSource = "tableList (selectedRowsData)";
|
|
}
|
|
// 또는 selectedRows prop 확인
|
|
else if (selectedRows && selectedRows.length > 0) {
|
|
hasSelection = true;
|
|
selectionCount = selectedRows.length;
|
|
selectionSource = "tableList (selectedRows)";
|
|
}
|
|
}
|
|
|
|
// 2. 분할 패널 좌측 선택 데이터 확인
|
|
if (rowSelectionSource === "auto" || rowSelectionSource === "splitPanelLeft") {
|
|
// SplitPanelContext에서 확인 (trackedSelectedLeftData 사용으로 리렌더링 보장)
|
|
if (trackedSelectedLeftData && Object.keys(trackedSelectedLeftData).length > 0) {
|
|
if (!hasSelection) {
|
|
hasSelection = true;
|
|
selectionCount = 1;
|
|
selectionSource = "splitPanelLeft (context)";
|
|
}
|
|
}
|
|
|
|
// 🆕 modalDataStore에서도 확인 (SplitPanelLayoutComponent에서 저장)
|
|
if (!hasSelection && Object.keys(modalStoreData).length > 0) {
|
|
// modalDataStore에서 데이터가 있는지 확인
|
|
for (const [sourceId, items] of Object.entries(modalStoreData)) {
|
|
if (items && items.length > 0) {
|
|
hasSelection = true;
|
|
selectionCount = items.length;
|
|
selectionSource = `modalDataStore (${sourceId})`;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. 플로우 위젯 선택 데이터 확인
|
|
if (rowSelectionSource === "auto" || rowSelectionSource === "flowWidget") {
|
|
// 플로우 위젯 선택 데이터 확인
|
|
if (!hasSelection && flowSelectedData && flowSelectedData.length > 0) {
|
|
hasSelection = true;
|
|
selectionCount = flowSelectedData.length;
|
|
selectionSource = "flowWidget";
|
|
}
|
|
}
|
|
|
|
// 디버깅 로그
|
|
console.log("🔍 [ButtonPrimary] 행 선택 체크:", component.label, {
|
|
rowSelectionSource,
|
|
hasSelection,
|
|
selectionCount,
|
|
selectionSource,
|
|
hasSplitPanelContext: !!splitPanelContext,
|
|
trackedSelectedLeftData: trackedSelectedLeftData,
|
|
selectedRowsData: selectedRowsData?.length,
|
|
selectedRows: selectedRows?.length,
|
|
flowSelectedData: flowSelectedData?.length,
|
|
modalStoreDataKeys: Object.keys(modalStoreData),
|
|
});
|
|
|
|
// 선택된 데이터가 없으면 비활성화
|
|
if (!hasSelection) {
|
|
console.log("🚫 [ButtonPrimary] 행 선택 필요 → 비활성화:", component.label);
|
|
return true;
|
|
}
|
|
|
|
// 다중 선택 허용하지 않는 경우, 정확히 1개만 선택되어야 함
|
|
if (!allowMultiRowSelection && selectionCount !== 1) {
|
|
console.log("🚫 [ButtonPrimary] 정확히 1개 행 선택 필요 → 비활성화:", component.label, {
|
|
selectionCount,
|
|
allowMultiRowSelection,
|
|
});
|
|
return true;
|
|
}
|
|
|
|
console.log("✅ [ButtonPrimary] 행 선택 조건 충족:", component.label, {
|
|
selectionCount,
|
|
selectionSource,
|
|
});
|
|
return false;
|
|
}, [
|
|
component.componentConfig?.action,
|
|
component.label,
|
|
selectedRows,
|
|
selectedRowsData,
|
|
trackedSelectedLeftData,
|
|
flowSelectedData,
|
|
splitPanelContext,
|
|
modalStoreData,
|
|
]);
|
|
|
|
// 확인 다이얼로그 상태
|
|
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]);
|
|
|
|
// 컴포넌트 설정
|
|
// 🔥 component.componentConfig도 병합해야 함 (화면 디자이너에서 저장된 설정)
|
|
const componentConfig = {
|
|
...config,
|
|
...component.config,
|
|
...component.componentConfig, // 🔥 화면 디자이너에서 저장된 action 등 포함
|
|
} 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();
|
|
|
|
// 액션 설정 처리 - 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,
|
|
dataflowTiming: component.webTypeConfig?.dataflowTiming,
|
|
};
|
|
} else if (componentConfig.action && typeof componentConfig.action === "object") {
|
|
// 🔥 이미 객체인 경우에도 제어관리 설정 추가
|
|
processedConfig.action = {
|
|
...componentConfig.action,
|
|
enableDataflowControl: component.webTypeConfig?.enableDataflowControl,
|
|
dataflowConfig: component.webTypeConfig?.dataflowConfig,
|
|
dataflowTiming: component.webTypeConfig?.dataflowTiming,
|
|
};
|
|
}
|
|
|
|
// 스타일 계산
|
|
// height: 100%로 부모(RealtimePreviewDynamic의 내부 div)의 높이를 따라감
|
|
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
|
const componentStyle: React.CSSProperties = {
|
|
...component.style,
|
|
...style,
|
|
width: "100%",
|
|
height: "100%",
|
|
};
|
|
|
|
// 디자인 모드 스타일 (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 전환 액션 및 모달 액션은 로딩 토스트 표시하지 않음
|
|
const silentActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan"];
|
|
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 전환 액션 및 모달 액션은 에러도 조용히 처리 (모달 내부에서 자체 에러 표시)
|
|
const silentErrorActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan"];
|
|
if (silentErrorActions.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, excel_upload, barcode_scan 액션은 조용히 처리
|
|
// (UI 전환만 하거나 모달 내부에서 자체적으로 메시지 표시)
|
|
const silentSuccessActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan"];
|
|
if (!silentSuccessActions.includes(actionConfig.type)) {
|
|
// 기본 성공 메시지 결정
|
|
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은 연속 등록 모드를 지원하므로 saveSuccessInModal 이벤트 발생
|
|
window.dispatchEvent(new CustomEvent("saveSuccessInModal"));
|
|
}, 100);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// 로딩 토스트 제거
|
|
if (currentLoadingToastRef.current !== undefined) {
|
|
toast.dismiss(currentLoadingToastRef.current);
|
|
currentLoadingToastRef.current = undefined;
|
|
}
|
|
|
|
console.error("❌ 버튼 액션 실행 오류:", error);
|
|
|
|
// 오류 토스트는 buttonActions.ts에서 이미 표시되므로 여기서는 제거
|
|
// (중복 토스트 방지)
|
|
}
|
|
};
|
|
|
|
// 이벤트 핸들러
|
|
/**
|
|
* transferData 액션 처리
|
|
*/
|
|
const handleTransferDataAction = async (actionConfig: any) => {
|
|
const dataTransferConfig = actionConfig.dataTransfer;
|
|
|
|
if (!dataTransferConfig) {
|
|
toast.error("데이터 전달 설정이 없습니다.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
let sourceData: any[] = [];
|
|
|
|
// 1. ScreenContext에서 DataProvider를 통해 데이터 가져오기 시도
|
|
if (screenContext) {
|
|
let sourceProvider = screenContext.getDataProvider(dataTransferConfig.sourceComponentId);
|
|
|
|
// 소스 컴포넌트를 찾을 수 없으면, 현재 화면에서 테이블 리스트 자동 탐색
|
|
if (!sourceProvider) {
|
|
console.log(`⚠️ [ButtonPrimary] 지정된 소스 컴포넌트를 찾을 수 없음: ${dataTransferConfig.sourceComponentId}`);
|
|
console.log(`🔍 [ButtonPrimary] 현재 화면에서 DataProvider 자동 탐색...`);
|
|
|
|
const allProviders = screenContext.getAllDataProviders();
|
|
console.log(`📋 [ButtonPrimary] 등록된 DataProvider 목록:`, Array.from(allProviders.keys()));
|
|
|
|
// 테이블 리스트 우선 탐색
|
|
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) {
|
|
const rawSourceData = sourceProvider.getSelectedData();
|
|
sourceData = Array.isArray(rawSourceData) ? rawSourceData : rawSourceData ? [rawSourceData] : [];
|
|
console.log("📦 [ButtonPrimary] ScreenContext에서 소스 데이터 획득:", {
|
|
rawSourceData,
|
|
sourceData,
|
|
count: sourceData.length
|
|
});
|
|
}
|
|
} else {
|
|
console.log("⚠️ [ButtonPrimary] ScreenContext가 없습니다. modalDataStore에서 데이터를 찾습니다.");
|
|
}
|
|
|
|
// 2. ScreenContext에서 데이터를 찾지 못한 경우, modalDataStore에서 fallback 조회
|
|
if (sourceData.length === 0) {
|
|
console.log("🔍 [ButtonPrimary] modalDataStore에서 데이터 탐색 시도...");
|
|
|
|
try {
|
|
const { useModalDataStore } = await import("@/stores/modalDataStore");
|
|
const dataRegistry = useModalDataStore.getState().dataRegistry;
|
|
|
|
console.log("📋 [ButtonPrimary] modalDataStore 전체 키:", Object.keys(dataRegistry));
|
|
|
|
// sourceTableName이 지정되어 있으면 해당 테이블에서 조회
|
|
const sourceTableName = dataTransferConfig.sourceTableName || tableName;
|
|
|
|
if (sourceTableName && dataRegistry[sourceTableName]) {
|
|
const modalData = dataRegistry[sourceTableName];
|
|
sourceData = modalData.map((item: any) => item.originalData || item);
|
|
console.log(`✅ [ButtonPrimary] modalDataStore에서 데이터 발견 (${sourceTableName}):`, sourceData.length, "건");
|
|
} else {
|
|
// 테이블명으로 못 찾으면 첫 번째 데이터 사용
|
|
const firstKey = Object.keys(dataRegistry)[0];
|
|
if (firstKey && dataRegistry[firstKey]?.length > 0) {
|
|
const modalData = dataRegistry[firstKey];
|
|
sourceData = modalData.map((item: any) => item.originalData || item);
|
|
console.log(`✅ [ButtonPrimary] modalDataStore 첫 번째 키에서 데이터 발견 (${firstKey}):`, sourceData.length, "건");
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn("⚠️ [ButtonPrimary] modalDataStore 접근 실패:", err);
|
|
}
|
|
}
|
|
|
|
// 3. 여전히 데이터가 없으면 에러
|
|
if (!sourceData || sourceData.length === 0) {
|
|
console.error("❌ [ButtonPrimary] 선택된 데이터를 찾을 수 없습니다.", {
|
|
hasScreenContext: !!screenContext,
|
|
sourceComponentId: dataTransferConfig.sourceComponentId,
|
|
sourceTableName: dataTransferConfig.sourceTableName || tableName,
|
|
});
|
|
toast.warning("선택된 데이터가 없습니다. 항목을 먼저 선택해주세요.");
|
|
return;
|
|
}
|
|
|
|
console.log("📦 [ButtonPrimary] 최종 소스 데이터:", { sourceData, count: sourceData.length });
|
|
|
|
// 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,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
// 4. 매핑 규칙 적용 + 추가 데이터 병합
|
|
const mappedData = sourceData.map((row) => {
|
|
const mappedRow = applyMappingRules(row, dataTransferConfig.mappingRules || []);
|
|
|
|
// 추가 데이터를 모든 행에 포함
|
|
return {
|
|
...mappedRow,
|
|
...additionalData,
|
|
};
|
|
});
|
|
|
|
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 || [],
|
|
});
|
|
|
|
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;
|
|
}
|
|
} else if (dataTransferConfig.targetType === "screen") {
|
|
// 다른 화면으로 전달 (구현 예정)
|
|
toast.info("다른 화면으로의 데이터 전달은 추후 구현 예정입니다.");
|
|
return;
|
|
} else {
|
|
toast.success(`${sourceData.length}개 항목이 전달되었습니다.`);
|
|
}
|
|
|
|
// 6. 전달 후 정리
|
|
if (dataTransferConfig.clearAfterTransfer) {
|
|
sourceProvider.clearSelection();
|
|
}
|
|
} catch (error: any) {
|
|
console.error("❌ 데이터 전달 실패:", error);
|
|
toast.error(error.message || "데이터 전달 중 오류가 발생했습니다.");
|
|
}
|
|
};
|
|
|
|
const handleClick = async (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
|
|
// 프리뷰 모드에서는 버튼 동작 차단
|
|
if (isPreviewMode) {
|
|
return;
|
|
}
|
|
|
|
// 디자인 모드에서는 기본 onClick만 실행
|
|
if (isDesignMode) {
|
|
onClick?.();
|
|
return;
|
|
}
|
|
|
|
// 인터랙티브 모드에서 액션 실행
|
|
if (isInteractive && processedConfig.action) {
|
|
// transferData 액션 처리 (화면 컨텍스트 필요)
|
|
if (processedConfig.action.type === "transferData") {
|
|
await handleTransferDataAction(processedConfig.action);
|
|
return;
|
|
}
|
|
|
|
// 🆕 선택된 데이터 우선순위:
|
|
// 1. selectedRowsData (테이블에서 직접 선택)
|
|
// 2. groupedData (부모창에서 모달로 전달된 데이터)
|
|
// 3. modalDataStore (분할 패널 등에서 선택한 데이터)
|
|
let effectiveSelectedRowsData = selectedRowsData;
|
|
|
|
// groupedData가 있으면 우선 사용 (모달에서 부모 데이터 접근)
|
|
if (
|
|
(!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) &&
|
|
groupedData &&
|
|
groupedData.length > 0
|
|
) {
|
|
effectiveSelectedRowsData = groupedData;
|
|
}
|
|
|
|
// modalDataStore에서 선택된 데이터 가져오기 (분할 패널 등에서 선택한 데이터)
|
|
// 단, 모달(modal) 액션은 신규 등록이므로 modalDataStore 데이터를 가져오지 않음
|
|
// (다른 화면에서 선택한 데이터가 남아있을 수 있으므로)
|
|
const shouldFetchFromModalDataStore =
|
|
processedConfig.action.type !== "modal" &&
|
|
(!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) &&
|
|
effectiveTableName;
|
|
|
|
if (shouldFetchFromModalDataStore) {
|
|
try {
|
|
const { useModalDataStore } = await import("@/stores/modalDataStore");
|
|
const dataRegistry = useModalDataStore.getState().dataRegistry;
|
|
const modalData = dataRegistry[effectiveTableName];
|
|
if (modalData && modalData.length > 0) {
|
|
// modalDataStore는 {id, originalData, additionalData} 형태로 저장됨
|
|
// originalData를 추출하여 실제 행 데이터를 가져옴
|
|
effectiveSelectedRowsData = modalData.map((item: any) => {
|
|
// originalData가 있으면 그것을 사용, 없으면 item 자체 사용 (하위 호환성)
|
|
return item.originalData || item;
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.warn("modalDataStore 접근 실패:", error);
|
|
}
|
|
}
|
|
|
|
// 삭제 액션인데 선택된 데이터가 없으면 경고 메시지 표시하고 중단
|
|
const hasDataToDelete =
|
|
(effectiveSelectedRowsData && effectiveSelectedRowsData.length > 0) ||
|
|
(flowSelectedData && flowSelectedData.length > 0);
|
|
|
|
if (processedConfig.action.type === "delete" && !hasDataToDelete) {
|
|
toast.warning("삭제할 항목을 먼저 선택해주세요.");
|
|
return;
|
|
}
|
|
|
|
// 🔧 모달 액션 시 선택 데이터 경고 제거
|
|
// 이전에는 "신규 등록 시에는 테이블에서 선택된 항목을 해제해주세요" 경고를 표시했으나,
|
|
// 다른 화면에서 선택한 데이터가 남아있는 경우 오탐이 발생하여 제거함.
|
|
// 모달 화면 내부에서 필요 시 자체적으로 선택 데이터를 무시하도록 처리하면 됨.
|
|
|
|
// 수정(edit) 액션 검증
|
|
if (processedConfig.action.type === "edit") {
|
|
// 선택된 데이터가 없으면 경고
|
|
if (!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) {
|
|
toast.warning("수정할 항목을 선택해주세요.");
|
|
return;
|
|
}
|
|
|
|
// groupByColumns 설정이 있으면 해당 컬럼 값이 유일한지 확인
|
|
const groupByColumns = processedConfig.action.groupByColumns;
|
|
if (groupByColumns && groupByColumns.length > 0 && effectiveSelectedRowsData.length > 1) {
|
|
// 첫 번째 그룹핑 컬럼 기준으로 중복 체크 (예: order_no)
|
|
const groupByColumn = groupByColumns[0];
|
|
const uniqueValues = new Set(
|
|
effectiveSelectedRowsData.map((row: any) => row[groupByColumn]).filter(Boolean)
|
|
);
|
|
|
|
if (uniqueValues.size > 1) {
|
|
// 컬럼명을 한글로 변환 (order_no -> 수주번호)
|
|
const columnLabels: Record<string, string> = {
|
|
order_no: "수주번호",
|
|
shipment_no: "출하번호",
|
|
purchase_no: "구매번호",
|
|
};
|
|
const columnLabel = columnLabels[groupByColumn] || groupByColumn;
|
|
toast.warning(`${columnLabel} 하나만 선택해주세요. (현재 ${uniqueValues.size}개 선택됨)`);
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 분할 패널 부모 데이터 가져오기 (우측 화면에서 저장 시 좌측 선택 데이터 포함)
|
|
// 조건 완화: splitPanelContext가 있고 selectedLeftData가 있으면 가져옴
|
|
// (탭 안에서도 분할 패널 컨텍스트에 접근 가능하도록)
|
|
let splitPanelParentData: Record<string, any> | undefined;
|
|
if (splitPanelContext) {
|
|
// 우측 화면이거나, 탭 안의 화면(splitPanelPosition이 undefined)인 경우 모두 처리
|
|
// 좌측 화면이 아닌 경우에만 부모 데이터 포함 (좌측에서 저장 시 자신의 데이터를 부모로 포함하면 안됨)
|
|
if (splitPanelPosition !== "left") {
|
|
splitPanelParentData = splitPanelContext.getMappedParentData();
|
|
}
|
|
}
|
|
|
|
// 🆕 분할 패널 우측이면 여러 소스에서 formData를 병합
|
|
// 우선순위: props.formData > screenContext.formData > splitPanelParentData
|
|
const screenContextFormData = screenContext?.formData || {};
|
|
const propsFormData = formData || {};
|
|
|
|
// 병합: splitPanelParentData를 기본으로, props.formData, screenContext.formData 순으로 오버라이드
|
|
// (일반 폼 필드는 props.formData, RepeaterFieldGroup은 screenContext.formData에 있음)
|
|
let effectiveFormData = { ...propsFormData, ...screenContextFormData };
|
|
|
|
// 분할 패널 우측이고 formData가 비어있으면 splitPanelParentData 사용
|
|
if (splitPanelPosition === "right" && Object.keys(effectiveFormData).length === 0 && splitPanelParentData) {
|
|
effectiveFormData = { ...splitPanelParentData };
|
|
}
|
|
|
|
const context: ButtonActionContext = {
|
|
formData: effectiveFormData,
|
|
originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용)
|
|
screenId: effectiveScreenId, // 🆕 ScreenContext에서 가져온 값 사용
|
|
tableName: effectiveTableName, // 🆕 ScreenContext에서 가져온 값 사용
|
|
userId, // 🆕 사용자 ID
|
|
userName, // 🆕 사용자 이름
|
|
companyCode, // 🆕 회사 코드
|
|
onFormDataChange,
|
|
onRefresh,
|
|
onClose,
|
|
onFlowRefresh, // 플로우 새로고침 콜백 추가
|
|
onSave: finalOnSave, // 🆕 EditModal의 handleSave 콜백 (props에서도 추출)
|
|
// 테이블 선택된 행 정보 추가 (modalDataStore에서 가져온 데이터 우선)
|
|
selectedRows,
|
|
selectedRowsData: effectiveSelectedRowsData,
|
|
// 테이블 정렬 정보 추가
|
|
sortBy, // 🆕 정렬 컬럼
|
|
sortOrder, // 🆕 정렬 방향
|
|
columnOrder, // 🆕 컬럼 순서
|
|
tableDisplayData, // 🆕 화면에 표시된 데이터
|
|
// 🆕 같은 화면의 모든 컴포넌트 (TableList 자동 감지용)
|
|
allComponents,
|
|
// 플로우 선택된 데이터 정보 추가
|
|
flowSelectedData,
|
|
flowSelectedStepId,
|
|
// 🆕 컴포넌트별 설정 (parentDataMapping 등)
|
|
componentConfigs,
|
|
// 🆕 분할 패널 부모 데이터 (좌측 화면에서 선택된 데이터)
|
|
splitPanelParentData,
|
|
// 🆕 분할 패널 컨텍스트 (quickInsert 등에서 좌측 패널 데이터 접근용)
|
|
splitPanelContext: splitPanelContext ? {
|
|
selectedLeftData: splitPanelContext.selectedLeftData,
|
|
refreshRightPanel: splitPanelContext.refreshRightPanel,
|
|
} : undefined,
|
|
} as ButtonActionContext;
|
|
|
|
// 확인이 필요한 액션인지 확인
|
|
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, // 부분 업데이트용 원본 데이터 필터링
|
|
_originalData: __originalData, // DOM 필터링
|
|
_initialData: __initialData, // DOM 필터링
|
|
_groupedData: __groupedData, // DOM 필터링
|
|
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 <div style={{ display: "none" }} />;
|
|
} else {
|
|
// 완전히 렌더링하지 않음 (auto-compact, 빈 공간 제거)
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// 🆕 최종 비활성화 상태 (설정 + 조건부 비활성화 + 행 선택 필수)
|
|
const finalDisabled = componentConfig.disabled || isOperationButtonDisabled || isRowSelectionDisabled || statusLoading;
|
|
|
|
// 공통 버튼 스타일
|
|
// 🔧 component.style에서 background/backgroundColor 충돌 방지
|
|
const userStyle = component.style
|
|
? Object.fromEntries(
|
|
Object.entries(component.style).filter(
|
|
([key]) => !["width", "height", "background", "backgroundColor"].includes(key)
|
|
)
|
|
)
|
|
: {};
|
|
|
|
const buttonElementStyle: React.CSSProperties = {
|
|
width: "100%",
|
|
height: "100%",
|
|
minHeight: "40px",
|
|
border: "none",
|
|
borderRadius: "0.5rem",
|
|
backgroundColor: finalDisabled ? "#e5e7eb" : buttonColor, // 🔧 background → backgroundColor로 변경
|
|
color: finalDisabled ? "#9ca3af" : "white",
|
|
// 🔧 크기 설정 적용 (sm/md/lg)
|
|
fontSize: componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem",
|
|
fontWeight: "600",
|
|
cursor: finalDisabled ? "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: finalDisabled ? "none" : "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
|
// 디자인 모드와 인터랙티브 모드 모두에서 사용자 스타일 적용 (width/height/background 제외)
|
|
...userStyle,
|
|
};
|
|
|
|
const buttonContent = processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼";
|
|
|
|
return (
|
|
<>
|
|
<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={finalDisabled}
|
|
className="transition-colors transition-transform duration-150 hover:opacity-90 active:scale-95"
|
|
style={buttonElementStyle}
|
|
onClick={handleClick}
|
|
onDragStart={onDragStart}
|
|
onDragEnd={onDragEnd}
|
|
>
|
|
{buttonContent}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* 확인 다이얼로그 - EditModal보다 위에 표시하도록 z-index 최상위로 설정 */}
|
|
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
|
<AlertDialogContent className="z-[99999]">
|
|
<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>
|
|
</>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* ButtonPrimary 래퍼 컴포넌트
|
|
* 추가적인 로직이나 상태 관리가 필요한 경우 사용
|
|
*/
|
|
export const ButtonPrimaryWrapper: React.FC<ButtonPrimaryComponentProps> = (props) => {
|
|
return <ButtonPrimaryComponent {...props} />;
|
|
};
|