ERP-node/frontend/components/common/ScreenModal.tsx

998 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useState, useEffect, useRef, useCallback } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogCancel,
AlertDialogAction,
} from "@/components/ui/alert-dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
import { screenApi } from "@/lib/api/screen";
import { ComponentData } from "@/types/screen";
import { toast } from "sonner";
import { useAuth } from "@/hooks/useAuth";
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHeightContext";
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
interface ScreenModalState {
isOpen: boolean;
screenId: number | null;
title: string;
description?: string;
size: "sm" | "md" | "lg" | "xl";
}
interface ScreenModalProps {
className?: string;
}
export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
const { userId, userName, user } = useAuth();
const splitPanelContext = useSplitPanelContext();
const [modalState, setModalState] = useState<ScreenModalState>({
isOpen: false,
screenId: null,
title: "",
description: "",
size: "md",
});
const [screenData, setScreenData] = useState<{
components: ComponentData[];
screenInfo: any;
} | null>(null);
const [loading, setLoading] = useState(false);
const [screenDimensions, setScreenDimensions] = useState<{
width: number;
height: number;
offsetX?: number;
offsetY?: number;
} | null>(null);
// 폼 데이터 상태 추가
const [formData, setFormData] = useState<Record<string, any>>({});
// 🆕 원본 데이터 상태 (수정 모드에서 UPDATE 판단용)
const [originalData, setOriginalData] = useState<Record<string, any> | null>(null);
// 🆕 선택된 데이터 상태 (RepeatScreenModal 등에서 사용)
const [selectedData, setSelectedData] = useState<Record<string, any>[]>([]);
// 연속 등록 모드 상태 (state로 변경 - 체크박스 UI 업데이트를 위해)
const [continuousMode, setContinuousMode] = useState(false);
// 화면 리셋 키 (컴포넌트 강제 리마운트용)
const [resetKey, setResetKey] = useState(0);
// 모달 닫기 확인 다이얼로그 표시 상태
const [showCloseConfirm, setShowCloseConfirm] = useState(false);
// localStorage에서 연속 모드 상태 복원
useEffect(() => {
const savedMode = localStorage.getItem("screenModal_continuousMode");
if (savedMode === "true") {
setContinuousMode(true);
// console.log("🔄 연속 모드 복원: true");
}
}, []);
// 화면의 실제 크기 계산 함수
const calculateScreenDimensions = (components: ComponentData[]) => {
if (components.length === 0) {
return {
width: 400,
height: 300,
offsetX: 0,
offsetY: 0,
};
}
// 모든 컴포넌트의 경계 찾기
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
components.forEach((component) => {
const x = parseFloat(component.position?.x?.toString() || "0");
const y = parseFloat(component.position?.y?.toString() || "0");
const width = parseFloat(component.size?.width?.toString() || "100");
const height = parseFloat(component.size?.height?.toString() || "40");
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x + width);
maxY = Math.max(maxY, y + height);
});
// 실제 컨텐츠 크기 계산
const contentWidth = maxX - minX;
const contentHeight = maxY - minY;
// 적절한 여백 추가
const paddingX = 40;
const paddingY = 40;
const finalWidth = Math.max(contentWidth + paddingX, 400);
const finalHeight = Math.max(contentHeight + paddingY, 300);
return {
width: Math.min(finalWidth, window.innerWidth * 0.95),
height: Math.min(finalHeight, window.innerHeight * 0.9),
offsetX: Math.max(0, minX - paddingX / 2), // 좌측 여백 고려
offsetY: Math.max(0, minY - paddingY / 2), // 상단 여백 고려
};
};
// 모달이 열린 시간 추적 (저장 성공 이벤트 무시용)
const modalOpenedAtRef = React.useRef<number>(0);
// 🆕 채번 필드 수동 입력 값 변경 이벤트 리스너
useEffect(() => {
const handleNumberingValueChanged = (event: CustomEvent) => {
const { columnName, value } = event.detail;
if (columnName && modalState.isOpen) {
setFormData((prev) => ({
...prev,
[columnName]: value,
}));
}
};
window.addEventListener("numberingValueChanged", handleNumberingValueChanged as EventListener);
return () => {
window.removeEventListener("numberingValueChanged", handleNumberingValueChanged as EventListener);
};
}, [modalState.isOpen]);
// 전역 모달 이벤트 리스너
useEffect(() => {
const handleOpenModal = (event: CustomEvent) => {
const {
screenId,
title,
description,
size,
urlParams,
editData,
splitPanelParentData,
selectedData: eventSelectedData,
selectedIds,
isCreateMode, // 🆕 복사 모드 플래그 (true면 editData가 있어도 originalData 설정 안 함)
fieldMappings, // 🆕 필드 매핑 정보 (명시적 매핑이 있으면 모든 매핑된 필드 전달)
} = event.detail;
// 🆕 모달 열린 시간 기록
modalOpenedAtRef.current = Date.now();
// 🆕 선택된 데이터 저장 (RepeatScreenModal 등에서 사용)
if (eventSelectedData && Array.isArray(eventSelectedData)) {
setSelectedData(eventSelectedData);
} else {
setSelectedData([]);
}
// 🆕 URL 파라미터가 있으면 현재 URL에 추가
if (urlParams && typeof window !== "undefined") {
const currentUrl = new URL(window.location.href);
Object.entries(urlParams).forEach(([key, value]) => {
currentUrl.searchParams.set(key, String(value));
});
// pushState로 URL 변경 (페이지 새로고침 없이)
window.history.pushState({}, "", currentUrl.toString());
}
// 🆕 editData가 있으면 formData와 originalData로 설정 (수정 모드)
// 🔧 단, isCreateMode가 true이면 (복사 모드) originalData를 설정하지 않음 → 채번 생성 가능
if (editData && !isCreateMode) {
// 🆕 배열인 경우 두 가지 데이터를 설정:
// 1. formData: 첫 번째 요소(객체) - 일반 입력 필드용 (TextInput 등)
// 2. selectedData: 전체 배열 - 다중 항목 컴포넌트용 (SelectedItemsDetailInput 등)
if (Array.isArray(editData)) {
const firstRecord = editData[0] || {};
setFormData(firstRecord); // 🔧 일반 입력 필드용 (객체)
setSelectedData(editData); // 🔧 다중 항목 컴포넌트용 (배열) - groupedData로 전달됨
setOriginalData(firstRecord); // 첫 번째 레코드를 원본으로 저장
} else {
setFormData(editData);
setSelectedData([editData]); // 🔧 단일 객체도 배열로 변환하여 저장
setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
}
} else if (editData && isCreateMode) {
// 🆕 복사 모드: formData만 설정하고 originalData는 null로 유지 (채번 생성 가능)
if (Array.isArray(editData)) {
const firstRecord = editData[0] || {};
setFormData(firstRecord);
setSelectedData(editData);
} else {
setFormData(editData);
setSelectedData([editData]);
}
setOriginalData(null); // 🔧 복사 모드에서는 originalData를 null로 설정
} else {
// 🆕 신규 등록 모드: 분할 패널 부모 데이터가 있으면 미리 설정
// 🔧 중요: 신규 등록 시에는 연결 필드(equipment_code 등)만 전달해야 함
// 모든 필드를 전달하면 동일한 컬럼명이 있을 때 부모 값이 들어가는 문제 발생
// 예: 설비의 manufacturer가 소모품의 manufacturer로 들어감
// parentDataMapping에서 명시된 필드만 추출
const parentDataMapping = splitPanelContext?.parentDataMapping || [];
// 부모 데이터 소스
// 🔧 수정: 여러 소스를 병합 (우선순위: splitPanelParentData > selectedLeftData > 기존 formData의 링크 필드)
// 예: screen 150→226→227 전환 시:
// - splitPanelParentData: item_info 데이터 (screen 226에서 전달)
// - selectedLeftData: customer_mng 데이터 (SplitPanel 좌측 선택)
// - 기존 formData: 이전 모달에서 설정된 link 필드 (customer_code 등)
const contextData = splitPanelContext?.selectedLeftData || {};
const eventData = splitPanelParentData && Object.keys(splitPanelParentData).length > 0
? splitPanelParentData
: {};
// 🆕 기존 formData에서 link 필드(_code, _id)를 가져와 base로 사용
// 모달 체인(226→227)에서 이전 모달의 연결 필드가 유지됨
const previousLinkFields: Record<string, any> = {};
if (formData && typeof formData === "object" && !Array.isArray(formData)) {
const linkFieldPatterns = ["_code", "_id"];
const excludeFields = ["id", "created_date", "updated_date", "created_at", "updated_at", "writer"];
for (const [key, value] of Object.entries(formData)) {
if (excludeFields.includes(key)) continue;
if (value === undefined || value === null) continue;
const isLinkField = linkFieldPatterns.some((pattern) => key.endsWith(pattern));
if (isLinkField) {
previousLinkFields[key] = value;
}
}
}
const rawParentData = { ...previousLinkFields, ...contextData, ...eventData };
// 🔧 신규 등록 모드에서는 연결에 필요한 필드만 전달
const parentData: Record<string, any> = {};
// 필수 연결 필드: company_code (멀티테넌시)
if (rawParentData.company_code) {
parentData.company_code = rawParentData.company_code;
}
// 🆕 명시적 필드 매핑이 있으면 매핑된 타겟 필드를 모두 보존
// (버튼 설정에서 fieldMappings로 지정한 필드는 link 필드가 아니어도 전달)
const mappedTargetFields = new Set<string>();
if (fieldMappings && Array.isArray(fieldMappings)) {
for (const mapping of fieldMappings) {
if (mapping.targetField) {
mappedTargetFields.add(mapping.targetField);
}
}
}
// parentDataMapping에 정의된 필드만 전달
for (const mapping of parentDataMapping) {
const sourceValue = rawParentData[mapping.sourceColumn];
if (sourceValue !== undefined && sourceValue !== null) {
parentData[mapping.targetColumn] = sourceValue;
}
}
// 🆕 명시적 필드 매핑이 있으면 해당 필드를 모두 전달
if (mappedTargetFields.size > 0) {
for (const [key, value] of Object.entries(rawParentData)) {
if (mappedTargetFields.has(key) && value !== undefined && value !== null) {
parentData[key] = value;
}
}
}
// parentDataMapping이 비어있고 명시적 필드 매핑도 없으면 연결 필드 자동 감지
if (parentDataMapping.length === 0 && mappedTargetFields.size === 0) {
const linkFieldPatterns = ["_code", "_id"];
const excludeFields = [
"id",
"company_code",
"created_date",
"updated_date",
"created_at",
"updated_at",
"writer",
];
for (const [key, value] of Object.entries(rawParentData)) {
if (excludeFields.includes(key)) continue;
if (value === undefined || value === null) continue;
// 연결 필드 패턴 확인
const isLinkField = linkFieldPatterns.some((pattern) => key.endsWith(pattern));
if (isLinkField) {
parentData[key] = value;
}
}
} else if (parentDataMapping.length === 0 && mappedTargetFields.size > 0) {
// 🆕 명시적 매핑이 있어도 연결 필드(_code, _id)는 추가로 전달
const linkFieldPatterns = ["_code", "_id"];
const excludeFields = [
"id",
"company_code",
"created_date",
"updated_date",
"created_at",
"updated_at",
"writer",
];
for (const [key, value] of Object.entries(rawParentData)) {
if (excludeFields.includes(key)) continue;
if (parentData[key] !== undefined) continue; // 이미 매핑된 필드는 스킵
if (value === undefined || value === null) continue;
const isLinkField = linkFieldPatterns.some((pattern) => key.endsWith(pattern));
if (isLinkField) {
parentData[key] = value;
}
}
}
if (Object.keys(parentData).length > 0) {
setFormData(parentData);
} else {
setFormData({});
}
setOriginalData(null); // 신규 등록 모드
}
setModalState({
isOpen: true,
screenId,
title,
description: description || "",
size,
});
};
const handleCloseModal = () => {
// 🆕 URL 파라미터 제거
if (typeof window !== "undefined") {
const currentUrl = new URL(window.location.href);
// dataSourceId 파라미터 제거
currentUrl.searchParams.delete("dataSourceId");
window.history.pushState({}, "", currentUrl.toString());
}
setModalState({
isOpen: false,
screenId: null,
title: "",
description: "",
size: "md",
});
setScreenData(null);
setFormData({});
setOriginalData(null); // 🆕 원본 데이터 초기화
setSelectedData([]); // 🆕 선택된 데이터 초기화
setContinuousMode(false);
localStorage.setItem("screenModal_continuousMode", "false");
};
// 저장 성공 이벤트 처리 (연속 등록 모드 지원)
const handleSaveSuccess = () => {
// 🆕 모달이 열린 후 500ms 이내의 저장 성공 이벤트는 무시 (이전 이벤트 방지)
const timeSinceOpen = Date.now() - modalOpenedAtRef.current;
if (timeSinceOpen < 500) {
return;
}
const isContinuousMode = continuousMode;
if (isContinuousMode) {
// 연속 모드: 폼만 초기화하고 모달은 유지
setFormData({});
setResetKey((prev) => prev + 1);
// 화면 데이터 다시 로드 (채번 규칙 새로 생성)
if (modalState.screenId) {
loadScreenData(modalState.screenId);
}
toast.success("저장되었습니다. 계속 입력하세요.");
} else {
// 일반 모드: 모달 닫기
handleCloseModal();
}
};
window.addEventListener("openScreenModal", handleOpenModal as EventListener);
window.addEventListener("closeSaveModal", handleCloseModal);
window.addEventListener("saveSuccessInModal", handleSaveSuccess);
return () => {
window.removeEventListener("openScreenModal", handleOpenModal as EventListener);
window.removeEventListener("closeSaveModal", handleCloseModal);
window.removeEventListener("saveSuccessInModal", handleSaveSuccess);
};
}, [continuousMode]); // continuousMode 의존성 추가
// 화면 데이터 로딩
useEffect(() => {
if (modalState.isOpen && modalState.screenId) {
loadScreenData(modalState.screenId);
}
}, [modalState.isOpen, modalState.screenId]);
const loadScreenData = async (screenId: number) => {
try {
setLoading(true);
// 화면 정보와 레이아웃 데이터 로딩 (V2 API 사용으로 기본값 병합)
const [screenInfo, v2LayoutData] = await Promise.all([
screenApi.getScreen(screenId),
screenApi.getLayoutV2(screenId),
]);
// V2 → Legacy 변환 (기본값 병합 포함)
let layoutData: any = null;
if (v2LayoutData && isValidV2Layout(v2LayoutData)) {
layoutData = convertV2ToLegacy(v2LayoutData);
if (layoutData) {
// screenResolution은 V2 레이아웃에서 직접 가져오기
layoutData.screenResolution = v2LayoutData.screenResolution || layoutData.screenResolution;
}
}
// V2 레이아웃이 없으면 기존 API로 fallback
if (!layoutData) {
layoutData = await screenApi.getLayout(screenId);
}
// 🆕 URL 파라미터 확인 (수정 모드)
if (typeof window !== "undefined") {
const urlParams = new URLSearchParams(window.location.search);
const mode = urlParams.get("mode");
const editId = urlParams.get("editId");
const tableName = urlParams.get("tableName") || screenInfo.tableName;
const groupByColumnsParam = urlParams.get("groupByColumns");
const primaryKeyColumn = urlParams.get("primaryKeyColumn"); // 🆕 Primary Key 컬럼명
// 수정 모드이고 editId가 있으면 해당 레코드 조회
if (mode === "edit" && editId && tableName) {
try {
// groupByColumns 파싱
let groupByColumns: string[] = [];
if (groupByColumnsParam) {
try {
groupByColumns = JSON.parse(groupByColumnsParam);
} catch {
// groupByColumns 파싱 실패 시 무시
}
}
// 🆕 apiClient를 named import로 가져오기
const { apiClient } = await import("@/lib/api/client");
const params: any = {
enableEntityJoin: true, // 엔티티 조인 활성화 (모든 엔티티 컬럼 자동 포함)
};
if (groupByColumns.length > 0) {
params.groupByColumns = JSON.stringify(groupByColumns);
}
// 🆕 Primary Key 컬럼명 전달 (백엔드 자동 감지 실패 시 사용)
if (primaryKeyColumn) {
params.primaryKeyColumn = primaryKeyColumn;
}
const apiResponse = await apiClient.get(`/data/${tableName}/${editId}`, { params });
const response = apiResponse.data;
if (response.success && response.data) {
// 🔧 날짜 필드 정규화 (타임존 제거)
const normalizeDates = (data: any): any => {
if (Array.isArray(data)) {
return data.map(normalizeDates);
}
if (typeof data !== "object" || data === null) {
return data;
}
const normalized: any = {};
for (const [key, value] of Object.entries(data)) {
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
// ISO 날짜 형식 감지: YYYY-MM-DD만 추출
normalized[key] = value.split("T")[0];
} else {
normalized[key] = value;
}
}
return normalized;
};
const normalizedData = normalizeDates(response.data);
// 🔧 배열 데이터는 formData로 설정하지 않음 (SelectedItemsDetailInput만 사용)
if (Array.isArray(normalizedData)) {
setFormData(normalizedData); // SelectedItemsDetailInput이 직접 사용
setOriginalData(normalizedData[0] || null); // 🆕 첫 번째 레코드를 원본으로 저장
} else {
setFormData(normalizedData);
setOriginalData(normalizedData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
}
} else {
toast.error("데이터를 불러올 수 없습니다.");
}
} catch (error) {
console.error("수정 데이터 조회 오류:", error);
toast.error("데이터를 불러오는 중 오류가 발생했습니다.");
}
}
}
// screenApi는 직접 데이터를 반환하므로 .success 체크 불필요
if (screenInfo && layoutData) {
const components = layoutData.components || [];
// 화면 관리에서 설정한 해상도 사용 (우선순위)
const screenResolution = (layoutData as any).screenResolution || (screenInfo as any).screenResolution;
let dimensions;
if (screenResolution && screenResolution.width && screenResolution.height) {
// 화면 관리에서 설정한 해상도 사용
dimensions = {
width: screenResolution.width,
height: screenResolution.height,
offsetX: 0,
offsetY: 0,
};
} else {
// 해상도 정보가 없으면 자동 계산
dimensions = calculateScreenDimensions(components);
}
setScreenDimensions(dimensions);
setScreenData({
components,
screenInfo: screenInfo,
});
} else {
throw new Error("화면 데이터가 없습니다");
}
} catch (error) {
console.error("화면 데이터 로딩 오류:", error);
toast.error("화면을 불러오는 중 오류가 발생했습니다.");
handleClose();
} finally {
setLoading(false);
}
};
// 사용자가 바깥 클릭/ESC/X 버튼으로 닫으려 할 때 확인 다이얼로그 표시
const handleCloseAttempt = useCallback(() => {
setShowCloseConfirm(true);
}, []);
// 확인 후 실제로 모달을 닫는 함수
const handleConfirmClose = useCallback(() => {
setShowCloseConfirm(false);
handleCloseInternal();
}, []);
// 닫기 취소 (계속 작업)
const handleCancelClose = useCallback(() => {
setShowCloseConfirm(false);
}, []);
const handleCloseInternal = () => {
// 🔧 URL 파라미터 제거 (mode, editId, tableName, groupByColumns, dataSourceId 등)
if (typeof window !== "undefined") {
const currentUrl = new URL(window.location.href);
currentUrl.searchParams.delete("mode");
currentUrl.searchParams.delete("editId");
currentUrl.searchParams.delete("tableName");
currentUrl.searchParams.delete("groupByColumns");
currentUrl.searchParams.delete("dataSourceId");
window.history.pushState({}, "", currentUrl.toString());
}
setModalState({
isOpen: false,
screenId: null,
title: "",
size: "md",
});
setScreenData(null);
setFormData({}); // 폼 데이터 초기화
setOriginalData(null); // 원본 데이터 초기화
setSelectedData([]); // 선택된 데이터 초기화
setContinuousMode(false);
localStorage.setItem("screenModal_continuousMode", "false");
};
// 기존 handleClose를 유지 (이벤트 핸들러 등에서 사용)
const handleClose = handleCloseInternal;
// 모달 크기 설정 - 화면관리 설정 크기 + 헤더/푸터
const getModalStyle = () => {
if (!screenDimensions) {
return {
className: "w-fit min-w-[400px] max-w-4xl max-h-[90vh] overflow-hidden p-0",
style: undefined, // undefined로 변경 - defaultWidth/defaultHeight 사용
needsScroll: false,
};
}
// 화면관리에서 설정한 크기 = 컨텐츠 영역 크기
// 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스 + gap + 패딩
// 🔧 DialogContent의 gap-4 (16px × 2) + 컨텐츠 pt-6 (24px) 포함
const headerHeight = 48; // DialogHeader (타이틀 + border-b + py-3)
const footerHeight = 44; // 연속 등록 모드 체크박스 영역
const dialogGap = 32; // gap-4 × 2 (header-content, content-footer 사이)
const contentTopPadding = 24; // pt-6 (컨텐츠 영역 상단 패딩)
const horizontalPadding = 16; // 좌우 패딩 최소화
const totalHeight = screenDimensions.height + headerHeight + footerHeight + dialogGap + contentTopPadding;
const maxAvailableHeight = window.innerHeight * 0.95;
// 콘텐츠가 화면 높이를 초과할 때만 스크롤 필요
const needsScroll = totalHeight > maxAvailableHeight;
return {
className: "overflow-hidden p-0",
style: {
width: `${Math.min(screenDimensions.width + horizontalPadding, window.innerWidth * 0.98)}px`,
// 🔧 height 대신 max-height만 설정 - 콘텐츠가 작으면 자동으로 줄어듦
maxHeight: `${maxAvailableHeight}px`,
maxWidth: "98vw",
},
needsScroll,
};
};
const modalStyle = getModalStyle();
// 안정적인 modalId를 상태로 저장 (모달이 닫혀도 유지)
const [persistedModalId, setPersistedModalId] = useState<string | undefined>(undefined);
// modalId 생성 및 업데이트
useEffect(() => {
// 모달이 열려있고 screenId가 있을 때만 업데이트
if (!modalState.isOpen) return;
let newModalId: string | undefined;
// 1순위: screenId (가장 안정적)
if (modalState.screenId) {
newModalId = `screen-modal-${modalState.screenId}`;
// console.log("🔑 ScreenModal modalId 생성:", {
// method: "screenId",
// screenId: modalState.screenId,
// result: newModalId,
// });
}
// 2순위: 테이블명
else if (screenData?.screenInfo?.tableName) {
newModalId = `screen-modal-table-${screenData.screenInfo.tableName}`;
// console.log("🔑 ScreenModal modalId 생성:", {
// method: "tableName",
// tableName: screenData.screenInfo.tableName,
// result: newModalId,
// });
}
// 3순위: 화면명
else if (screenData?.screenInfo?.screenName) {
newModalId = `screen-modal-name-${screenData.screenInfo.screenName}`;
// console.log("🔑 ScreenModal modalId 생성:", {
// method: "screenName",
// screenName: screenData.screenInfo.screenName,
// result: newModalId,
// });
}
// 4순위: 제목
else if (modalState.title) {
const titleId = modalState.title.replace(/\s+/g, "-");
newModalId = `screen-modal-title-${titleId}`;
// console.log("🔑 ScreenModal modalId 생성:", {
// method: "title",
// title: modalState.title,
// result: newModalId,
// });
}
if (newModalId) {
setPersistedModalId(newModalId);
}
}, [
modalState.isOpen,
modalState.screenId,
modalState.title,
screenData?.screenInfo?.tableName,
screenData?.screenInfo?.screenName,
]);
return (
<Dialog
open={modalState.isOpen}
onOpenChange={(open) => {
// X 버튼 클릭 시에도 확인 다이얼로그 표시
if (!open) {
handleCloseAttempt();
}
}}
>
<DialogContent
className={`${modalStyle.className} ${className || ""} max-w-none flex flex-col`}
{...(modalStyle.style && { style: modalStyle.style })}
// 바깥 클릭 바로 닫히지 않도록 방지
onInteractOutside={(e) => {
e.preventDefault();
handleCloseAttempt();
}}
// ESC 누를 때도 바로 닫히지 않도록 방지
onEscapeKeyDown={(e) => {
e.preventDefault();
handleCloseAttempt();
}}
>
<DialogHeader className="shrink-0 border-b px-4 py-3">
<div className="flex items-center gap-2">
<DialogTitle className="text-base">{modalState.title}</DialogTitle>
{modalState.description && !loading && (
<DialogDescription className="text-muted-foreground text-xs">{modalState.description}</DialogDescription>
)}
{loading && (
<DialogDescription className="text-xs">{loading ? "화면을 불러오는 중입니다..." : ""}</DialogDescription>
)}
</div>
</DialogHeader>
<div
className={`flex-1 min-h-0 flex items-start justify-center pt-6 ${modalStyle.needsScroll ? 'overflow-auto' : 'overflow-visible'}`}
>
{loading ? (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-b-2 border-blue-600"></div>
<p className="text-muted-foreground"> ...</p>
</div>
</div>
) : screenData ? (
<ActiveTabProvider>
<TableOptionsProvider>
<div
className="relative bg-white"
style={{
width: `${screenDimensions?.width || 800}px`,
height: `${screenDimensions?.height || 600}px`,
// 🆕 라벨이 위로 튀어나갈 수 있도록 overflow visible 설정
overflow: "visible",
}}
>
{(() => {
// 🆕 동적 y 좌표 조정을 위해 먼저 숨겨지는 컴포넌트들 파악
const isComponentHidden = (comp: any) => {
const cc = comp.componentConfig?.conditionalConfig || comp.conditionalConfig;
if (!cc?.enabled || !formData) return false;
const { field, operator, value, action } = cc;
const fieldValue = formData[field];
let conditionMet = false;
switch (operator) {
case "=":
case "==":
case "===":
conditionMet = fieldValue === value;
break;
case "!=":
case "!==":
conditionMet = fieldValue !== value;
break;
default:
conditionMet = fieldValue === value;
}
return (action === "show" && !conditionMet) || (action === "hide" && conditionMet);
};
// 표시되는 컴포넌트들의 y 범위 수집
const visibleRanges: { y: number; bottom: number }[] = [];
screenData.components.forEach((comp: any) => {
if (!isComponentHidden(comp)) {
const y = parseFloat(comp.position?.y?.toString() || "0");
const height = parseFloat(comp.size?.height?.toString() || "0");
visibleRanges.push({ y, bottom: y + height });
}
});
// 숨겨지는 컴포넌트의 "실제 빈 공간" 계산 (표시되는 컴포넌트와 겹치지 않는 영역)
const getActualGap = (hiddenY: number, hiddenBottom: number): number => {
// 숨겨지는 영역 중 표시되는 컴포넌트와 겹치는 부분을 제외
let gapStart = hiddenY;
let gapEnd = hiddenBottom;
for (const visible of visibleRanges) {
// 겹치는 영역 확인
if (visible.y < gapEnd && visible.bottom > gapStart) {
// 겹치는 부분을 제외
if (visible.y <= gapStart && visible.bottom >= gapEnd) {
// 완전히 덮힘 - 빈 공간 없음
return 0;
} else if (visible.y <= gapStart) {
// 위쪽이 덮힘
gapStart = visible.bottom;
} else if (visible.bottom >= gapEnd) {
// 아래쪽이 덮힘
gapEnd = visible.y;
}
}
}
return Math.max(0, gapEnd - gapStart);
};
// 숨겨지는 컴포넌트들의 실제 빈 공간 수집
const hiddenGaps: { bottom: number; gap: number }[] = [];
screenData.components.forEach((comp: any) => {
if (isComponentHidden(comp)) {
const y = parseFloat(comp.position?.y?.toString() || "0");
const height = parseFloat(comp.size?.height?.toString() || "0");
const bottom = y + height;
const gap = getActualGap(y, bottom);
if (gap > 0) {
hiddenGaps.push({ bottom, gap });
}
}
});
// bottom 기준으로 정렬 및 중복 제거 (같은 bottom은 가장 큰 gap만 유지)
const mergedGaps = new Map<number, number>();
hiddenGaps.forEach(({ bottom, gap }) => {
const existing = mergedGaps.get(bottom) || 0;
mergedGaps.set(bottom, Math.max(existing, gap));
});
const sortedGaps = Array.from(mergedGaps.entries())
.map(([bottom, gap]) => ({ bottom, gap }))
.sort((a, b) => a.bottom - b.bottom);
// 각 컴포넌트의 y 조정값 계산 함수
const getYOffset = (compY: number, compId?: string) => {
let offset = 0;
for (const { bottom, gap } of sortedGaps) {
// 컴포넌트가 숨겨진 영역 아래에 있으면 그 빈 공간만큼 위로 이동
if (compY > bottom) {
offset += gap;
}
}
return offset;
};
return screenData.components.map((component: any) => {
// 숨겨지는 컴포넌트는 렌더링 안함
if (isComponentHidden(component)) {
return null;
}
// 화면 관리 해상도를 사용하는 경우 offset 조정 불필요
const offsetX = screenDimensions?.offsetX || 0;
const offsetY = screenDimensions?.offsetY || 0;
// 🆕 동적 y 좌표 조정 (숨겨진 컴포넌트 높이만큼 위로 이동)
const compY = parseFloat(component.position?.y?.toString() || "0");
const yAdjustment = getYOffset(compY, component.id);
// offset이 0이면 원본 위치 사용 (화면 관리 해상도 사용 시)
const adjustedComponent = {
...component,
position: {
...component.position,
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
y: compY - offsetY - yAdjustment, // 🆕 동적 조정 적용
},
};
return (
<InteractiveScreenViewerDynamic
key={`${component.id}-${resetKey}`}
component={adjustedComponent}
allComponents={screenData.components}
formData={formData}
originalData={originalData} // 🆕 원본 데이터 전달 (UPDATE 판단용)
onFormDataChange={(fieldName, value) => {
setFormData((prev) => {
const newFormData = {
...prev,
[fieldName]: value,
};
return newFormData;
});
}}
onRefresh={() => {
// 부모 화면의 테이블 새로고침 이벤트 발송
window.dispatchEvent(new CustomEvent("refreshTable"));
}}
screenInfo={{
id: modalState.screenId!,
tableName: screenData.screenInfo?.tableName,
}}
groupedData={selectedData}
userId={userId}
userName={userName}
companyCode={user?.companyCode}
/>
);
});
})()}
</div>
</TableOptionsProvider>
</ActiveTabProvider>
) : (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground"> .</p>
</div>
)}
</div>
{/* 연속 등록 모드 체크박스 */}
<div className="border-t px-4 py-3">
<div className="flex items-center gap-2">
<Checkbox
id="continuous-mode"
checked={continuousMode}
onCheckedChange={(checked) => {
const isChecked = checked === true;
setContinuousMode(isChecked);
localStorage.setItem("screenModal_continuousMode", String(isChecked));
}}
/>
<Label htmlFor="continuous-mode" className="cursor-pointer text-sm font-normal select-none">
( )
</Label>
</div>
</div>
</DialogContent>
{/* 모달 닫기 확인 다이얼로그 */}
<AlertDialog open={showCloseConfirm} onOpenChange={setShowCloseConfirm}>
<AlertDialogContent className="!z-[1100] max-w-[95vw] sm:max-w-[400px]">
<AlertDialogHeader>
<AlertDialogTitle className="text-base sm:text-lg">
?
</AlertDialogTitle>
<AlertDialogDescription className="text-xs sm:text-sm">
.
<br />
&apos; &apos; .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="gap-2 sm:gap-0">
<AlertDialogCancel
onClick={handleCancelClose}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmClose}
className="h-8 flex-1 text-xs bg-destructive text-destructive-foreground hover:bg-destructive/90 sm:h-10 sm:flex-none sm:text-sm"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
);
};
export default ScreenModal;