ERP-node/frontend/components/pop/viewer/PopViewerWithModals.tsx

182 lines
5.7 KiB
TypeScript

/**
* PopViewerWithModals - 뷰어 모드에서 모달 렌더링 래퍼
*
* PopRenderer를 감싸서:
* 1. __pop_modal_open__ 이벤트 구독 → Dialog 열기
* 2. __pop_modal_close__ 이벤트 구독 → Dialog 닫기
* 3. 모달 스택 관리 (중첩 모달 지원)
*
* 모달 내부는 또 다른 PopRenderer로 렌더링 (독립 그리드).
*/
"use client";
import { useState, useCallback, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import PopRenderer from "../designer/renderers/PopRenderer";
import type { PopLayoutDataV5, PopModalDefinition, GridMode } from "../designer/types/pop-layout";
import { detectGridMode, resolveModalWidth } from "../designer/types/pop-layout";
import { usePopEvent } from "@/hooks/pop/usePopEvent";
import { useConnectionResolver } from "@/hooks/pop/useConnectionResolver";
// ========================================
// 타입
// ========================================
interface PopViewerWithModalsProps {
/** 전체 레이아웃 (모달 정의 포함) */
layout: PopLayoutDataV5;
/** 뷰포트 너비 */
viewportWidth: number;
/** 화면 ID (이벤트 버스용) */
screenId: string;
/** 현재 그리드 모드 (PopRenderer 전달용) */
currentMode?: GridMode;
/** Gap 오버라이드 */
overrideGap?: number;
/** Padding 오버라이드 */
overridePadding?: number;
}
/** 열린 모달 상태 */
interface OpenModal {
definition: PopModalDefinition;
}
// ========================================
// 메인 컴포넌트
// ========================================
export default function PopViewerWithModals({
layout,
viewportWidth,
screenId,
currentMode,
overrideGap,
overridePadding,
}: PopViewerWithModalsProps) {
const [modalStack, setModalStack] = useState<OpenModal[]>([]);
const { subscribe } = usePopEvent(screenId);
// 연결 해석기: layout에 정의된 connections를 이벤트 라우팅으로 변환
useConnectionResolver({
screenId,
connections: layout.dataFlow?.connections || [],
});
// 모달 열기 이벤트 구독
useEffect(() => {
const unsubOpen = subscribe("__pop_modal_open__", (payload: unknown) => {
const data = payload as {
modalId?: string;
title?: string;
mode?: string;
};
// fullscreen 모달: layout.modals에서 정의 찾기
if (data?.modalId) {
const modalDef = layout.modals?.find(m => m.id === data.modalId);
if (modalDef) {
setModalStack(prev => [...prev, { definition: modalDef }]);
}
}
});
const unsubClose = subscribe("__pop_modal_close__", () => {
// 가장 최근 모달 닫기
setModalStack(prev => prev.slice(0, -1));
});
return () => {
unsubOpen();
unsubClose();
};
}, [subscribe, layout.modals]);
// 최상위 모달만 닫기 (X 버튼, overlay 클릭, ESC)
const handleCloseTopModal = useCallback(() => {
setModalStack(prev => prev.slice(0, -1));
}, []);
return (
<>
{/* 메인 화면 렌더링 */}
<PopRenderer
layout={layout}
viewportWidth={viewportWidth}
currentScreenId={Number(screenId) || undefined}
currentMode={currentMode}
isDesignMode={false}
overrideGap={overrideGap}
overridePadding={overridePadding}
/>
{/* 모달 스택 렌더링 */}
{modalStack.map((modal, index) => {
const { definition } = modal;
const isTopModal = index === modalStack.length - 1;
const closeOnOverlay = definition.frameConfig?.closeOnOverlay !== false;
const closeOnEsc = definition.frameConfig?.closeOnEsc !== false;
const modalLayout: PopLayoutDataV5 = {
...layout,
gridConfig: definition.gridConfig,
components: definition.components,
overrides: definition.overrides,
};
const detectedMode = currentMode || detectGridMode(viewportWidth);
const modalWidth = resolveModalWidth(definition.sizeConfig, detectedMode, viewportWidth);
const isFull = modalWidth >= viewportWidth;
const rendererWidth = isFull ? viewportWidth : modalWidth - 32;
return (
<Dialog
key={`${definition.id}-${index}`}
open={true}
onOpenChange={(open) => {
if (!open && isTopModal) handleCloseTopModal();
}}
>
<DialogContent
className={isFull
? "h-dvh max-h-dvh w-screen max-w-[100vw] overflow-auto rounded-none border-none p-0"
: "max-h-[90vh] overflow-auto p-0"
}
style={isFull ? undefined : {
maxWidth: `${modalWidth}px`,
width: `${modalWidth}px`,
}}
onInteractOutside={(e) => {
// 최상위 모달이 아니면 overlay 클릭 무시 (하위 모달이 먼저 닫히는 것 방지)
if (!isTopModal || !closeOnOverlay) e.preventDefault();
}}
onEscapeKeyDown={(e) => {
if (!isTopModal || !closeOnEsc) e.preventDefault();
}}
>
<DialogHeader className={isFull ? "px-4 pt-3 pb-2" : "px-4 pt-4 pb-2"}>
<DialogTitle className="text-base">
{definition.title}
</DialogTitle>
</DialogHeader>
<div className={isFull ? "flex-1 overflow-auto" : "px-4 pb-4"}>
<PopRenderer
layout={modalLayout}
viewportWidth={rendererWidth}
currentScreenId={Number(screenId) || undefined}
/>
</div>
</DialogContent>
</Dialog>
);
})}
</>
);
}