954 lines
38 KiB
TypeScript
954 lines
38 KiB
TypeScript
"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 설정 안 함)
|
||
} = 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;
|
||
}
|
||
|
||
// 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) {
|
||
// 연속 모드: 폼만 초기화하고 모달은 유지
|
||
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 />
|
||
계속 작업하시려면 '계속 작업' 버튼을 눌러주세요.
|
||
</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;
|