"use client"; import { useState, useEffect, useMemo } from "react"; import { Loader2, Search, X, Grid3x3, Package, ParkingCircle } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import dynamic from "next/dynamic"; import { useToast } from "@/hooks/use-toast"; import type { PlacedObject, MaterialData } from "@/types/digitalTwin"; import { getLayoutById, getMaterials } from "@/lib/api/digitalTwin"; import { OBJECT_COLORS, DEFAULT_COLOR } from "./constants"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), { ssr: false, loading: () => (
), }); interface DigitalTwinViewerProps { layoutId: number; } export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) { const { toast } = useToast(); const [placedObjects, setPlacedObjects] = useState([]); const [selectedObject, setSelectedObject] = useState(null); const [isLoading, setIsLoading] = useState(true); const [materials, setMaterials] = useState([]); const [loadingMaterials, setLoadingMaterials] = useState(false); const [showInfoPanel, setShowInfoPanel] = useState(false); const [externalDbConnectionId, setExternalDbConnectionId] = useState(null); const [layoutName, setLayoutName] = useState(""); const [hierarchyConfig, setHierarchyConfig] = useState(null); // 검색 및 필터 const [searchQuery, setSearchQuery] = useState(""); const [filterType, setFilterType] = useState("all"); // 레이아웃 데이터 로드 useEffect(() => { const loadLayout = async () => { try { setIsLoading(true); const response = await getLayoutById(layoutId); if (response.success && response.data) { const { layout, objects } = response.data; // 레이아웃 정보 저장 (snake_case와 camelCase 모두 지원) setLayoutName(layout.layout_name || layout.layoutName); setExternalDbConnectionId(layout.external_db_connection_id || layout.externalDbConnectionId); // hierarchy_config 저장 if (layout.hierarchy_config) { const config = typeof layout.hierarchy_config === "string" ? JSON.parse(layout.hierarchy_config) : layout.hierarchy_config; setHierarchyConfig(config); } // 객체 데이터 변환 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); } 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); } }; loadLayout(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [layoutId]); // toast 제거 - 무한 루프 방지 // Location의 자재 목록 로드 const loadMaterialsForLocation = async (locaKey: string, externalDbConnectionId: number) => { if (!hierarchyConfig?.material) { console.warn("hierarchyConfig.material이 없습니다. 자재 로드를 건너뜁니다."); return; } try { setLoadingMaterials(true); setShowInfoPanel(true); const response = await getMaterials(externalDbConnectionId, { tableName: hierarchyConfig.material.tableName, keyColumn: hierarchyConfig.material.keyColumn, locationKeyColumn: hierarchyConfig.material.locationKeyColumn, layerColumn: hierarchyConfig.material.layerColumn, locaKey: locaKey, }); if (response.success && response.data) { const layerColumn = hierarchyConfig.material.layerColumn || "LOLAYER"; const sortedMaterials = response.data.sort((a: any, b: any) => (a[layerColumn] || 0) - (b[layerColumn] || 0)); setMaterials(sortedMaterials); } else { setMaterials([]); } } catch (error) { console.error("자재 로드 실패:", error); setMaterials([]); } finally { setLoadingMaterials(false); } }; // 객체 클릭 const handleObjectClick = (objectId: number | null) => { if (objectId === null) { setSelectedObject(null); setShowInfoPanel(false); return; } const obj = placedObjects.find((o) => o.id === objectId); setSelectedObject(obj || null); // Location을 클릭한 경우, 자재 정보 표시 (STP는 자재 미적재이므로 제외) if ( obj && (obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") && obj.locaKey && externalDbConnectionId ) { setShowInfoPanel(true); loadMaterialsForLocation(obj.locaKey, externalDbConnectionId); } else { setShowInfoPanel(true); setMaterials([]); } }; // 타입별 개수 계산 (useMemo로 최적화) const typeCounts = useMemo(() => { const counts: Record = { all: placedObjects.length, area: 0, "location-bed": 0, "location-stp": 0, "location-temp": 0, "location-dest": 0, "crane-mobile": 0, rack: 0, }; placedObjects.forEach((obj) => { if (counts[obj.type] !== undefined) { counts[obj.type]++; } }); return counts; }, [placedObjects]); // 필터링된 객체 목록 (useMemo로 최적화) const filteredObjects = useMemo(() => { return placedObjects.filter((obj) => { // 타입 필터 if (filterType !== "all" && obj.type !== filterType) { return false; } // 검색 쿼리 if (searchQuery) { const query = searchQuery.toLowerCase(); return ( obj.name.toLowerCase().includes(query) || obj.areaKey?.toLowerCase().includes(query) || obj.locaKey?.toLowerCase().includes(query) ); } return true; }); }, [placedObjects, filterType, searchQuery]); // 객체 타입별 기본 색상 (useMemo로 최적화) const getObjectColor = useMemo(() => { return (type: string, savedColor?: string): string => { // 저장된 색상이 있으면 우선 사용 if (savedColor) return savedColor; // 없으면 타입별 기본 색상 사용 return OBJECT_COLORS[type] || DEFAULT_COLOR; }; }, []); // 3D 캔버스용 placements 변환 (useMemo로 최적화) const canvasPlacements = useMemo(() => { return placedObjects.map((obj) => ({ id: obj.id, name: obj.name, position_x: obj.position.x, position_y: obj.position.y, position_z: obj.position.z, size_x: obj.size.x, size_y: obj.size.y, size_z: obj.size.z, color: obj.color, data_source_type: obj.type, material_count: obj.materialCount, material_preview_height: obj.materialPreview?.height, yard_layout_id: undefined, material_code: null, material_name: null, quantity: null, unit: null, data_source_config: undefined, data_binding: undefined, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), })); }, [placedObjects]); if (isLoading) { return (
); } return (
{/* 상단 헤더 */}

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

읽기 전용 뷰

{/* 메인 영역 */}
{/* 좌측: 검색/필터 */}
{/* 검색 */}
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"); // 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}
{obj.areaKey && (

Area: {obj.areaKey}

)} {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}개

)}
))}
)}
); })}
); })() )}
{/* 중앙: 3D 캔버스 */}
{!isLoading && ( handleObjectClick(placement?.id || null)} focusOnPlacementId={null} onCollisionDetected={() => {}} /> )}
{/* 우측: 정보 패널 */}
{selectedObject ? (

상세 정보

{selectedObject.name}

{/* 기본 정보 */}

{selectedObject.type}

{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 ? (
) : materials.length === 0 ? (
{externalDbConnectionId ? "자재가 없습니다" : "외부 DB 연결이 설정되지 않았습니다"}
) : (
{materials.map((material, index) => { const displayColumns = hierarchyConfig?.material?.displayColumns || []; return (
층 {material[hierarchyConfig?.material?.layerColumn || "LOLAYER"]} {displayColumns[0] && ( {material[displayColumns[0].column]} )}
{displayColumns.map((colConfig: any) => (
{colConfig.label}: {material[colConfig.column] || "-"}
))}
); })}
)}
)}
) : (

객체를 선택하세요

)}
); }