/** * 임베드된 화면 컴포넌트 * 다른 화면 안에 임베드되어 표시되는 화면 */ "use client"; import React, { forwardRef, useImperativeHandle, useState, useEffect, useRef, useCallback } from "react"; import type { ScreenEmbedding, DataReceiver, DataReceivable, EmbeddedScreenHandle, DataReceiveMode, } from "@/types/screen-embedding"; import type { ComponentData } from "@/types/screen"; import { logger } from "@/lib/utils/logger"; import { applyMappingRules, filterDataByCondition } from "@/lib/utils/dataMapping"; import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; import { screenApi } from "@/lib/api/screen"; import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext"; import { ScreenContextProvider } from "@/contexts/ScreenContext"; import { useAuth } from "@/hooks/useAuth"; interface EmbeddedScreenProps { embedding: ScreenEmbedding; onSelectionChanged?: (selectedRows: any[]) => void; position?: SplitPanelPosition; // 분할 패널 내 위치 (left/right) initialFormData?: Record; // 🆕 수정 모드에서 전달되는 초기 데이터 } /** * 임베드된 화면 컴포넌트 */ export const EmbeddedScreen = forwardRef( ({ embedding, onSelectionChanged, position, initialFormData }, ref) => { const [layout, setLayout] = useState([]); const [selectedRows, setSelectedRows] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [screenInfo, setScreenInfo] = useState(null); const [formData, setFormData] = useState>(initialFormData || {}); // 🆕 초기 데이터로 시작 const [formDataVersion, setFormDataVersion] = useState(0); // 🆕 폼 데이터 버전 (강제 리렌더링용) // 컴포넌트 참조 맵 const componentRefs = useRef>(new Map()); // 분할 패널 컨텍스트 (분할 패널 내부에 있을 때만 사용) const splitPanelContext = useSplitPanelContext(); // 🆕 selectedLeftData 참조 안정화 (실제 값이 바뀔 때만 업데이트) const selectedLeftData = splitPanelContext?.selectedLeftData; const prevSelectedLeftDataRef = useRef(""); // 🆕 사용자 정보 가져오기 (저장 액션에 필요) const { userId, userName, companyCode } = useAuth(); // 컴포넌트들의 실제 영역 계산 (가로폭 맞춤을 위해) const contentBounds = React.useMemo(() => { if (layout.length === 0) return { width: 0, height: 0 }; let maxRight = 0; let maxBottom = 0; layout.forEach((component) => { const { position: compPosition = { x: 0, y: 0 }, size = { width: 200, height: 40 } } = component; const right = (compPosition.x || 0) + (size.width || 200); const bottom = (compPosition.y || 0) + (size.height || 40); if (right > maxRight) maxRight = right; if (bottom > maxBottom) maxBottom = bottom; }); return { width: maxRight, height: maxBottom }; }, [layout]); // 필드 값 변경 핸들러 const handleFieldChange = useCallback((fieldName: string, value: any) => { setFormData((prev) => ({ ...prev, [fieldName]: value, })); }, []); // 화면 데이터 로드 useEffect(() => { loadScreenData(); }, [embedding.childScreenId]); // initialFormData 변경 시 formData 업데이트 (수정 모드) useEffect(() => { if (initialFormData && Object.keys(initialFormData).length > 0) { setFormData(initialFormData); } }, [initialFormData]); // 🆕 분할 패널에서 좌측 선택 데이터가 변경되면 우측 화면의 formData에 자동 반영 // 🆕 좌측 선택 데이터가 변경되면 우측 formData를 업데이트 useEffect(() => { // 우측 화면인 경우에만 적용 if (position !== "right" || !splitPanelContext) { return; } // 자동 데이터 전달이 비활성화된 경우 스킵 if (splitPanelContext.disableAutoDataTransfer) { return; } // 🆕 값 비교로 실제 변경 여부 확인 (불필요한 리렌더링 방지) const currentDataStr = JSON.stringify(selectedLeftData || {}); if (prevSelectedLeftDataRef.current === currentDataStr) { return; // 실제 값이 같으면 스킵 } prevSelectedLeftDataRef.current = currentDataStr; // 🆕 현재 화면의 모든 컴포넌트에서 columnName 수집 const allColumnNames = layout.filter((comp) => comp.columnName).map((comp) => comp.columnName as string); // 🆕 모든 필드를 빈 값으로 초기화한 후, selectedLeftData로 덮어쓰기 const initializedFormData: Record = {}; // 먼저 모든 컬럼을 빈 문자열로 초기화 allColumnNames.forEach((colName) => { initializedFormData[colName] = ""; }); // selectedLeftData가 있으면 해당 값으로 덮어쓰기 if (selectedLeftData && Object.keys(selectedLeftData).length > 0) { Object.keys(selectedLeftData).forEach((key) => { // null/undefined는 빈 문자열로, 나머지는 그대로 initializedFormData[key] = selectedLeftData[key] ?? ""; }); } setFormData(initializedFormData); setFormDataVersion((v) => v + 1); // 🆕 버전 증가로 컴포넌트 강제 리렌더링 }, [position, splitPanelContext, selectedLeftData, layout]); // 선택 변경 이벤트 전파 useEffect(() => { onSelectionChanged?.(selectedRows); }, [selectedRows, onSelectionChanged]); /** * 화면 레이아웃 로드 */ const loadScreenData = async () => { try { setLoading(true); setError(null); // 화면 정보 로드 (screenApi.getScreen은 직접 ScreenDefinition 객체를 반환) const screenData = await screenApi.getScreen(embedding.childScreenId); if (screenData) { setScreenInfo(screenData); } else { console.warn("⚠️ [EmbeddedScreen] 화면 정보 로드 실패:", { screenId: embedding.childScreenId, }); } // 화면 레이아웃 로드 (별도 API) const layoutData = await screenApi.getLayout(embedding.childScreenId); logger.info("📦 화면 레이아웃 로드 완료", { screenId: embedding.childScreenId, mode: embedding.mode, hasLayoutData: !!layoutData, componentsCount: layoutData?.components?.length || 0, position, }); if (layoutData && layoutData.components && Array.isArray(layoutData.components)) { setLayout(layoutData.components); logger.info("✅ 임베드 화면 컴포넌트 설정 완료", { screenId: embedding.childScreenId, componentsCount: layoutData.components.length, }); } else { logger.warn("⚠️ 화면에 컴포넌트가 없습니다", { screenId: embedding.childScreenId, layoutData, }); setLayout([]); } } catch (err: any) { logger.error("화면 레이아웃 로드 실패", err); setError(err.message || "화면을 불러올 수 없습니다."); } finally { setLoading(false); } }; /** * 컴포넌트 등록 */ const registerComponent = useCallback((id: string, component: DataReceivable) => { componentRefs.current.set(id, component); logger.debug("컴포넌트 등록", { componentId: id, componentType: component.componentType, }); }, []); /** * 컴포넌트 등록 해제 */ const unregisterComponent = useCallback((id: string) => { componentRefs.current.delete(id); logger.debug("컴포넌트 등록 해제", { componentId: id, }); }, []); /** * 선택된 행 업데이트 */ const handleSelectionChange = useCallback((rows: any[]) => { setSelectedRows(rows); }, []); // 외부에서 호출 가능한 메서드 useImperativeHandle(ref, () => ({ /** * 선택된 행 가져오기 */ getSelectedRows: () => { return selectedRows; }, /** * 선택 초기화 */ clearSelection: () => { setSelectedRows([]); }, /** * 데이터 수신 */ receiveData: async (data: any[], receivers: DataReceiver[]) => { logger.info("데이터 수신 시작", { dataCount: data.length, receiversCount: receivers.length, }); const errors: Array<{ componentId: string; error: string }> = []; // 각 데이터 수신자에게 데이터 전달 for (const receiver of receivers) { try { const component = componentRefs.current.get(receiver.targetComponentId); if (!component) { const errorMsg = `컴포넌트를 찾을 수 없습니다: ${receiver.targetComponentId}`; logger.warn(errorMsg); errors.push({ componentId: receiver.targetComponentId, error: errorMsg, }); continue; } // 1. 조건 필터링 let filteredData = data; if (receiver.condition) { filteredData = filterDataByCondition(data, receiver.condition); logger.debug("조건 필터링 적용", { componentId: receiver.targetComponentId, originalCount: data.length, filteredCount: filteredData.length, }); } // 2. 매핑 규칙 적용 const mappedData = applyMappingRules(filteredData, receiver.mappingRules); logger.debug("매핑 규칙 적용", { componentId: receiver.targetComponentId, mappingRulesCount: receiver.mappingRules.length, }); // 3. 검증 if (receiver.validation) { if (receiver.validation.required && mappedData.length === 0) { throw new Error("필수 데이터가 없습니다."); } if (receiver.validation.minRows && mappedData.length < receiver.validation.minRows) { throw new Error(`최소 ${receiver.validation.minRows}개의 데이터가 필요합니다.`); } if (receiver.validation.maxRows && mappedData.length > receiver.validation.maxRows) { throw new Error(`최대 ${receiver.validation.maxRows}개까지만 허용됩니다.`); } } // 4. 데이터 전달 await component.receiveData(mappedData, receiver.mode); logger.info("데이터 전달 성공", { componentId: receiver.targetComponentId, componentType: receiver.targetComponentType, mode: receiver.mode, dataCount: mappedData.length, }); } catch (err: any) { logger.error("데이터 전달 실패", { componentId: receiver.targetComponentId, error: err.message, }); errors.push({ componentId: receiver.targetComponentId, error: err.message, }); } } if (errors.length > 0) { throw new Error(`일부 컴포넌트에 데이터 전달 실패: ${errors.map((e) => e.componentId).join(", ")}`); } }, /** * 현재 데이터 가져오기 */ getData: () => { const allData: Record = {}; componentRefs.current.forEach((component, id) => { allData[id] = component.getData(); }); return allData; }, })); // 로딩 상태 if (loading) { return (

화면을 불러오는 중...

); } // 에러 상태 if (error) { return (

화면을 불러올 수 없습니다

{error}

); } // 화면 렌더링 - 절대 위치 기반 레이아웃 (원본 화면과 동일하게) // position을 ScreenContextProvider에 전달하여 중첩된 화면에서도 위치를 알 수 있게 함 return (
{layout.length === 0 ? (

화면에 컴포넌트가 없습니다.

) : (
{layout.map((component) => { const { position: compPosition = { x: 0, y: 0, z: 1 }, size = { width: 200, height: 40 } } = component; // 컴포넌트가 컨테이너 너비를 초과하지 않도록 너비 조정 // 부모 컨테이너의 100%를 기준으로 계산 const componentStyle: React.CSSProperties = { left: compPosition.x || 0, top: compPosition.y || 0, width: size.width || 200, height: size.height || 40, zIndex: compPosition.z || 1, // 컴포넌트가 오른쪽 경계를 넘어가면 너비 조정 maxWidth: `calc(100% - ${compPosition.x || 0}px)`, }; return (
); })}
)}
); }, ); EmbeddedScreen.displayName = "EmbeddedScreen";