303 lines
9.7 KiB
TypeScript
303 lines
9.7 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";
|
|
|
|
interface EmbeddedScreenProps {
|
|
embedding: ScreenEmbedding;
|
|
onSelectionChanged?: (selectedRows: any[]) => void;
|
|
}
|
|
|
|
/**
|
|
* 임베드된 화면 컴포넌트
|
|
*/
|
|
export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenProps>(
|
|
({ embedding, onSelectionChanged }, ref) => {
|
|
const [layout, setLayout] = useState<ComponentData[]>([]);
|
|
const [selectedRows, setSelectedRows] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// 컴포넌트 참조 맵
|
|
const componentRefs = useRef<Map<string, DataReceivable>>(new Map());
|
|
|
|
// 화면 데이터 로드
|
|
useEffect(() => {
|
|
loadScreenData();
|
|
}, [embedding.childScreenId]);
|
|
|
|
// 선택 변경 이벤트 전파
|
|
useEffect(() => {
|
|
onSelectionChanged?.(selectedRows);
|
|
}, [selectedRows, onSelectionChanged]);
|
|
|
|
/**
|
|
* 화면 레이아웃 로드
|
|
*/
|
|
const loadScreenData = async () => {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
// 화면 레이아웃 로드 (별도 API)
|
|
const layoutData = await screenApi.getLayout(embedding.childScreenId);
|
|
|
|
logger.info("📦 화면 레이아웃 로드 완료", {
|
|
screenId: embedding.childScreenId,
|
|
mode: embedding.mode,
|
|
hasLayoutData: !!layoutData,
|
|
componentsCount: layoutData?.components?.length || 0,
|
|
});
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
// 화면 렌더링 - 레이아웃 기반 렌더링
|
|
return (
|
|
<div className="h-full w-full overflow-auto p-4">
|
|
{layout.length === 0 ? (
|
|
<div className="flex h-full items-center justify-center">
|
|
<p className="text-muted-foreground text-sm">화면에 컴포넌트가 없습니다.</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{layout.map((component) => (
|
|
<DynamicComponentRenderer
|
|
key={component.id}
|
|
component={component}
|
|
isInteractive={true}
|
|
screenId={embedding.childScreenId}
|
|
onSelectionChange={embedding.mode === "select" ? handleSelectionChange : undefined}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
);
|
|
|
|
EmbeddedScreen.displayName = "EmbeddedScreen";
|