diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 7276f5b0..8dc36db1 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -206,6 +206,16 @@ button:disabled { backdrop-filter: none !important; } +/* ===== Dialog 열릴 때 사이드바/탭바/컨텐츠 상호작용 유지 ===== */ +/* Radix Dialog가 body에 pointer-events:none을 걸어도 네비게이션 영역과 메인 컨텐츠는 클릭 가능 */ +/* 비활성 탭에 모달이 열려있어도 활성 탭 컨텐츠를 조작할 수 있도록 main 포함 */ +[data-sidebar], +[data-tabbar], +main { + pointer-events: auto !important; +} + + /* ===== Accessibility - Focus Styles ===== */ /* 모든 인터랙티브 요소에 대한 포커스 스타일 */ button:focus-visible, diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 16dd5afc..bd959452 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -25,6 +25,7 @@ import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; import { ConditionalZone, LayerDefinition } from "@/types/screen-management"; +import { DialogPortalContainerContext } from "@/contexts/DialogPortalContext"; interface ScreenModalState { isOpen: boolean; @@ -34,6 +35,20 @@ interface ScreenModalState { size: "sm" | "md" | "lg" | "xl"; } +// TSP (Tab State Persistence) - 탭별 모달 상태 독립 유지 +const MODAL_TSP_PREFIX = "tsp-screen-modal-"; +const getModalTspKey = (tabId: string) => `${MODAL_TSP_PREFIX}${tabId}`; + +interface PersistedModalData { + modalState: ScreenModalState; + formData: Record; + originalData: Record | null; + selectedData: Record[]; + modalOpenTabId: string | null; + continuousMode: boolean; + formDataChanged: boolean; +} + interface ScreenModalProps { className?: string; } @@ -147,6 +162,158 @@ export const ScreenModal: React.FC = ({ className }) => { // 모달이 열린 시간 추적 (저장 성공 이벤트 무시용) const modalOpenedAtRef = React.useRef(0); + // 탭별 모달 상태 독립 관리 + const modalOpenTabIdRef = useRef(null); + const [isTabHidden, setIsTabHidden] = useState(false); + const isTabHiddenRef = useRef(false); + isTabHiddenRef.current = isTabHidden; + + // 탭별 모달 상태 인메모리 캐시 (탭 전환 시 각 탭의 모달을 독립적으로 유지) + const tabModalCacheRef = useRef>(new Map()); + + // ScreenModal 전용 포탈 컨테이너 - Dialog/AlertDialog가 이 안으로 포탈됨 + const [portalContainer, setPortalContainer] = useState(null); + + // isTabHidden 변경 시 포탈 컨테이너의 display를 직접 제어 + useEffect(() => { + if (portalContainer) { + portalContainer.style.display = isTabHidden ? "none" : ""; + } + }, [isTabHidden, portalContainer]); + + // 탭 전환 시 모달 상태 스왑: 현재 탭 → 캐시 저장, 새 탭 → 캐시 복원 + useEffect(() => { + const handleTabSwitch = (e: Event) => { + const { tabId: newTabId } = (e as CustomEvent).detail; + const s = latestTspRef.current; + const currentTabId = modalOpenTabIdRef.current; + + // 같은 탭이면: 숨겨진 모달이 있으면 다시 보여주고 종료 + if (currentTabId === newTabId) { + if (isTabHiddenRef.current && s.modalState.isOpen) { + setIsTabHidden(false); + } + return; + } + + // 현재 모달이 열려있으면 현재 탭 캐시에 저장 + if (s.modalState.isOpen && currentTabId) { + const cached: PersistedModalData = { + modalState: s.modalState, + formData: s.formData, + originalData: s.originalData, + selectedData: s.selectedData, + modalOpenTabId: currentTabId, + continuousMode: s.continuousMode, + formDataChanged: formDataChangedRef.current, + }; + tabModalCacheRef.current.set(currentTabId, cached); + try { sessionStorage.setItem(getModalTspKey(currentTabId), JSON.stringify(cached)); } catch {} + } + + // 새 탭에 캐시된 모달이 있으면 복원 + const newTabCache = tabModalCacheRef.current.get(newTabId); + if (newTabCache && newTabCache.modalState?.isOpen) { + isRestoringRef.current = true; + setModalState(newTabCache.modalState); + setFormData(newTabCache.formData || {}); + setOriginalData(newTabCache.originalData || null); + setSelectedData(newTabCache.selectedData || []); + setContinuousMode(newTabCache.continuousMode || false); + modalOpenTabIdRef.current = newTabId; + formDataChangedRef.current = newTabCache.formDataChanged || false; + setIsTabHidden(false); + } else if (s.modalState.isOpen) { + // 새 탭에 모달이 없으면 현재 모달 숨김 + setIsTabHidden(true); + } + }; + window.addEventListener("tabSwitch", handleTabSwitch); + return () => window.removeEventListener("tabSwitch", handleTabSwitch); + }, []); + + // ─── TSP: latestTspRef (최신 상태 참조 — 이벤트 핸들러/beforeunload에서 사용) ─── + const latestTspRef = useRef({ modalState, formData, originalData, selectedData, continuousMode }); + latestTspRef.current = { modalState, formData, originalData, selectedData, continuousMode }; + + const isRestoringRef = useRef(false); + + // ─── TSP: 새로고침 후 비활성 탭 모달만 복원 ─── + useEffect(() => { + // __activeTabId가 설정된 후 실행 (TabContext 초기화 대기) + setTimeout(() => { + const currentTabId = (window as any).__activeTabId; + + for (let i = sessionStorage.length - 1; i >= 0; i--) { + const key = sessionStorage.key(i); + if (!key?.startsWith(MODAL_TSP_PREFIX)) continue; + try { + const data: PersistedModalData = JSON.parse(sessionStorage.getItem(key)!); + if (!data.modalOpenTabId || !data.modalState?.isOpen) continue; + + if (data.modalOpenTabId === currentTabId) { + // 활성 탭 모달은 삭제 (새로고침 = 현재 화면 초기화) + sessionStorage.removeItem(key); + } else { + // 비활성 탭 모달만 캐시에 로드 + tabModalCacheRef.current.set(data.modalOpenTabId, data); + } + } catch { + sessionStorage.removeItem(key!); + } + } + }, 0); + }, []); + + // ─── TSP: 상태 변경 시 현재 탭의 sessionStorage에 저장 (300ms 디바운스) ─── + useEffect(() => { + const tabId = modalOpenTabIdRef.current; + if (!modalState.isOpen || !tabId) return; + + const timer = setTimeout(() => { + try { + const data: PersistedModalData = { + modalState, + formData, + originalData, + selectedData, + modalOpenTabId: tabId, + continuousMode, + formDataChanged: formDataChangedRef.current, + }; + sessionStorage.setItem(getModalTspKey(tabId), JSON.stringify(data)); + tabModalCacheRef.current.set(tabId, data); + } catch { /* sessionStorage 용량 초과 무시 */ } + }, 300); + + return () => clearTimeout(timer); + }, [modalState, formData, originalData, selectedData, continuousMode]); + + // ─── TSP: beforeunload 시 즉시 저장 (디바운스 대기 중인 데이터 유실 방지) ─── + useEffect(() => { + const flushTsp = () => { + const s = latestTspRef.current; + const tabId = modalOpenTabIdRef.current; + if (!s.modalState.isOpen || !tabId) return; + try { + const data: PersistedModalData = { + ...s, + modalOpenTabId: tabId, + formDataChanged: formDataChangedRef.current, + }; + sessionStorage.setItem(getModalTspKey(tabId), JSON.stringify(data)); + } catch { /* 무시 */ } + + // 다른 탭의 캐시된 모달도 flush + tabModalCacheRef.current.forEach((cached, cachedTabId) => { + if (cachedTabId === tabId) return; + try { sessionStorage.setItem(getModalTspKey(cachedTabId), JSON.stringify(cached)); } catch {} + }); + }; + window.addEventListener("beforeunload", flushTsp); + return () => window.removeEventListener("beforeunload", flushTsp); + }, []); + // 🆕 채번 필드 수동 입력 값 변경 이벤트 리스너 useEffect(() => { const handleNumberingValueChanged = (event: CustomEvent) => { @@ -192,6 +359,28 @@ export const ScreenModal: React.FC = ({ className }) => { // 🆕 모달 열린 시간 기록 modalOpenedAtRef.current = Date.now(); + const newTabId = (window as any).__activeTabId || null; + const oldTabId = modalOpenTabIdRef.current; + + // 다른 탭에서 이미 모달이 열려있으면 현재 상태를 해당 탭 캐시에 저장 + if (oldTabId && oldTabId !== newTabId && latestTspRef.current.modalState.isOpen) { + const cached: PersistedModalData = { + modalState: latestTspRef.current.modalState, + formData: latestTspRef.current.formData, + originalData: latestTspRef.current.originalData, + selectedData: latestTspRef.current.selectedData, + modalOpenTabId: oldTabId, + continuousMode: latestTspRef.current.continuousMode, + formDataChanged: formDataChangedRef.current, + }; + tabModalCacheRef.current.set(oldTabId, cached); + try { sessionStorage.setItem(getModalTspKey(oldTabId), JSON.stringify(cached)); } catch {} + } + + // 모달이 열린 탭 ID 기록 + modalOpenTabIdRef.current = newTabId; + setIsTabHidden(false); + // 폼 변경 추적 초기화 formDataChangedRef.current = false; @@ -402,6 +591,13 @@ export const ScreenModal: React.FC = ({ className }) => { setSelectedData([]); // 🆕 선택된 데이터 초기화 setContinuousMode(false); localStorage.setItem("screenModal_continuousMode", "false"); + const closingTabId = modalOpenTabIdRef.current; + modalOpenTabIdRef.current = null; + setIsTabHidden(false); + if (closingTabId) { + tabModalCacheRef.current.delete(closingTabId); + try { sessionStorage.removeItem(getModalTspKey(closingTabId)); } catch {} + } }; // 저장 성공 이벤트 처리 (연속 등록 모드 지원) @@ -475,16 +671,15 @@ export const ScreenModal: React.FC = ({ className }) => { layoutData = await screenApi.getLayout(screenId); } - // 🆕 URL 파라미터 확인 (수정 모드) - if (typeof window !== "undefined") { + // TSP 복원 중이면 URL 파라미터 기반 데이터 fetch 스킵 (이미 복원된 formData 유지) + if (typeof window !== "undefined" && !isRestoringRef.current) { 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 컬럼명 + const primaryKeyColumn = urlParams.get("primaryKeyColumn"); - // 수정 모드이고 editId가 있으면 해당 레코드 조회 if (mode === "edit" && editId && tableName) { try { // groupByColumns 파싱 @@ -556,6 +751,9 @@ export const ScreenModal: React.FC = ({ className }) => { } } + // TSP 복원 플래그 해제 + isRestoringRef.current = false; + // screenApi는 직접 데이터를 반환하므로 .success 체크 불필요 if (screenInfo && layoutData) { const components = layoutData.components || []; @@ -882,6 +1080,11 @@ export const ScreenModal: React.FC = ({ className }) => { setConditionalLayers([]); // 🆕 조건부 레이어 초기화 setContinuousMode(false); localStorage.setItem("screenModal_continuousMode", "false"); + const closingTabId = modalOpenTabIdRef.current; + if (closingTabId) { + tabModalCacheRef.current.delete(closingTabId); + try { sessionStorage.removeItem(getModalTspKey(closingTabId)); } catch {} + } }; // 기존 handleClose를 유지 (이벤트 핸들러 등에서 사용) @@ -889,28 +1092,31 @@ export const ScreenModal: React.FC = ({ className }) => { // 모달 크기 설정 - 화면관리 설정 크기 + 헤더/푸터 const getModalStyle = () => { + const overlayLeft = parseFloat( + getComputedStyle(document.documentElement).getPropertyValue("--dialog-overlay-left") || "0" + ); + const overlayTop = parseFloat( + getComputedStyle(document.documentElement).getPropertyValue("--dialog-overlay-top") || "0" + ); + const availableWidth = window.innerWidth - overlayLeft; + const availableMaxW = `calc(100vw - ${overlayLeft}px - 32px)`; + const availableMaxH = `calc(100dvh - ${overlayTop}px - 16px)`; + if (!screenDimensions) { - console.log("⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용"); return { className: "w-fit min-w-[400px] max-w-4xl overflow-hidden", - style: { padding: 0, gap: 0, maxHeight: "calc(100dvh - 8px)" }, + style: { padding: 0, gap: 0, maxHeight: availableMaxH, maxWidth: availableMaxW }, }; } - const finalWidth = Math.min(screenDimensions.width, window.innerWidth * 0.98); - console.log("✅ [ScreenModal] getModalStyle: 해상도 적용됨", { - screenDimensions, - finalWidth: `${finalWidth}px`, - viewportWidth: window.innerWidth, - }); + const finalWidth = Math.min(screenDimensions.width, availableWidth * 0.98); return { className: "overflow-hidden", style: { width: `${finalWidth}px`, - // CSS가 알아서 처리: 뷰포트 안에 들어가면 auto-height, 넘치면 max-height로 제한 - maxHeight: "calc(100dvh - 8px)", - maxWidth: "98vw", + maxHeight: availableMaxH, + maxWidth: availableMaxW, padding: 0, gap: 0, }, @@ -979,11 +1185,13 @@ export const ScreenModal: React.FC = ({ className }) => { ]); return ( + <> +
+ { - // X 버튼 클릭 시에도 확인 다이얼로그 표시 - if (!open) { + if (!open && !isTabHidden) { handleCloseAttempt(); } }} @@ -991,14 +1199,14 @@ export const ScreenModal: React.FC = ({ className }) => { { e.preventDefault(); + if (isTabHidden) return; handleCloseAttempt(); }} - // ESC 키 누를 때도 바로 닫히지 않도록 방지 onEscapeKeyDown={(e) => { e.preventDefault(); + if (isTabHidden) return; handleCloseAttempt(); }} > @@ -1310,6 +1518,8 @@ export const ScreenModal: React.FC = ({ className }) => { + + ); }; diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index 0437807d..7fbcaa2e 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -302,6 +302,22 @@ function AppLayoutInner({ children }: AppLayoutProps) { return () => window.removeEventListener("resize", checkIsMobile); }, []); + // 모달 오버레이가 사이드바/탭바 영역을 덮지 않도록 CSS 변수 설정 + useEffect(() => { + const root = document.documentElement; + if (!isMobile) { + root.style.setProperty("--dialog-overlay-left", "200px"); + root.style.setProperty("--dialog-overlay-top", "36px"); + } else { + root.style.removeProperty("--dialog-overlay-left"); + root.style.removeProperty("--dialog-overlay-top"); + } + return () => { + root.style.removeProperty("--dialog-overlay-left"); + root.style.removeProperty("--dialog-overlay-top"); + }; + }, [isMobile]); + // 프로필 관련 로직 const { isModalOpen, @@ -596,6 +612,7 @@ function AppLayoutInner({ children }: AppLayoutProps) { {/* 왼쪽 사이드바 */}