459 lines
17 KiB
TypeScript
459 lines
17 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";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
|
|
interface EmbeddedScreenProps {
|
|
embedding: ScreenEmbedding;
|
|
onSelectionChanged?: (selectedRows: any[]) => void;
|
|
position?: SplitPanelPosition; // 분할 패널 내 위치 (left/right)
|
|
initialFormData?: Record<string, any>; // 🆕 수정 모드에서 전달되는 초기 데이터
|
|
}
|
|
|
|
/**
|
|
* 임베드된 화면 컴포넌트
|
|
*/
|
|
export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenProps>(
|
|
({ embedding, onSelectionChanged, position, initialFormData }, 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>>(initialFormData || {}); // 🆕 초기 데이터로 시작
|
|
const [formDataVersion, setFormDataVersion] = useState(0); // 🆕 폼 데이터 버전 (강제 리렌더링용)
|
|
|
|
// 컴포넌트 참조 맵
|
|
const componentRefs = useRef<Map<string, DataReceivable>>(new Map());
|
|
|
|
// 분할 패널 컨텍스트 (분할 패널 내부에 있을 때만 사용)
|
|
const splitPanelContext = useSplitPanelContext();
|
|
|
|
// 🆕 selectedLeftData 참조 안정화 (실제 값이 바뀔 때만 업데이트)
|
|
const selectedLeftData = splitPanelContext?.selectedLeftData;
|
|
const prevSelectedLeftDataRef = useRef<string>("");
|
|
|
|
// 🆕 사용자 정보 가져오기 (저장 액션에 필요)
|
|
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) => {
|
|
console.log("📝 [EmbeddedScreen] 필드 값 변경:", { fieldName, value });
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
[fieldName]: value,
|
|
}));
|
|
}, []);
|
|
|
|
// 화면 데이터 로드
|
|
useEffect(() => {
|
|
loadScreenData();
|
|
}, [embedding.childScreenId]);
|
|
|
|
// 🆕 initialFormData 변경 시 formData 업데이트 (수정 모드)
|
|
useEffect(() => {
|
|
if (initialFormData && Object.keys(initialFormData).length > 0) {
|
|
console.log("📝 [EmbeddedScreen] 초기 폼 데이터 설정:", initialFormData);
|
|
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<string, any> = {};
|
|
|
|
// 먼저 모든 컬럼을 빈 문자열로 초기화
|
|
allColumnNames.forEach((colName) => {
|
|
initializedFormData[colName] = "";
|
|
});
|
|
|
|
// selectedLeftData가 있으면 해당 값으로 덮어쓰기
|
|
if (selectedLeftData && Object.keys(selectedLeftData).length > 0) {
|
|
Object.keys(selectedLeftData).forEach((key) => {
|
|
// null/undefined는 빈 문자열로, 나머지는 그대로
|
|
initializedFormData[key] = selectedLeftData[key] ?? "";
|
|
});
|
|
}
|
|
|
|
console.log("🔗 [EmbeddedScreen] 우측 폼 데이터 교체:", {
|
|
allColumnNames,
|
|
selectedLeftDataKeys: selectedLeftData ? Object.keys(selectedLeftData) : [],
|
|
initializedFormDataKeys: Object.keys(initializedFormData),
|
|
});
|
|
|
|
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);
|
|
console.log("📋 [EmbeddedScreen] 화면 정보 API 응답:", {
|
|
screenId: embedding.childScreenId,
|
|
hasData: !!screenData,
|
|
tableName: screenData?.tableName,
|
|
screenName: screenData?.name || screenData?.screenName,
|
|
position,
|
|
});
|
|
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<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 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="relative w-full"
|
|
style={{
|
|
minHeight: contentBounds.height + 20, // 여유 공간 추가
|
|
}}
|
|
>
|
|
{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 (
|
|
<div key={`${component.id}-${formDataVersion}`} className="absolute" style={componentStyle}>
|
|
<DynamicComponentRenderer
|
|
component={component}
|
|
isInteractive={true}
|
|
screenId={embedding.childScreenId}
|
|
tableName={screenInfo?.tableName}
|
|
formData={formData}
|
|
onFormDataChange={handleFieldChange}
|
|
onSelectionChange={embedding.mode === "select" ? handleSelectionChange : undefined}
|
|
userId={userId}
|
|
userName={userName}
|
|
companyCode={companyCode}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ScreenContextProvider>
|
|
);
|
|
},
|
|
);
|
|
|
|
EmbeddedScreen.displayName = "EmbeddedScreen";
|