diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx index 3e7b5471..ae98c795 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx @@ -51,6 +51,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) const [isExternalMode, setIsExternalMode] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const [layoutKey, setLayoutKey] = useState(0); // 레이아웃 강제 리렌더링용 + const [lastRefreshedAt, setLastRefreshedAt] = useState(null); // 마지막 갱신 시간 const canvasContainerRef = useRef(null); // 외부 업체 역할 체크 @@ -214,6 +215,8 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) }), ); } + // 마지막 갱신 시간 기록 + setLastRefreshedAt(new Date()); } else { throw new Error(response.error || "레이아웃 조회 실패"); } @@ -250,6 +253,155 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) // eslint-disable-next-line react-hooks/exhaustive-deps }, [layoutId]); + // 10초 주기 자동 갱신 (중앙 관제 화면 자동 새로고침) + useEffect(() => { + const AUTO_REFRESH_INTERVAL = 10000; // 10초 + + const silentRefresh = async () => { + // 로딩 중이거나 새로고침 중이면 스킵 + if (isLoading || isRefreshing) return; + + try { + // 레이아웃 데이터 조용히 갱신 + const response = await getLayoutById(layoutId); + + if (response.success && response.data) { + const { layout, objects } = response.data; + const dbConnectionId = layout.external_db_connection_id || layout.externalDbConnectionId; + + // hierarchy_config 파싱 + let hierarchyConfigData: any = null; + if (layout.hierarchy_config) { + hierarchyConfigData = + typeof layout.hierarchy_config === "string" + ? JSON.parse(layout.hierarchy_config) + : layout.hierarchy_config; + setHierarchyConfig(hierarchyConfigData); + } + + // 객체 데이터 변환 + const loadedObjects: PlacedObject[] = objects.map((obj: any) => { + const objectType = obj.object_type; + return { + id: obj.id, + type: objectType, + name: obj.object_name, + position: { + x: parseFloat(obj.position_x), + y: parseFloat(obj.position_y), + z: parseFloat(obj.position_z), + }, + size: { + x: parseFloat(obj.size_x), + y: parseFloat(obj.size_y), + z: parseFloat(obj.size_z), + }, + rotation: obj.rotation ? parseFloat(obj.rotation) : 0, + color: getObjectColor(objectType, obj.color), + areaKey: obj.area_key, + locaKey: obj.loca_key, + locType: obj.loc_type, + materialCount: obj.loc_type === "STP" ? undefined : obj.material_count, + materialPreview: + obj.loc_type === "STP" || !obj.material_preview_height + ? undefined + : { height: parseFloat(obj.material_preview_height) }, + parentId: obj.parent_id, + displayOrder: obj.display_order, + locked: obj.locked, + visible: obj.visible !== false, + hierarchyLevel: obj.hierarchy_level, + parentKey: obj.parent_key, + externalKey: obj.external_key, + }; + }); + + // 외부 DB 연결이 있고 자재 설정이 있으면, 각 Location의 실제 자재 개수 조회 + if (dbConnectionId && hierarchyConfigData?.material) { + const locationObjects = loadedObjects.filter( + (obj) => + (obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") && + obj.locaKey, + ); + + // 각 Location에 대해 자재 개수 조회 (병렬 처리) + const materialCountPromises = locationObjects.map(async (obj) => { + try { + const matResponse = await getMaterials(dbConnectionId, { + tableName: hierarchyConfigData.material.tableName, + keyColumn: hierarchyConfigData.material.keyColumn, + locationKeyColumn: hierarchyConfigData.material.locationKeyColumn, + layerColumn: hierarchyConfigData.material.layerColumn, + locaKey: obj.locaKey!, + }); + if (matResponse.success && matResponse.data) { + return { id: obj.id, count: matResponse.data.length }; + } + } catch { + // 자동 갱신 시에는 에러 로그 생략 + } + return { id: obj.id, count: 0 }; + }); + + const materialCounts = await Promise.all(materialCountPromises); + + // materialCount 업데이트 + const updatedObjects = loadedObjects.map((obj) => { + const countData = materialCounts.find((m) => m.id === obj.id); + if (countData && countData.count > 0) { + return { ...obj, materialCount: countData.count }; + } + return obj; + }); + + setPlacedObjects(updatedObjects); + } else { + setPlacedObjects(loadedObjects); + } + + // 선택된 객체가 있으면 자재 목록도 갱신 + if (selectedObject && dbConnectionId && hierarchyConfigData?.material) { + const currentObj = loadedObjects.find((o) => o.id === selectedObject.id); + if ( + currentObj && + (currentObj.type === "location-bed" || + currentObj.type === "location-temp" || + currentObj.type === "location-dest") && + currentObj.locaKey + ) { + const matResponse = await getMaterials(dbConnectionId, { + tableName: hierarchyConfigData.material.tableName, + keyColumn: hierarchyConfigData.material.keyColumn, + locationKeyColumn: hierarchyConfigData.material.locationKeyColumn, + layerColumn: hierarchyConfigData.material.layerColumn, + locaKey: currentObj.locaKey, + }); + if (matResponse.success && matResponse.data) { + const layerColumn = hierarchyConfigData.material.layerColumn || "LOLAYER"; + const sortedMaterials = matResponse.data.sort( + (a: any, b: any) => (b[layerColumn] || 0) - (a[layerColumn] || 0), + ); + setMaterials(sortedMaterials); + } + } + } + + // 마지막 갱신 시간 기록 + setLastRefreshedAt(new Date()); + } + } catch { + // 자동 갱신 실패 시 조용히 무시 (사용자 경험 방해 안 함) + } + }; + + // 10초마다 자동 갱신 + const intervalId = setInterval(silentRefresh, AUTO_REFRESH_INTERVAL); + + // 컴포넌트 언마운트 시 인터벌 정리 + return () => clearInterval(intervalId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [layoutId, isLoading, isRefreshing, selectedObject]); + // Location의 자재 목록 로드 const loadMaterialsForLocation = async (locaKey: string, externalDbConnectionId: number) => { if (!hierarchyConfig?.material) { @@ -405,7 +557,14 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)

{layoutName || "디지털 트윈 야드"}

-

{isExternalMode ? "야드 관제 화면" : "읽기 전용 뷰"}

+
+

{isExternalMode ? "야드 관제 화면" : "읽기 전용 뷰"}

+ {lastRefreshedAt && ( + + 마지막 갱신: {lastRefreshedAt.toLocaleTimeString("ko-KR")} + + )} +
{/* 전체 화면 버튼 - 외부 업체 모드에서만 표시 */}