diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx index 7e0986a3..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); // 외부 업체 역할 체크 @@ -61,18 +62,16 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) console.log("=== 사용자 권한 그룹 조회 ==="); console.log("API 응답:", response); console.log("찾는 역할:", EXTERNAL_VENDOR_ROLE); - + if (response.success && response.data) { console.log("권한 그룹 목록:", response.data); - + // 사용자의 권한 그룹 중 LSTHIRA_EXTERNAL_VENDOR가 있는지 확인 - const hasExternalRole = response.data.some( - (group: any) => { - console.log("체크 중인 그룹:", group.authCode, group.authName); - return group.authCode === EXTERNAL_VENDOR_ROLE || group.authName === EXTERNAL_VENDOR_ROLE; - } - ); - + const hasExternalRole = response.data.some((group: any) => { + console.log("체크 중인 그룹:", group.authCode, group.authName); + return group.authCode === EXTERNAL_VENDOR_ROLE || group.authName === EXTERNAL_VENDOR_ROLE; + }); + console.log("외부 업체 역할 보유:", hasExternalRole); setIsExternalMode(hasExternalRole); } @@ -101,12 +100,12 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) const handleFullscreenChange = () => { const isNowFullscreen = !!document.fullscreenElement; setIsFullscreen(isNowFullscreen); - + // 전체화면 종료 시 레이아웃 강제 리렌더링 if (!isNowFullscreen) { setTimeout(() => { - setLayoutKey(prev => prev + 1); - window.dispatchEvent(new Event('resize')); + setLayoutKey((prev) => prev + 1); + window.dispatchEvent(new Event("resize")); }, 50); } }; @@ -216,6 +215,8 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) }), ); } + // 마지막 갱신 시간 기록 + setLastRefreshedAt(new Date()); } else { throw new Error(response.error || "레이아웃 조회 실패"); } @@ -252,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) { @@ -407,9 +557,14 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)

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

-

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

+
+

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

+ {lastRefreshedAt && ( + + 마지막 갱신: {lastRefreshedAt.toLocaleTimeString("ko-KR")} + + )} +
{/* 전체 화면 버튼 - 외부 업체 모드에서만 표시 */} @@ -420,11 +575,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) onClick={toggleFullscreen} title={isFullscreen ? "전체 화면 종료" : "전체 화면"} > - {isFullscreen ? ( - - ) : ( - - )} + {isFullscreen ? : } {isFullscreen ? "종료" : "전체 화면"} )} @@ -445,234 +596,234 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
{/* 좌측: 검색/필터 - 외부 모드에서는 숨김 */} {!isExternalMode && ( -
-
- {/* 검색 */} -
- -
- - setSearchQuery(e.target.value)} - placeholder="이름, Area, Location 검색..." - className="h-10 pl-9 text-sm" - /> - {searchQuery && ( - - )} +
+
+ {/* 검색 */} +
+ +
+ + setSearchQuery(e.target.value)} + placeholder="이름, Area, Location 검색..." + className="h-10 pl-9 text-sm" + /> + {searchQuery && ( + + )} +
+ + {/* 타입 필터 */} +
+ + +
+ + {/* 필터 초기화 */} + {(searchQuery || filterType !== "all") && ( + + )}
- {/* 타입 필터 */} -
- - -
+ {/* 객체 목록 */} +
+ + {filteredObjects.length === 0 ? ( +
+ {searchQuery ? "검색 결과가 없습니다" : "객체가 없습니다"} +
+ ) : ( + (() => { + // Area 객체가 있는 경우 계층 트리 아코디언 적용 + const areaObjects = filteredObjects.filter((obj) => obj.type === "area"); - {/* 필터 초기화 */} - {(searchQuery || filterType !== "all") && ( - - )} -
+ // Area가 없으면 기존 평면 리스트 유지 + if (areaObjects.length === 0) { + return ( +
+ {filteredObjects.map((obj) => { + let typeLabel = obj.type; + if (obj.type === "location-bed") typeLabel = "베드(BED)"; + else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)"; + else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)"; + else if (obj.type === "location-dest") typeLabel = "지정착지(DES)"; + else if (obj.type === "crane-mobile") typeLabel = "크레인"; + else if (obj.type === "area") typeLabel = "Area"; + else if (obj.type === "rack") typeLabel = "랙"; - {/* 객체 목록 */} -
- - {filteredObjects.length === 0 ? ( -
- {searchQuery ? "검색 결과가 없습니다" : "객체가 없습니다"} -
- ) : ( - (() => { - // Area 객체가 있는 경우 계층 트리 아코디언 적용 - const areaObjects = filteredObjects.filter((obj) => obj.type === "area"); - - // Area가 없으면 기존 평면 리스트 유지 - if (areaObjects.length === 0) { - return ( -
- {filteredObjects.map((obj) => { - let typeLabel = obj.type; - if (obj.type === "location-bed") typeLabel = "베드(BED)"; - else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)"; - else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)"; - else if (obj.type === "location-dest") typeLabel = "지정착지(DES)"; - else if (obj.type === "crane-mobile") typeLabel = "크레인"; - else if (obj.type === "area") typeLabel = "Area"; - else if (obj.type === "rack") typeLabel = "랙"; - - return ( -
handleObjectClick(obj.id)} - className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${ - selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm" - }`} - > -
-
-

{obj.name}

-
- - {typeLabel} + return ( +
handleObjectClick(obj.id)} + className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${ + selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm" + }`} + > +
+
+

{obj.name}

+
+ + {typeLabel} +
+
+ {obj.areaKey && ( +

+ Area: {obj.areaKey} +

+ )} + {obj.locaKey && ( +

+ Location: {obj.locaKey} +

+ )} + {obj.materialCount !== undefined && obj.materialCount > 0 && ( +

+ 자재: {obj.materialCount}개 +

+ )} +
-
- {obj.areaKey && ( -

- Area: {obj.areaKey} -

+ ); + })} +
+ ); + } + + // Area가 있는 경우: Area → Location 계층 아코디언 + return ( + + {areaObjects.map((areaObj) => { + const childLocations = filteredObjects.filter( + (obj) => + obj.type !== "area" && + obj.areaKey === areaObj.areaKey && + (obj.parentId === areaObj.id || obj.externalKey === areaObj.externalKey), + ); + + return ( + + +
{ + e.stopPropagation(); + handleObjectClick(areaObj.id); + }} + > +
+ + {areaObj.name} +
+
+ ({childLocations.length}) + +
+
+
+ + {childLocations.length === 0 ? ( +

Location이 없습니다

+ ) : ( +
+ {childLocations.map((locationObj) => ( +
handleObjectClick(locationObj.id)} + className={`cursor-pointer rounded-lg border p-2 transition-all ${ + selectedObject?.id === locationObj.id + ? "border-primary bg-primary/10" + : "hover:border-primary/50" + }`} + > +
+
+ {locationObj.type === "location-stp" ? ( + + ) : ( + + )} + {locationObj.name} +
+ +
+

+ 위치: ({locationObj.position.x.toFixed(1)}, {locationObj.position.z.toFixed(1)}) +

+ {locationObj.locaKey && ( +

+ Location: {locationObj.locaKey} +

+ )} + {locationObj.materialCount !== undefined && locationObj.materialCount > 0 && ( +

+ 자재: {locationObj.materialCount}개 +

+ )} +
+ ))} +
)} - {obj.locaKey && ( -

- Location: {obj.locaKey} -

- )} - {obj.materialCount !== undefined && obj.materialCount > 0 && ( -

- 자재: {obj.materialCount}개 -

- )} -
-
+ + ); })} -
+ ); - } - - // Area가 있는 경우: Area → Location 계층 아코디언 - return ( - - {areaObjects.map((areaObj) => { - const childLocations = filteredObjects.filter( - (obj) => - obj.type !== "area" && - obj.areaKey === areaObj.areaKey && - (obj.parentId === areaObj.id || obj.externalKey === areaObj.externalKey), - ); - - return ( - - -
{ - e.stopPropagation(); - handleObjectClick(areaObj.id); - }} - > -
- - {areaObj.name} -
-
- ({childLocations.length}) - -
-
-
- - {childLocations.length === 0 ? ( -

Location이 없습니다

- ) : ( -
- {childLocations.map((locationObj) => ( -
handleObjectClick(locationObj.id)} - className={`cursor-pointer rounded-lg border p-2 transition-all ${ - selectedObject?.id === locationObj.id - ? "border-primary bg-primary/10" - : "hover:border-primary/50" - }`} - > -
-
- {locationObj.type === "location-stp" ? ( - - ) : ( - - )} - {locationObj.name} -
- -
-

- 위치: ({locationObj.position.x.toFixed(1)}, {locationObj.position.z.toFixed(1)}) -

- {locationObj.locaKey && ( -

- Location: {locationObj.locaKey} -

- )} - {locationObj.materialCount !== undefined && locationObj.materialCount > 0 && ( -

- 자재: {locationObj.materialCount}개 -

- )} -
- ))} -
- )} -
-
- ); - })} -
- ); - })() - )} + })() + )} +
-
)} {/* 중앙 + 우측 컨테이너 (전체화면 시 함께 표시) */} -
@@ -691,107 +842,102 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) {/* 우측: 정보 패널 */}
- {selectedObject ? ( -
-
-

상세 정보

-

{selectedObject.name}

-
- - {/* 기본 정보 */} -
-
- -

{selectedObject.type}

+ {selectedObject ? ( +
+
+

상세 정보

+

{selectedObject.name}

- {selectedObject.areaKey && ( -
- -

{selectedObject.areaKey}

-
- )} - {selectedObject.locaKey && ( -
- -

{selectedObject.locaKey}

-
- )} - {selectedObject.materialCount !== undefined && selectedObject.materialCount > 0 && ( -
- -

{selectedObject.materialCount}개

-
- )} -
- {/* 자재 목록 (Location인 경우) - 테이블 형태 */} - {(selectedObject.type === "location-bed" || - selectedObject.type === "location-stp" || - selectedObject.type === "location-temp" || - selectedObject.type === "location-dest") && ( -
- {loadingMaterials ? ( -
- + {/* 기본 정보 */} +
+
+ +

{selectedObject.type}

+
+ {selectedObject.areaKey && ( +
+ +

{selectedObject.areaKey}

- ) : materials.length === 0 ? ( -
- {externalDbConnectionId ? "자재가 없습니다" : "외부 DB 연결이 설정되지 않았습니다"} + )} + {selectedObject.locaKey && ( +
+ +

{selectedObject.locaKey}

- ) : ( -
- - {/* 테이블 형태로 전체 조회 */} -
- - - - - {(hierarchyConfig?.material?.displayColumns || []).map((colConfig: any) => ( - - ))} - - - - {materials.map((material, index) => { - const layerColumn = hierarchyConfig?.material?.layerColumn || "LOLAYER"; - const displayColumns = hierarchyConfig?.material?.displayColumns || []; - return ( - - - {displayColumns.map((colConfig: any) => ( - - ))} - - ); - })} - -
- {colConfig.label} -
- {material[layerColumn]}단 - - {material[colConfig.column] || "-"} -
-
+ )} + {selectedObject.materialCount !== undefined && selectedObject.materialCount > 0 && ( +
+ +

{selectedObject.materialCount}개

)}
- )} -
- ) : ( -
-

객체를 선택하세요

-
- )} + + {/* 자재 목록 (Location인 경우) - 테이블 형태 */} + {(selectedObject.type === "location-bed" || + selectedObject.type === "location-stp" || + selectedObject.type === "location-temp" || + selectedObject.type === "location-dest") && ( +
+ {loadingMaterials ? ( +
+ +
+ ) : materials.length === 0 ? ( +
+ {externalDbConnectionId ? "자재가 없습니다" : "외부 DB 연결이 설정되지 않았습니다"} +
+ ) : ( +
+ + {/* 테이블 형태로 전체 조회 */} +
+ + + + + {(hierarchyConfig?.material?.displayColumns || []).map((colConfig: any) => ( + + ))} + + + + {materials.map((material, index) => { + const layerColumn = hierarchyConfig?.material?.layerColumn || "LOLAYER"; + const displayColumns = hierarchyConfig?.material?.displayColumns || []; + return ( + + + {displayColumns.map((colConfig: any) => ( + + ))} + + ); + })} + +
+ {colConfig.label} +
+ {material[layerColumn]}단 + + {material[colConfig.column] || "-"} +
+
+
+ )} +
+ )} +
+ ) : ( +
+

객체를 선택하세요

+
+ )}
{/* 풀스크린 모드일 때 종료 버튼 */} @@ -800,7 +946,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) variant="outline" size="sm" onClick={toggleFullscreen} - className="absolute top-4 right-4 z-50 bg-background/80 backdrop-blur-sm" + className="bg-background/80 absolute top-4 right-4 z-50 backdrop-blur-sm" > 종료