352 lines
12 KiB
TypeScript
352 lines
12 KiB
TypeScript
/**
|
|
* 임베드된 화면 컴포넌트
|
|
* 다른 화면 안에 임베드되어 표시되는 화면
|
|
*/
|
|
|
|
"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";
|
|
|
|
interface EmbeddedScreenProps {
|
|
embedding: ScreenEmbedding;
|
|
onSelectionChanged?: (selectedRows: any[]) => void;
|
|
position?: SplitPanelPosition; // 분할 패널 내 위치 (left/right)
|
|
}
|
|
|
|
/**
|
|
* 임베드된 화면 컴포넌트
|
|
*/
|
|
export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenProps>(
|
|
({ embedding, onSelectionChanged, position }, ref) => {
|
|
const [layout, setLayout] = useState<ComponentData[]>([]);
|
|
const [selectedRows, setSelectedRows] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [screenInfo, setScreenInfo] = useState<any>(null);
|
|
const [formData, setFormData] = useState<Record<string, any>>({}); // 폼 데이터 상태 추가
|
|
|
|
// 컴포넌트 참조 맵
|
|
const componentRefs = useRef<Map<string, DataReceivable>>(new Map());
|
|
|
|
// 분할 패널 컨텍스트 (분할 패널 내부에 있을 때만 사용)
|
|
const splitPanelContext = useSplitPanelContext();
|
|
|
|
// 필드 값 변경 핸들러
|
|
const handleFieldChange = useCallback((fieldName: string, value: any) => {
|
|
console.log("📝 [EmbeddedScreen] 필드 값 변경:", { fieldName, value });
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
[fieldName]: value,
|
|
}));
|
|
}, []);
|
|
|
|
// 화면 데이터 로드
|
|
useEffect(() => {
|
|
loadScreenData();
|
|
}, [embedding.childScreenId]);
|
|
|
|
// 선택 변경 이벤트 전파
|
|
useEffect(() => {
|
|
onSelectionChanged?.(selectedRows);
|
|
}, [selectedRows, onSelectionChanged]);
|
|
|
|
/**
|
|
* 화면 레이아웃 로드
|
|
*/
|
|
const loadScreenData = async () => {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
// 화면 정보 로드
|
|
const screenResponse = await screenApi.getScreen(embedding.childScreenId);
|
|
if (screenResponse.success && screenResponse.data) {
|
|
setScreenInfo(screenResponse.data);
|
|
}
|
|
|
|
// 화면 레이아웃 로드 (별도 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<string, any> = {};
|
|
|
|
componentRefs.current.forEach((component, id) => {
|
|
allData[id] = component.getData();
|
|
});
|
|
|
|
return allData;
|
|
},
|
|
}));
|
|
|
|
// 로딩 상태
|
|
if (loading) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center">
|
|
<div className="flex flex-col items-center gap-4">
|
|
<div className="border-primary h-8 w-8 animate-spin rounded-full border-4 border-t-transparent" />
|
|
<p className="text-muted-foreground text-sm">화면을 불러오는 중...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 에러 상태
|
|
if (error) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center">
|
|
<div className="flex flex-col items-center gap-4 text-center">
|
|
<div className="bg-destructive/10 flex h-12 w-12 items-center justify-center rounded-full">
|
|
<svg className="text-destructive h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-medium">화면을 불러올 수 없습니다</p>
|
|
<p className="text-muted-foreground mt-1 text-xs">{error}</p>
|
|
</div>
|
|
<button onClick={loadScreenData} className="text-primary text-sm hover:underline">
|
|
다시 시도
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 화면 렌더링 - 절대 위치 기반 레이아웃 (원본 화면과 동일하게)
|
|
// position을 ScreenContextProvider에 전달하여 중첩된 화면에서도 위치를 알 수 있게 함
|
|
return (
|
|
<ScreenContextProvider
|
|
screenId={embedding.childScreenId}
|
|
tableName={screenInfo?.tableName}
|
|
splitPanelPosition={position}
|
|
>
|
|
<div className="relative h-full w-full overflow-auto">
|
|
{layout.length === 0 ? (
|
|
<div className="flex h-full items-center justify-center">
|
|
<p className="text-muted-foreground text-sm">화면에 컴포넌트가 없습니다.</p>
|
|
</div>
|
|
) : (
|
|
<div className="relative" style={{ minHeight: "100%", minWidth: "100%" }}>
|
|
{layout.map((component) => {
|
|
const { position: compPosition = { x: 0, y: 0, z: 1 }, size = { width: 200, height: 40 } } = component;
|
|
|
|
return (
|
|
<div
|
|
key={component.id}
|
|
className="absolute"
|
|
style={{
|
|
left: compPosition.x || 0,
|
|
top: compPosition.y || 0,
|
|
width: size.width || 200,
|
|
height: size.height || 40,
|
|
zIndex: compPosition.z || 1,
|
|
}}
|
|
>
|
|
<DynamicComponentRenderer
|
|
component={component}
|
|
isInteractive={true}
|
|
screenId={embedding.childScreenId}
|
|
tableName={screenInfo?.tableName}
|
|
formData={formData}
|
|
onFormDataChange={handleFieldChange}
|
|
onSelectionChange={embedding.mode === "select" ? handleSelectionChange : undefined}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ScreenContextProvider>
|
|
);
|
|
},
|
|
);
|
|
|
|
EmbeddedScreen.displayName = "EmbeddedScreen";
|