ERP-node/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx

633 lines
27 KiB
TypeScript

"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: () => (
<div className="bg-muted flex h-full items-center justify-center">
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
</div>
),
});
interface DigitalTwinViewerProps {
layoutId: number;
}
export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) {
const { toast } = useToast();
const [placedObjects, setPlacedObjects] = useState<PlacedObject[]>([]);
const [selectedObject, setSelectedObject] = useState<PlacedObject | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [materials, setMaterials] = useState<MaterialData[]>([]);
const [loadingMaterials, setLoadingMaterials] = useState(false);
const [showInfoPanel, setShowInfoPanel] = useState(false);
const [externalDbConnectionId, setExternalDbConnectionId] = useState<number | null>(null);
const [layoutName, setLayoutName] = useState<string>("");
const [hierarchyConfig, setHierarchyConfig] = useState<any>(null);
// 검색 및 필터
const [searchQuery, setSearchQuery] = useState("");
const [filterType, setFilterType] = useState<string>("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<string, number> = {
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 (
<div className="flex h-full items-center justify-center">
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
</div>
);
}
return (
<div className="bg-background flex h-full flex-col">
{/* 상단 헤더 */}
<div className="flex items-center justify-between border-b p-4">
<div>
<h2 className="text-lg font-semibold">{layoutName || "디지털 트윈 야드"}</h2>
<p className="text-muted-foreground text-sm"> </p>
</div>
</div>
{/* 메인 영역 */}
<div className="flex flex-1 overflow-hidden">
{/* 좌측: 검색/필터 */}
<div className="flex h-full w-64 flex-shrink-0 flex-col border-r">
<div className="space-y-4 p-4">
{/* 검색 */}
<div>
<Label className="mb-2 block text-sm font-semibold"></Label>
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="이름, Area, Location 검색..."
className="h-10 pl-9 text-sm"
/>
{searchQuery && (
<Button
variant="ghost"
size="sm"
className="absolute top-1/2 right-1 h-7 w-7 -translate-y-1/2 p-0"
onClick={() => setSearchQuery("")}
>
<X className="h-3 w-3" />
</Button>
)}
</div>
</div>
{/* 타입 필터 */}
<div>
<Label className="mb-2 block text-sm font-semibold"> </Label>
<Select value={filterType} onValueChange={setFilterType}>
<SelectTrigger className="h-10 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> ({typeCounts.all})</SelectItem>
<SelectItem value="area">Area ({typeCounts.area})</SelectItem>
<SelectItem value="location-bed">(BED) ({typeCounts["location-bed"]})</SelectItem>
<SelectItem value="location-stp">(STP) ({typeCounts["location-stp"]})</SelectItem>
<SelectItem value="location-temp">(TMP) ({typeCounts["location-temp"]})</SelectItem>
<SelectItem value="location-dest">(DES) ({typeCounts["location-dest"]})</SelectItem>
<SelectItem value="crane-mobile"> ({typeCounts["crane-mobile"]})</SelectItem>
<SelectItem value="rack"> ({typeCounts.rack})</SelectItem>
</SelectContent>
</Select>
</div>
{/* 필터 초기화 */}
{(searchQuery || filterType !== "all") && (
<Button
variant="outline"
size="sm"
className="h-9 w-full text-sm"
onClick={() => {
setSearchQuery("");
setFilterType("all");
}}
>
</Button>
)}
</div>
{/* 객체 목록 */}
<div className="flex-1 overflow-y-auto border-t p-4">
<Label className="mb-2 block text-sm font-semibold"> ({filteredObjects.length})</Label>
{filteredObjects.length === 0 ? (
<div className="text-muted-foreground flex h-32 items-center justify-center text-center text-sm">
{searchQuery ? "검색 결과가 없습니다" : "객체가 없습니다"}
</div>
) : (
(() => {
// Area 객체가 있는 경우 계층 트리 아코디언 적용
const areaObjects = filteredObjects.filter((obj) => obj.type === "area");
// Area가 없으면 기존 평면 리스트 유지
if (areaObjects.length === 0) {
return (
<div className="space-y-2">
{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 (
<div
key={obj.id}
onClick={() => 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"
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm font-medium">{obj.name}</p>
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: obj.color }}
/>
<span>{typeLabel}</span>
</div>
</div>
</div>
<div className="mt-2 space-y-1">
{obj.areaKey && (
<p className="text-muted-foreground text-xs">
Area: <span className="font-medium">{obj.areaKey}</span>
</p>
)}
{obj.locaKey && (
<p className="text-muted-foreground text-xs">
Location: <span className="font-medium">{obj.locaKey}</span>
</p>
)}
{obj.materialCount !== undefined && obj.materialCount > 0 && (
<p className="text-xs text-yellow-600">
: <span className="font-semibold">{obj.materialCount}</span>
</p>
)}
</div>
</div>
);
})}
</div>
);
}
// Area가 있는 경우: Area → Location 계층 아코디언
return (
<Accordion type="multiple" className="w-full">
{areaObjects.map((areaObj) => {
const childLocations = filteredObjects.filter(
(obj) =>
obj.type !== "area" &&
obj.areaKey === areaObj.areaKey &&
(obj.parentId === areaObj.id || obj.externalKey === areaObj.externalKey),
);
return (
<AccordionItem key={areaObj.id} value={`area-${areaObj.id}`} className="border-b">
<AccordionTrigger className="px-2 py-3 hover:no-underline">
<div
className={`flex w-full items-center justify-between pr-2 ${
selectedObject?.id === areaObj.id ? "text-primary font-semibold" : ""
}`}
onClick={(e) => {
e.stopPropagation();
handleObjectClick(areaObj.id);
}}
>
<div className="flex items-center gap-2">
<Grid3x3 className="h-4 w-4" />
<span className="text-sm font-medium">{areaObj.name}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">({childLocations.length})</span>
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: areaObj.color }}
/>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-2 pb-3">
{childLocations.length === 0 ? (
<p className="text-muted-foreground py-2 text-center text-xs">Location이 </p>
) : (
<div className="space-y-2">
{childLocations.map((locationObj) => (
<div
key={locationObj.id}
onClick={() => 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"
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{locationObj.type === "location-stp" ? (
<ParkingCircle className="h-3 w-3" />
) : (
<Package className="h-3 w-3" />
)}
<span className="text-xs font-medium">{locationObj.name}</span>
</div>
<span
className="inline-block h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: locationObj.color }}
/>
</div>
<p className="text-muted-foreground mt-1 text-[10px]">
: ({locationObj.position.x.toFixed(1)},{" "}
{locationObj.position.z.toFixed(1)})
</p>
{locationObj.locaKey && (
<p className="text-muted-foreground mt-0.5 text-[10px]">
Location: <span className="font-medium">{locationObj.locaKey}</span>
</p>
)}
{locationObj.materialCount !== undefined && locationObj.materialCount > 0 && (
<p className="mt-0.5 text-[10px] text-yellow-600">
: <span className="font-semibold">{locationObj.materialCount}</span>
</p>
)}
</div>
))}
</div>
)}
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
);
})()
)}
</div>
</div>
{/* 중앙: 3D 캔버스 */}
<div className="relative flex-1">
{!isLoading && (
<Yard3DCanvas
placements={canvasPlacements}
selectedPlacementId={selectedObject?.id || null}
onPlacementClick={(placement) => handleObjectClick(placement?.id || null)}
focusOnPlacementId={null}
onCollisionDetected={() => {}}
/>
)}
</div>
{/* 우측: 정보 패널 */}
<div className="h-full w-[480px] flex-shrink-0 overflow-y-auto border-l">
{selectedObject ? (
<div className="p-4">
<div className="mb-4">
<h3 className="text-lg font-semibold"> </h3>
<p className="text-muted-foreground text-xs">{selectedObject.name}</p>
</div>
{/* 기본 정보 */}
<div className="bg-muted space-y-3 rounded-lg p-3">
<div>
<Label className="text-muted-foreground text-xs"></Label>
<p className="text-sm font-medium">{selectedObject.type}</p>
</div>
{selectedObject.areaKey && (
<div>
<Label className="text-muted-foreground text-xs">Area Key</Label>
<p className="text-sm font-medium">{selectedObject.areaKey}</p>
</div>
)}
{selectedObject.locaKey && (
<div>
<Label className="text-muted-foreground text-xs">Location Key</Label>
<p className="text-sm font-medium">{selectedObject.locaKey}</p>
</div>
)}
{selectedObject.materialCount !== undefined && selectedObject.materialCount > 0 && (
<div>
<Label className="text-muted-foreground text-xs"> </Label>
<p className="text-sm font-medium">{selectedObject.materialCount}</p>
</div>
)}
</div>
{/* 자재 목록 (Location인 경우) - 아코디언 */}
{(selectedObject.type === "location-bed" ||
selectedObject.type === "location-stp" ||
selectedObject.type === "location-temp" ||
selectedObject.type === "location-dest") && (
<div className="mt-4">
{loadingMaterials ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
) : materials.length === 0 ? (
<div className="text-muted-foreground flex h-32 items-center justify-center text-center text-sm">
{externalDbConnectionId ? "자재가 없습니다" : "외부 DB 연결이 설정되지 않았습니다"}
</div>
) : (
<div className="space-y-2">
<Label className="mb-2 block text-sm font-semibold"> ({materials.length})</Label>
{materials.map((material, index) => {
const displayColumns = hierarchyConfig?.material?.displayColumns || [];
return (
<details
key={`${material.STKKEY}-${index}`}
className="bg-muted group hover:bg-accent rounded-lg border transition-colors"
>
<summary className="flex cursor-pointer items-center justify-between p-3 text-sm font-medium">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold">
{material[hierarchyConfig?.material?.layerColumn || "LOLAYER"]}
</span>
{displayColumns[0] && (
<span className="text-muted-foreground text-xs">
{material[displayColumns[0].column]}
</span>
)}
</div>
</div>
<svg
className="text-muted-foreground h-4 w-4 transition-transform group-open:rotate-180"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div className="space-y-2 border-t p-3 pt-3">
{displayColumns.map((colConfig: any) => (
<div key={colConfig.column} className="flex justify-between text-xs">
<span className="text-muted-foreground">{colConfig.label}:</span>
<span className="font-medium">{material[colConfig.column] || "-"}</span>
</div>
))}
</div>
</details>
);
})}
</div>
)}
</div>
)}
</div>
) : (
<div className="flex h-full items-center justify-center p-4">
<p className="text-muted-foreground text-sm"> </p>
</div>
)}
</div>
</div>
</div>
);
}