1158 lines
46 KiB
TypeScript
1158 lines
46 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useRef, useCallback, useMemo } 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";
|
|
import { ConditionalZone, LayerDefinition } from "@/types/screen-management";
|
|
|
|
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>[]>([]);
|
|
|
|
// 🆕 조건부 레이어 상태 (Zone 기반)
|
|
const [conditionalLayers, setConditionalLayers] = useState<(LayerDefinition & { components: ComponentData[]; zone?: ConditionalZone })[]>([]);
|
|
|
|
// 연속 등록 모드 상태 (state로 변경 - 체크박스 UI 업데이트를 위해)
|
|
const [continuousMode, setContinuousMode] = useState(false);
|
|
|
|
// 화면 리셋 키 (컴포넌트 강제 리마운트용)
|
|
const [resetKey, setResetKey] = useState(0);
|
|
|
|
// 모달 닫기 확인 다이얼로그 표시 상태
|
|
const [showCloseConfirm, setShowCloseConfirm] = useState(false);
|
|
|
|
// 사용자가 폼 데이터를 실제로 변경했는지 추적 (변경 없으면 경고 없이 바로 닫기)
|
|
const formDataChangedRef = useRef(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 = 0;
|
|
const paddingY = 0;
|
|
|
|
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), // 여백 없이 컨텐츠 시작점 기준
|
|
offsetY: Math.max(0, minY), // 여백 없이 컨텐츠 시작점 기준
|
|
};
|
|
};
|
|
|
|
// 모달이 열린 시간 추적 (저장 성공 이벤트 무시용)
|
|
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 설정 안 함)
|
|
} = event.detail;
|
|
|
|
// 🆕 모달 열린 시간 기록
|
|
modalOpenedAtRef.current = Date.now();
|
|
|
|
// 폼 변경 추적 초기화
|
|
formDataChangedRef.current = false;
|
|
|
|
// 🆕 선택된 데이터 저장 (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;
|
|
}
|
|
|
|
// parentDataMapping에 정의된 필드만 전달
|
|
for (const mapping of parentDataMapping) {
|
|
const sourceValue = rawParentData[mapping.sourceColumn];
|
|
if (sourceValue !== undefined && sourceValue !== null) {
|
|
parentData[mapping.targetColumn] = sourceValue;
|
|
}
|
|
}
|
|
|
|
// parentDataMapping이 비어있으면 연결 필드 자동 감지 (equipment_code, xxx_code, xxx_id 패턴)
|
|
if (parentDataMapping.length === 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;
|
|
}
|
|
}
|
|
}
|
|
|
|
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) {
|
|
// 연속 모드: 폼만 초기화하고 모달은 유지
|
|
formDataChangedRef.current = false;
|
|
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,
|
|
});
|
|
|
|
// 🆕 조건부 레이어/존 로드
|
|
loadConditionalLayersAndZones(screenId);
|
|
} else {
|
|
throw new Error("화면 데이터가 없습니다");
|
|
}
|
|
} catch (error) {
|
|
console.error("화면 데이터 로딩 오류:", error);
|
|
toast.error("화면을 불러오는 중 오류가 발생했습니다.");
|
|
handleClose();
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// 🆕 조건부 레이어 & 존 로드 함수
|
|
const loadConditionalLayersAndZones = async (screenId: number) => {
|
|
try {
|
|
const [layersRes, zonesRes] = await Promise.all([
|
|
screenApi.getScreenLayers(screenId),
|
|
screenApi.getScreenZones(screenId),
|
|
]);
|
|
|
|
const loadedLayers = layersRes || [];
|
|
const loadedZones: ConditionalZone[] = zonesRes || [];
|
|
|
|
// 기본 레이어(layer_id=1) 제외
|
|
const nonBaseLayers = loadedLayers.filter((l: any) => l.layer_id !== 1);
|
|
|
|
if (nonBaseLayers.length === 0) {
|
|
setConditionalLayers([]);
|
|
return;
|
|
}
|
|
|
|
const layerDefs: (LayerDefinition & { components: ComponentData[] })[] = [];
|
|
|
|
for (const layer of nonBaseLayers) {
|
|
try {
|
|
const layerLayout = await screenApi.getLayerLayout(screenId, layer.layer_id);
|
|
|
|
let layerComponents: ComponentData[] = [];
|
|
if (layerLayout && isValidV2Layout(layerLayout)) {
|
|
const legacyLayout = convertV2ToLegacy(layerLayout);
|
|
layerComponents = (legacyLayout?.components || []) as unknown as ComponentData[];
|
|
} else if (layerLayout?.components) {
|
|
layerComponents = layerLayout.components;
|
|
}
|
|
|
|
// condition_config에서 zone_id, condition_value 추출
|
|
const cc = layer.condition_config || {};
|
|
const zone = loadedZones.find((z) => z.zone_id === cc.zone_id);
|
|
|
|
layerDefs.push({
|
|
id: `layer-${layer.layer_id}`,
|
|
name: layer.layer_name || `레이어 ${layer.layer_id}`,
|
|
type: "conditional",
|
|
zIndex: layer.layer_id,
|
|
isVisible: false,
|
|
isLocked: false,
|
|
zoneId: cc.zone_id,
|
|
conditionValue: cc.condition_value,
|
|
condition: zone
|
|
? {
|
|
targetComponentId: zone.trigger_component_id || "",
|
|
operator: (zone.trigger_operator || "eq") as any,
|
|
value: cc.condition_value || "",
|
|
}
|
|
: undefined,
|
|
components: layerComponents,
|
|
zone: zone || undefined, // 🆕 Zone 위치 정보 포함 (오프셋 계산용)
|
|
} as any);
|
|
} catch (err) {
|
|
console.warn(`[ScreenModal] 레이어 ${layer.layer_id} 로드 실패:`, err);
|
|
}
|
|
}
|
|
|
|
console.log("[ScreenModal] 조건부 레이어 로드 완료:", layerDefs.length, "개",
|
|
layerDefs.map((l) => ({
|
|
id: l.id, name: l.name, conditionValue: l.conditionValue,
|
|
componentCount: l.components.length,
|
|
condition: l.condition,
|
|
}))
|
|
);
|
|
|
|
setConditionalLayers(layerDefs);
|
|
} catch (error) {
|
|
console.error("[ScreenModal] 조건부 레이어 로드 실패:", error);
|
|
}
|
|
};
|
|
|
|
// 🆕 조건부 레이어 활성화 평가 (formData 변경 시)
|
|
const activeConditionalComponents = useMemo(() => {
|
|
if (conditionalLayers.length === 0) return [];
|
|
|
|
const allComponents = screenData?.components || [];
|
|
const activeComps: ComponentData[] = [];
|
|
|
|
conditionalLayers.forEach((layer) => {
|
|
if (!layer.condition) return;
|
|
const { targetComponentId, operator, value } = layer.condition;
|
|
if (!targetComponentId) return;
|
|
|
|
// V2 레이아웃: overrides.columnName 우선
|
|
const comp = allComponents.find((c: any) => c.id === targetComponentId);
|
|
const fieldKey =
|
|
(comp as any)?.overrides?.columnName ||
|
|
(comp as any)?.columnName ||
|
|
(comp as any)?.componentConfig?.columnName ||
|
|
targetComponentId;
|
|
|
|
const targetValue = formData[fieldKey];
|
|
|
|
let isMatch = false;
|
|
switch (operator) {
|
|
case "eq":
|
|
isMatch = String(targetValue ?? "") === String(value ?? "");
|
|
break;
|
|
case "neq":
|
|
isMatch = String(targetValue ?? "") !== String(value ?? "");
|
|
break;
|
|
case "in":
|
|
if (Array.isArray(value)) {
|
|
isMatch = value.some((v) => String(v) === String(targetValue ?? ""));
|
|
} else if (typeof value === "string" && value.includes(",")) {
|
|
isMatch = value.split(",").map((v) => v.trim()).includes(String(targetValue ?? ""));
|
|
}
|
|
break;
|
|
}
|
|
|
|
console.log("[ScreenModal] 레이어 조건 평가:", {
|
|
layerName: layer.name, fieldKey,
|
|
targetValue: String(targetValue ?? "(없음)"),
|
|
conditionValue: String(value), operator, isMatch,
|
|
});
|
|
|
|
if (isMatch) {
|
|
// Zone 오프셋 적용 (레이어 2 컴포넌트는 Zone 상대 좌표로 저장됨)
|
|
const zoneX = layer.zone?.x || 0;
|
|
const zoneY = layer.zone?.y || 0;
|
|
|
|
const offsetComponents = layer.components.map((c: any) => ({
|
|
...c,
|
|
position: {
|
|
...c.position,
|
|
x: parseFloat(c.position?.x?.toString() || "0") + zoneX,
|
|
y: parseFloat(c.position?.y?.toString() || "0") + zoneY,
|
|
},
|
|
}));
|
|
|
|
activeComps.push(...offsetComponents);
|
|
}
|
|
});
|
|
|
|
return activeComps;
|
|
}, [formData, conditionalLayers, screenData?.components]);
|
|
|
|
// 사용자가 바깥 클릭/ESC/X 버튼으로 닫으려 할 때
|
|
// 폼 데이터 변경이 있으면 확인 다이얼로그, 없으면 바로 닫기
|
|
const handleCloseAttempt = useCallback(() => {
|
|
if (formDataChangedRef.current) {
|
|
setShowCloseConfirm(true);
|
|
} else {
|
|
handleCloseInternal();
|
|
}
|
|
}, []);
|
|
|
|
// 확인 후 실제로 모달을 닫는 함수
|
|
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([]); // 선택된 데이터 초기화
|
|
setConditionalLayers([]); // 🆕 조건부 레이어 초기화
|
|
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 overflow-hidden",
|
|
style: { padding: 0, gap: 0, maxHeight: "calc(100dvh - 8px)" },
|
|
};
|
|
}
|
|
|
|
return {
|
|
className: "overflow-hidden",
|
|
style: {
|
|
width: `${Math.min(screenDimensions.width, window.innerWidth * 0.98)}px`,
|
|
// CSS가 알아서 처리: 뷰포트 안에 들어가면 auto-height, 넘치면 max-height로 제한
|
|
maxHeight: "calc(100dvh - 8px)",
|
|
maxWidth: "98vw",
|
|
padding: 0,
|
|
gap: 0,
|
|
},
|
|
};
|
|
};
|
|
|
|
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`}
|
|
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 overflow-auto"
|
|
>
|
|
{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`,
|
|
// 🆕 조건부 레이어 활성화 시 높이 자동 확장
|
|
minHeight: `${screenDimensions?.height || 600}px`,
|
|
height: (() => {
|
|
const baseHeight = screenDimensions?.height || 600;
|
|
if (activeConditionalComponents.length > 0) {
|
|
const offsetY = screenDimensions?.offsetY || 0;
|
|
let maxBottom = 0;
|
|
activeConditionalComponents.forEach((comp: any) => {
|
|
const y = parseFloat(comp.position?.y?.toString() || "0") - offsetY;
|
|
const h = parseFloat(comp.size?.height?.toString() || "40");
|
|
maxBottom = Math.max(maxBottom, y + h);
|
|
});
|
|
return `${Math.max(baseHeight, maxBottom + 20)}px`;
|
|
}
|
|
return `${baseHeight}px`;
|
|
})(),
|
|
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) => {
|
|
// 사용자가 실제로 데이터를 변경한 것으로 표시
|
|
formDataChangedRef.current = true;
|
|
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}
|
|
/>
|
|
);
|
|
});
|
|
})()}
|
|
|
|
{/* 🆕 조건부 레이어 컴포넌트 렌더링 */}
|
|
{activeConditionalComponents.map((component: any) => {
|
|
const offsetX = screenDimensions?.offsetX || 0;
|
|
const offsetY = screenDimensions?.offsetY || 0;
|
|
|
|
const adjustedComponent = {
|
|
...component,
|
|
position: {
|
|
...component.position,
|
|
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
|
|
y: parseFloat(component.position?.y?.toString() || "0") - offsetY,
|
|
},
|
|
};
|
|
|
|
return (
|
|
<InteractiveScreenViewerDynamic
|
|
key={`conditional-${component.id}-${resetKey}`}
|
|
component={adjustedComponent}
|
|
allComponents={[...(screenData?.components || []), ...activeConditionalComponents]}
|
|
formData={formData}
|
|
originalData={originalData}
|
|
onFormDataChange={(fieldName, value) => {
|
|
formDataChangedRef.current = true;
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
[fieldName]: value,
|
|
}));
|
|
}}
|
|
onRefresh={() => {
|
|
window.dispatchEvent(new CustomEvent("refreshTable"));
|
|
}}
|
|
screenInfo={{
|
|
id: modalState.screenId!,
|
|
tableName: screenData?.screenInfo?.tableName,
|
|
}}
|
|
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 />
|
|
계속 작업하시려면 '계속 작업' 버튼을 눌러주세요.
|
|
</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;
|