ERP-node/frontend/components/screen-embedding/EmbeddedScreen.tsx

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";