diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx index 3a4b1901..b99b58af 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx @@ -648,7 +648,14 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi description: `${loadedObjects.length}개의 객체를 불러왔습니다.`, }); - // Location 객체들의 자재 개수 로드 + // Location 객체들의 자재 개수 로드 (직접 dbConnectionId와 materialTableName 전달) + const dbConnectionId = layout.external_db_connection_id; + const hierarchyConfigParsed = + typeof layout.hierarchy_config === "string" + ? JSON.parse(layout.hierarchy_config) + : layout.hierarchy_config; + const materialTableName = hierarchyConfigParsed?.material?.tableName; + const locationObjects = loadedObjects.filter( (obj) => (obj.type === "location-bed" || @@ -657,10 +664,10 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi obj.type === "location-dest") && obj.locaKey, ); - if (locationObjects.length > 0) { + if (locationObjects.length > 0 && dbConnectionId && materialTableName) { const locaKeys = locationObjects.map((obj) => obj.locaKey!); setTimeout(() => { - loadMaterialCountsForLocations(locaKeys); + loadMaterialCountsForLocations(locaKeys, dbConnectionId, materialTableName); }, 100); } } else { @@ -1045,11 +1052,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi }; // Location별 자재 개수 로드 (locaKeys를 직접 받음) - const loadMaterialCountsForLocations = async (locaKeys: string[]) => { - if (!selectedDbConnection || locaKeys.length === 0) return; + const loadMaterialCountsForLocations = async (locaKeys: string[], dbConnectionId?: number, materialTableName?: string) => { + const connectionId = dbConnectionId || selectedDbConnection; + const tableName = materialTableName || selectedTables.material; + if (!connectionId || locaKeys.length === 0) return; try { - const response = await getMaterialCounts(selectedDbConnection, selectedTables.material, locaKeys); + const response = await getMaterialCounts(connectionId, tableName, locaKeys); + console.log("📊 자재 개수 API 응답:", response); + if (response.success && response.data) { // 각 Location 객체에 자재 개수 업데이트 (STP는 자재 미적재이므로 제외) setPlacedObjects((prev) => @@ -1060,13 +1071,23 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi ) { return obj; } - const materialCount = response.data?.find((mc) => mc.LOCAKEY === obj.locaKey); + // 백엔드 응답 필드명: location_key, count (대소문자 모두 체크) + const materialCount = response.data?.find( + (mc: any) => + mc.LOCAKEY === obj.locaKey || + mc.location_key === obj.locaKey || + mc.locakey === obj.locaKey + ); if (materialCount) { + // count 또는 material_count 필드 사용 + const count = materialCount.count || materialCount.material_count || 0; + const maxLayer = materialCount.max_layer || count; + console.log(`📊 ${obj.locaKey}: 자재 ${count}개`); return { ...obj, - materialCount: materialCount.material_count, + materialCount: Number(count), materialPreview: { - height: materialCount.max_layer * 1.5, // 층당 1.5 높이 (시각적) + height: maxLayer * 1.5, // 층당 1.5 높이 (시각적) }, }; } diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx index f2445d50..91804987 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx @@ -54,15 +54,17 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) // 레이아웃 정보 저장 (snake_case와 camelCase 모두 지원) setLayoutName(layout.layout_name || layout.layoutName); - setExternalDbConnectionId(layout.external_db_connection_id || layout.externalDbConnectionId); + const dbConnectionId = layout.external_db_connection_id || layout.externalDbConnectionId; + setExternalDbConnectionId(dbConnectionId); // hierarchy_config 저장 + let hierarchyConfigData: any = null; if (layout.hierarchy_config) { - const config = + hierarchyConfigData = typeof layout.hierarchy_config === "string" ? JSON.parse(layout.hierarchy_config) : layout.hierarchy_config; - setHierarchyConfig(config); + setHierarchyConfig(hierarchyConfigData); } // 객체 데이터 변환 @@ -103,6 +105,47 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) }); setPlacedObjects(loadedObjects); + + // 외부 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 (e) { + console.warn(`자재 개수 조회 실패 (${obj.locaKey}):`, e); + } + return { id: obj.id, count: 0 }; + }); + + const materialCounts = await Promise.all(materialCountPromises); + + // materialCount 업데이트 + setPlacedObjects((prev) => + prev.map((obj) => { + const countData = materialCounts.find((m) => m.id === obj.id); + if (countData && countData.count > 0) { + return { ...obj, materialCount: countData.count }; + } + return obj; + }) + ); + } } else { throw new Error(response.error || "레이아웃 조회 실패"); } diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx index 892acc88..a3b29042 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx @@ -1,7 +1,7 @@ "use client"; import { Canvas, useThree } from "@react-three/fiber"; -import { OrbitControls, Grid, Box, Text } from "@react-three/drei"; +import { OrbitControls, Box, Text } from "@react-three/drei"; import { Suspense, useRef, useState, useEffect, useMemo } from "react"; import * as THREE from "three"; @@ -525,68 +525,77 @@ function MaterialBox({ case "location-bed": case "location-temp": case "location-dest": - // 베드 타입 Location: 초록색 상자 + // 베드 타입 Location: 회색 철판들이 데이터 개수만큼 쌓이는 형태 + const locPlateCount = placement.material_count || placement.quantity || 5; // 데이터 개수 + const locVisiblePlateCount = locPlateCount; // 데이터 개수만큼 모두 렌더링 + const locPlateThickness = 0.15; // 각 철판 두께 + const locPlateGap = 0.03; // 철판 사이 미세한 간격 + // 실제 렌더링되는 폴리곤 기준으로 높이 계산 + const locVisibleStackHeight = locVisiblePlateCount * (locPlateThickness + locPlateGap); + // 그룹의 position_y를 상쇄해서 바닥(y=0)부터 시작하도록 + const locYOffset = -placement.position_y; + const locPlateBaseY = locYOffset + locPlateThickness / 2; + return ( <> - - - - - {/* 대표 자재 스택 (자재가 있을 때만) */} - {placement.material_count !== undefined && - placement.material_count > 0 && - placement.material_preview_height && ( + {/* 철판 스택 - 데이터 개수만큼 회색 판 쌓기 (최대 20개) */} + {Array.from({ length: locVisiblePlateCount }).map((_, idx) => { + const yPos = locPlateBaseY + idx * (locPlateThickness + locPlateGap); + // 약간의 랜덤 오프셋으로 자연스러움 추가 + const xOffset = (Math.sin(idx * 0.5) * 0.02); + const zOffset = (Math.cos(idx * 0.7) * 0.02); + + return ( + {/* 각 철판 외곽선 */} + + + + - )} - - {/* Location 이름 */} + ); + })} + + {/* Location 이름 - 실제 폴리곤 높이 기준, 뒤쪽(+Z)에 배치 */} {placement.name && ( {placement.name} )} - {/* 자재 개수 */} - {placement.material_count !== undefined && placement.material_count > 0 && ( + {/* 수량 표시 텍스트 - 실제 폴리곤 높이 기준, 앞쪽(-Z)에 배치 */} + {locPlateCount > 0 && ( - {`자재: ${placement.material_count}개`} + {`${locPlateCount}장`} )} @@ -886,83 +895,79 @@ function MaterialBox({ case "plate-stack": default: - // 후판 스택: 팔레트 + 박스 (기존 렌더링) + // 후판 스택: 회색 철판들이 데이터 개수만큼 쌓이는 형태 + const plateCount = placement.material_count || placement.quantity || 5; // 데이터 개수 (기본 5장) + const visiblePlateCount = plateCount; // 데이터 개수만큼 모두 렌더링 + const plateThickness = 0.15; // 각 철판 두께 + const plateGap = 0.03; // 철판 사이 미세한 간격 + // 실제 렌더링되는 폴리곤 기준으로 높이 계산 + const visibleStackHeight = visiblePlateCount * (plateThickness + plateGap); + // 그룹의 position_y를 상쇄해서 바닥(y=0)부터 시작하도록 + const yOffset = -placement.position_y; + const plateBaseY = yOffset + plateThickness / 2; + return ( <> - {/* 팔레트 그룹 - 박스 하단에 붙어있도록 */} - - {/* 상단 가로 판자들 (5개) */} - {[-boxDepth * 0.4, -boxDepth * 0.2, 0, boxDepth * 0.2, boxDepth * 0.4].map((zOffset, idx) => ( + {/* 철판 스택 - 데이터 개수만큼 회색 판 쌓기 (최대 20개) */} + {Array.from({ length: visiblePlateCount }).map((_, idx) => { + const yPos = plateBaseY + idx * (plateThickness + plateGap); + // 약간의 랜덤 오프셋으로 자연스러움 추가 (실제 철판처럼) + const xOffset = (Math.sin(idx * 0.5) * 0.02); + const zOffset = (Math.cos(idx * 0.7) * 0.02); + + return ( - + + {/* 각 철판 외곽선 */} - - + + - ))} - - {/* 중간 세로 받침대 (3개) */} - {[-boxWidth * 0.35, 0, boxWidth * 0.35].map((xOffset, idx) => ( - - - - - - - - ))} - - {/* 하단 가로 판자들 (3개) */} - {[-boxDepth * 0.3, 0, boxDepth * 0.3].map((zOffset, idx) => ( - - - - - - - - ))} - - - {/* 메인 박스 */} - - {/* 메인 재질 - 골판지 느낌 */} - - - {/* 외곽선 - 더 진하게 */} - - - - - + ); + })} + + {/* 수량 표시 텍스트 (상단) - 앞쪽(-Z)에 배치 */} + {plateCount > 0 && ( + + {`${plateCount}장`} + + )} + + {/* 자재명 표시 (있는 경우) - 뒤쪽(+Z)에 배치 */} + {placement.material_name && ( + + {placement.material_name} + + )} ); } @@ -1114,20 +1119,11 @@ function Scene({ {/* 배경색 */} - {/* 바닥 그리드 (타일을 4등분) */} - + {/* 바닥 - 단색 평면 (그리드 제거) */} + + + + {/* 자재 박스들 */} {placements.map((placement) => (