From 95cbd62b1aaacb40d69d751ab9171aaf67d60e23 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 15 Dec 2025 09:46:26 +0900 Subject: [PATCH] =?UTF-8?q?3D=20=EC=95=BC=EB=93=9C=20=EC=9C=84=EC=A0=AF=20?= =?UTF-8?q?=EC=83=88=EB=A1=9C=EA=B3=A0=EC=B9=A8=20=EB=B2=84=ED=8A=BC?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/yard-3d/DigitalTwinEditor.tsx | 361 ++++++++++-------- .../widgets/yard-3d/DigitalTwinViewer.tsx | 347 +++++++++-------- 2 files changed, 379 insertions(+), 329 deletions(-) diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx index b99b58af..f511a7b1 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx @@ -2,7 +2,19 @@ import { useState, useEffect, useMemo } from "react"; import { Button } from "@/components/ui/button"; -import { ArrowLeft, Save, Loader2, Grid3x3, Move, Box, Package, Truck, Check, ParkingCircle } from "lucide-react"; +import { + ArrowLeft, + Save, + Loader2, + Grid3x3, + Move, + Box, + Package, + Truck, + Check, + ParkingCircle, + RefreshCw, +} from "lucide-react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -78,7 +90,7 @@ const DebouncedInput = ({ const handleBlur = (e: React.FocusEvent) => { setIsEditing(false); if (onCommit && debounce === 0) { - // 값이 변경되었을 때만 커밋하도록 하면 좋겠지만, + // 값이 변경되었을 때만 커밋하도록 하면 좋겠지만, // 부모 상태와 비교하기 어려우므로 항상 커밋 (handleObjectUpdate 내부에서 처리됨) onCommit(type === "number" ? parseFloat(localValue as string) : localValue); } @@ -545,150 +557,170 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi // 레이아웃 데이터 로드 const [layoutData, setLayoutData] = useState<{ layout: any; objects: any[] } | null>(null); + const [isRefreshing, setIsRefreshing] = useState(false); - useEffect(() => { - const loadLayout = async () => { - try { - setIsLoading(true); - const response = await getLayoutById(layoutId); + // 레이아웃 로드 함수 + const loadLayout = async () => { + try { + setIsLoading(true); + const response = await getLayoutById(layoutId); - if (response.success && response.data) { - const { layout, objects } = response.data; - setLayoutData({ layout, objects }); // 레이아웃 데이터 저장 + if (response.success && response.data) { + const { layout, objects } = response.data; + setLayoutData({ layout, objects }); // 레이아웃 데이터 저장 - // 외부 DB 연결 ID 복원 - if (layout.external_db_connection_id) { - setSelectedDbConnection(layout.external_db_connection_id); - } - - // 계층 구조 설정 로드 - if (layout.hierarchy_config) { - try { - // hierarchy_config가 문자열이면 파싱, 객체면 그대로 사용 - const config = - typeof layout.hierarchy_config === "string" - ? JSON.parse(layout.hierarchy_config) - : layout.hierarchy_config; - setHierarchyConfig(config); - - // 선택된 테이블 정보도 복원 - const newSelectedTables: any = { - warehouse: config.warehouse?.tableName || "", - area: "", - location: "", - material: "", - }; - - if (config.levels && config.levels.length > 0) { - // 레벨 1 = Area - if (config.levels[0]?.tableName) { - newSelectedTables.area = config.levels[0].tableName; - } - // 레벨 2 = Location - if (config.levels[1]?.tableName) { - newSelectedTables.location = config.levels[1].tableName; - } - } - - // 자재 테이블 정보 - if (config.material?.tableName) { - newSelectedTables.material = config.material.tableName; - } - - setSelectedTables(newSelectedTables); - } catch (e) { - console.error("계층 구조 설정 파싱 실패:", e); - } - } - - // 객체 데이터 변환 (DB -> PlacedObject) - const loadedObjects: PlacedObject[] = (objects as unknown as DbObject[]).map((obj) => ({ - id: obj.id, - type: obj.object_type, - 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: 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 || 1, - parentKey: obj.parent_key, - externalKey: obj.external_key, - })); - - setPlacedObjects(loadedObjects); - - // 다음 임시 ID 설정 (기존 ID 중 최소값 - 1) - const minId = Math.min(...loadedObjects.map((o) => o.id), 0); - setNextObjectId(minId - 1); - - setHasUnsavedChanges(false); - - toast({ - title: "레이아웃 불러오기 완료", - description: `${loadedObjects.length}개의 객체를 불러왔습니다.`, - }); - - // 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" || - obj.type === "location-stp" || - obj.type === "location-temp" || - obj.type === "location-dest") && - obj.locaKey, - ); - if (locationObjects.length > 0 && dbConnectionId && materialTableName) { - const locaKeys = locationObjects.map((obj) => obj.locaKey!); - setTimeout(() => { - loadMaterialCountsForLocations(locaKeys, dbConnectionId, materialTableName); - }, 100); - } - } else { - throw new Error(response.error || "레이아웃 조회 실패"); + // 외부 DB 연결 ID 복원 + if (layout.external_db_connection_id) { + setSelectedDbConnection(layout.external_db_connection_id); } - } catch (error) { - console.error("레이아웃 로드 실패:", error); - const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다."; - toast({ - variant: "destructive", - title: "오류", - description: errorMessage, - }); - } finally { - setIsLoading(false); - } - }; + // 계층 구조 설정 로드 + if (layout.hierarchy_config) { + try { + // hierarchy_config가 문자열이면 파싱, 객체면 그대로 사용 + const config = + typeof layout.hierarchy_config === "string" + ? JSON.parse(layout.hierarchy_config) + : layout.hierarchy_config; + setHierarchyConfig(config); + + // 선택된 테이블 정보도 복원 + const newSelectedTables: any = { + warehouse: config.warehouse?.tableName || "", + area: "", + location: "", + material: "", + }; + + if (config.levels && config.levels.length > 0) { + // 레벨 1 = Area + if (config.levels[0]?.tableName) { + newSelectedTables.area = config.levels[0].tableName; + } + // 레벨 2 = Location + if (config.levels[1]?.tableName) { + newSelectedTables.location = config.levels[1].tableName; + } + } + + // 자재 테이블 정보 + if (config.material?.tableName) { + newSelectedTables.material = config.material.tableName; + } + + setSelectedTables(newSelectedTables); + } catch (e) { + console.error("계층 구조 설정 파싱 실패:", e); + } + } + + // 객체 데이터 변환 (DB -> PlacedObject) + const loadedObjects: PlacedObject[] = (objects as unknown as DbObject[]).map((obj) => ({ + id: obj.id, + type: obj.object_type, + 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: 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 || 1, + parentKey: obj.parent_key, + externalKey: obj.external_key, + })); + + setPlacedObjects(loadedObjects); + + // 다음 임시 ID 설정 (기존 ID 중 최소값 - 1) + const minId = Math.min(...loadedObjects.map((o) => o.id), 0); + setNextObjectId(minId - 1); + + setHasUnsavedChanges(false); + + toast({ + title: "레이아웃 불러오기 완료", + description: `${loadedObjects.length}개의 객체를 불러왔습니다.`, + }); + + // 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" || + obj.type === "location-stp" || + obj.type === "location-temp" || + obj.type === "location-dest") && + obj.locaKey, + ); + if (locationObjects.length > 0 && dbConnectionId && materialTableName) { + const locaKeys = locationObjects.map((obj) => obj.locaKey!); + setTimeout(() => { + loadMaterialCountsForLocations(locaKeys, dbConnectionId, materialTableName); + }, 100); + } + } else { + throw new Error(response.error || "레이아웃 조회 실패"); + } + } catch (error) { + console.error("레이아웃 로드 실패:", error); + const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다."; + toast({ + variant: "destructive", + title: "오류", + description: errorMessage, + }); + } finally { + setIsLoading(false); + } + }; + + // 위젯 새로고침 핸들러 + const handleRefresh = async () => { + if (hasUnsavedChanges) { + const confirmed = window.confirm( + "저장되지 않은 변경사항이 있습니다. 새로고침하면 변경사항이 사라집니다. 계속하시겠습니까?", + ); + if (!confirmed) return; + } + setIsRefreshing(true); + setSelectedObject(null); + setMaterials([]); + await loadLayout(); + setIsRefreshing(false); + toast({ + title: "새로고침 완료", + description: "데이터가 갱신되었습니다.", + }); + }; + + // 초기 로드 + useEffect(() => { loadLayout(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [layoutId]); // toast 제거 + }, [layoutId]); // 외부 DB 연결 자동 선택 (externalDbConnections 로드 완료 시) useEffect(() => { @@ -1052,7 +1084,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi }; // Location별 자재 개수 로드 (locaKeys를 직접 받음) - const loadMaterialCountsForLocations = async (locaKeys: string[], dbConnectionId?: number, materialTableName?: string) => { + const loadMaterialCountsForLocations = async ( + locaKeys: string[], + dbConnectionId?: number, + materialTableName?: string, + ) => { const connectionId = dbConnectionId || selectedDbConnection; const tableName = materialTableName || selectedTables.material; if (!connectionId || locaKeys.length === 0) return; @@ -1060,7 +1096,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi try { const response = await getMaterialCounts(connectionId, tableName, locaKeys); console.log("📊 자재 개수 API 응답:", response); - + if (response.success && response.data) { // 각 Location 객체에 자재 개수 업데이트 (STP는 자재 미적재이므로 제외) setPlacedObjects((prev) => @@ -1073,10 +1109,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi } // 백엔드 응답 필드명: location_key, count (대소문자 모두 체크) const materialCount = response.data?.find( - (mc: any) => - mc.LOCAKEY === obj.locaKey || - mc.location_key === obj.locaKey || - mc.locakey === obj.locaKey + (mc: any) => mc.LOCAKEY === obj.locaKey || mc.location_key === obj.locaKey || mc.locakey === obj.locaKey, ); if (materialCount) { // count 또는 material_count 필드 사용 @@ -1527,6 +1560,16 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
{hasUnsavedChanges && 미저장 변경사항 있음} +
- setSelectedTemplateId(val)}> {mappingTemplates.length === 0 ? ( -
- 사용 가능한 템플릿이 없습니다 -
+
사용 가능한 템플릿이 없습니다
) : ( mappingTemplates.map((tpl) => (
{tpl.name} {tpl.description && ( - - {tpl.description} - + {tpl.description} )}
@@ -1704,17 +1740,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi }} onLoadColumns={async (tableName: string) => { try { - const response = await ExternalDbConnectionAPI.getTableColumns( - selectedDbConnection, - tableName, - ); + const response = await ExternalDbConnectionAPI.getTableColumns(selectedDbConnection, tableName); if (response.success && response.data) { // 컬럼 정보 객체 배열로 반환 (이름 + 설명 + PK 플래그) return response.data.map((col: any) => ({ - column_name: - typeof col === "string" - ? col - : col.column_name || col.COLUMN_NAME || String(col), + column_name: typeof col === "string" ? col : col.column_name || col.COLUMN_NAME || String(col), data_type: col.data_type || col.DATA_TYPE, description: col.description || col.COLUMN_COMMENT || undefined, is_primary_key: col.is_primary_key ?? col.IS_PRIMARY_KEY, @@ -2354,10 +2384,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi > 취소 - diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx index 91804987..71462ebe 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useMemo } from "react"; -import { Loader2, Search, X, Grid3x3, Package, ParkingCircle } from "lucide-react"; +import { Loader2, Search, X, Grid3x3, Package, ParkingCircle, RefreshCw } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; @@ -41,130 +41,144 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) // 검색 및 필터 const [searchQuery, setSearchQuery] = useState(""); const [filterType, setFilterType] = useState("all"); + const [isRefreshing, setIsRefreshing] = useState(false); - // 레이아웃 데이터 로드 - useEffect(() => { - const loadLayout = async () => { - try { - setIsLoading(true); - const response = await getLayoutById(layoutId); + // 레이아웃 데이터 로드 함수 + const loadLayout = async () => { + try { + setIsLoading(true); + const response = await getLayoutById(layoutId); - if (response.success && response.data) { - const { layout, objects } = response.data; + if (response.success && response.data) { + const { layout, objects } = response.data; - // 레이아웃 정보 저장 (snake_case와 camelCase 모두 지원) - setLayoutName(layout.layout_name || layout.layoutName); - const dbConnectionId = layout.external_db_connection_id || layout.externalDbConnectionId; - setExternalDbConnectionId(dbConnectionId); + // 레이아웃 정보 저장 (snake_case와 camelCase 모두 지원) + setLayoutName(layout.layout_name || layout.layoutName); + const dbConnectionId = layout.external_db_connection_id || layout.externalDbConnectionId; + setExternalDbConnectionId(dbConnectionId); - // 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); - } + // 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, - }; + // 객체 데이터 변환 + 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, + }; + }); + + 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 }; }); - setPlacedObjects(loadedObjects); + const materialCounts = await Promise.all(materialCountPromises); - // 외부 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); + // 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 { 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 || "레이아웃 조회 실패"); + return obj; + }), + ); } - } catch (error) { - console.error("레이아웃 로드 실패:", error); - const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다."; - toast({ - variant: "destructive", - title: "오류", - description: errorMessage, - }); - } finally { - setIsLoading(false); + } else { + throw new Error(response.error || "레이아웃 조회 실패"); } - }; + } catch (error) { + console.error("레이아웃 로드 실패:", error); + const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다."; + toast({ + variant: "destructive", + title: "오류", + description: errorMessage, + }); + } finally { + setIsLoading(false); + } + }; + // 위젯 새로고침 핸들러 + const handleRefresh = async () => { + setIsRefreshing(true); + setSelectedObject(null); + setMaterials([]); + setShowInfoPanel(false); + await loadLayout(); + setIsRefreshing(false); + toast({ + title: "새로고침 완료", + description: "데이터가 갱신되었습니다.", + }); + }; + + // 초기 로드 + useEffect(() => { loadLayout(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [layoutId]); // toast 제거 - 무한 루프 방지 + }, [layoutId]); // Location의 자재 목록 로드 const loadMaterialsForLocation = async (locaKey: string, externalDbConnectionId: number) => { @@ -322,6 +336,16 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)

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

읽기 전용 뷰

+ {/* 메인 영역 */} @@ -404,59 +428,59 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) // 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.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} -

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

- Location: {obj.locaKey} -

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

- 자재: {obj.materialCount}개 -

- )} -
+ ); + })}
); - })} -
- ); } // Area가 있는 경우: Area → Location 계층 아코디언 @@ -525,8 +549,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) />

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

{locationObj.locaKey && (

-- 2.43.0