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) => (