419 lines
14 KiB
TypeScript
419 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import React, { useRef, useState, useEffect, Suspense } from "react";
|
|
import { Canvas, useFrame } from "@react-three/fiber";
|
|
import { OrbitControls, Text, Box, Html } from "@react-three/drei";
|
|
import * as THREE from "three";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Loader2, Maximize2, Info } from "lucide-react";
|
|
|
|
interface WarehouseData {
|
|
id: string;
|
|
name: string;
|
|
position_x: number;
|
|
position_y: number;
|
|
position_z: number;
|
|
size_x: number;
|
|
size_y: number;
|
|
size_z: number;
|
|
color: string;
|
|
capacity: number;
|
|
current_usage: number;
|
|
status: string;
|
|
description?: string;
|
|
}
|
|
|
|
interface MaterialData {
|
|
id: string;
|
|
warehouse_id: string;
|
|
name: string;
|
|
material_code: string;
|
|
quantity: number;
|
|
unit: string;
|
|
position_x: number;
|
|
position_y: number;
|
|
position_z: number;
|
|
size_x: number;
|
|
size_y: number;
|
|
size_z: number;
|
|
color: string;
|
|
status: string;
|
|
}
|
|
|
|
interface Warehouse3DWidgetProps {
|
|
element?: any;
|
|
}
|
|
|
|
// 창고 3D 박스 컴포넌트
|
|
function WarehouseBox({
|
|
warehouse,
|
|
onClick,
|
|
isSelected,
|
|
}: {
|
|
warehouse: WarehouseData;
|
|
onClick: () => void;
|
|
isSelected: boolean;
|
|
}) {
|
|
const meshRef = useRef<THREE.Mesh>(null);
|
|
const [hovered, setHovered] = useState(false);
|
|
|
|
useFrame(() => {
|
|
if (meshRef.current) {
|
|
if (isSelected) {
|
|
meshRef.current.scale.lerp(new THREE.Vector3(1.05, 1.05, 1.05), 0.1);
|
|
} else if (hovered) {
|
|
meshRef.current.scale.lerp(new THREE.Vector3(1.02, 1.02, 1.02), 0.1);
|
|
} else {
|
|
meshRef.current.scale.lerp(new THREE.Vector3(1, 1, 1), 0.1);
|
|
}
|
|
}
|
|
});
|
|
|
|
const usagePercentage = (warehouse.current_usage / warehouse.capacity) * 100;
|
|
|
|
return (
|
|
<group position={[warehouse.position_x, warehouse.position_y + warehouse.size_y / 2, warehouse.position_z]}>
|
|
<mesh
|
|
ref={meshRef}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onClick();
|
|
}}
|
|
onPointerOver={() => setHovered(true)}
|
|
onPointerOut={() => setHovered(false)}
|
|
>
|
|
<boxGeometry args={[warehouse.size_x, warehouse.size_y, warehouse.size_z]} />
|
|
<meshStandardMaterial color={warehouse.color} transparent opacity={0.3} wireframe={false} />
|
|
</mesh>
|
|
|
|
{/* 창고 테두리 */}
|
|
<lineSegments>
|
|
<edgesGeometry args={[new THREE.BoxGeometry(warehouse.size_x, warehouse.size_y, warehouse.size_z)]} />
|
|
<lineBasicMaterial color={isSelected ? "#FFD700" : hovered ? "#FFFFFF" : warehouse.color} linewidth={2} />
|
|
</lineSegments>
|
|
|
|
{/* 창고 이름 라벨 */}
|
|
<Text position={[0, warehouse.size_y / 2 + 1, 0]} fontSize={1} color="white" anchorX="center" anchorY="middle">
|
|
{warehouse.name}
|
|
</Text>
|
|
|
|
{/* 사용률 표시 */}
|
|
<Html position={[0, warehouse.size_y / 2 + 2, 0]} center>
|
|
<div className="pointer-events-none rounded bg-black/80 px-2 py-1 text-xs text-white">
|
|
{usagePercentage.toFixed(0)}% 사용중
|
|
</div>
|
|
</Html>
|
|
</group>
|
|
);
|
|
}
|
|
|
|
// 자재 3D 박스 컴포넌트
|
|
function MaterialBox({
|
|
material,
|
|
onClick,
|
|
isSelected,
|
|
}: {
|
|
material: MaterialData;
|
|
onClick: () => void;
|
|
isSelected: boolean;
|
|
}) {
|
|
const meshRef = useRef<THREE.Mesh>(null);
|
|
const [hovered, setHovered] = useState(false);
|
|
|
|
useFrame(() => {
|
|
if (meshRef.current && (isSelected || hovered)) {
|
|
meshRef.current.rotation.y += 0.01;
|
|
}
|
|
});
|
|
|
|
const statusColor =
|
|
{
|
|
stocked: material.color,
|
|
reserved: "#FFA500",
|
|
urgent: "#FF0000",
|
|
out_of_stock: "#808080",
|
|
}[material.status] || material.color;
|
|
|
|
return (
|
|
<group position={[material.position_x, material.position_y + material.size_y / 2, material.position_z]}>
|
|
<mesh
|
|
ref={meshRef}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onClick();
|
|
}}
|
|
onPointerOver={() => setHovered(true)}
|
|
onPointerOut={() => setHovered(false)}
|
|
>
|
|
<boxGeometry args={[material.size_x, material.size_y, material.size_z]} />
|
|
<meshStandardMaterial color={statusColor} metalness={0.5} roughness={0.2} />
|
|
</mesh>
|
|
|
|
{(hovered || isSelected) && (
|
|
<Html position={[0, material.size_y / 2 + 0.5, 0]} center>
|
|
<div className="pointer-events-none rounded bg-black/90 px-2 py-1 text-xs text-white shadow-lg">
|
|
<div className="font-bold">{material.name}</div>
|
|
<div className="text-gray-300">
|
|
{material.quantity} {material.unit}
|
|
</div>
|
|
</div>
|
|
</Html>
|
|
)}
|
|
</group>
|
|
);
|
|
}
|
|
|
|
// 3D 씬 컴포넌트
|
|
function Scene({
|
|
warehouses,
|
|
materials,
|
|
onSelectWarehouse,
|
|
onSelectMaterial,
|
|
selectedWarehouse,
|
|
selectedMaterial,
|
|
}: {
|
|
warehouses: WarehouseData[];
|
|
materials: MaterialData[];
|
|
onSelectWarehouse: (warehouse: WarehouseData | null) => void;
|
|
onSelectMaterial: (material: MaterialData | null) => void;
|
|
selectedWarehouse: WarehouseData | null;
|
|
selectedMaterial: MaterialData | null;
|
|
}) {
|
|
return (
|
|
<>
|
|
{/* 조명 */}
|
|
<ambientLight intensity={0.5} />
|
|
<directionalLight position={[10, 10, 5]} intensity={1} castShadow />
|
|
<directionalLight position={[-10, 10, -5]} intensity={0.5} />
|
|
|
|
{/* 바닥 그리드 */}
|
|
<gridHelper args={[100, 50, "#444444", "#222222"]} position={[0, 0, 0]} />
|
|
|
|
{/* 창고들 */}
|
|
{warehouses.map((warehouse) => (
|
|
<WarehouseBox
|
|
key={warehouse.id}
|
|
warehouse={warehouse}
|
|
onClick={() => {
|
|
if (selectedWarehouse?.id === warehouse.id) {
|
|
onSelectWarehouse(null);
|
|
} else {
|
|
onSelectWarehouse(warehouse);
|
|
onSelectMaterial(null);
|
|
}
|
|
}}
|
|
isSelected={selectedWarehouse?.id === warehouse.id}
|
|
/>
|
|
))}
|
|
|
|
{/* 자재들 */}
|
|
{materials.map((material) => (
|
|
<MaterialBox
|
|
key={material.id}
|
|
material={material}
|
|
onClick={() => {
|
|
if (selectedMaterial?.id === material.id) {
|
|
onSelectMaterial(null);
|
|
} else {
|
|
onSelectMaterial(material);
|
|
}
|
|
}}
|
|
isSelected={selectedMaterial?.id === material.id}
|
|
/>
|
|
))}
|
|
|
|
{/* 카메라 컨트롤 */}
|
|
<OrbitControls enableDamping dampingFactor={0.05} minDistance={10} maxDistance={100} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function Warehouse3DWidget({ element }: Warehouse3DWidgetProps) {
|
|
const [warehouses, setWarehouses] = useState<WarehouseData[]>([]);
|
|
const [materials, setMaterials] = useState<MaterialData[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [selectedWarehouse, setSelectedWarehouse] = useState<WarehouseData | null>(null);
|
|
const [selectedMaterial, setSelectedMaterial] = useState<MaterialData | null>(null);
|
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, []);
|
|
|
|
const loadData = async () => {
|
|
try {
|
|
setLoading(true);
|
|
// API 호출 (백엔드 API 구현 필요)
|
|
const response = await fetch("/api/warehouse/data");
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setWarehouses(data.warehouses || []);
|
|
setMaterials(data.materials || []);
|
|
} else {
|
|
// 임시 더미 데이터 (개발용)
|
|
console.log("API 실패, 더미 데이터 사용");
|
|
}
|
|
} catch (error) {
|
|
console.error("창고 데이터 로드 실패:", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<Card className="h-full">
|
|
<CardContent className="flex h-full items-center justify-center">
|
|
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Card className={`flex h-full flex-col ${isFullscreen ? "fixed inset-0 z-50" : ""}`}>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-lg font-bold">🏭 창고 현황 (3D)</CardTitle>
|
|
<div className="flex gap-2">
|
|
<Badge variant="outline">
|
|
{warehouses.length}개 창고 | {materials.length}개 자재
|
|
</Badge>
|
|
<button onClick={() => setIsFullscreen(!isFullscreen)} className="text-gray-500 hover:text-gray-700">
|
|
<Maximize2 className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-1 gap-4 p-4">
|
|
{/* 3D 뷰 */}
|
|
<div className="flex-1 rounded-lg bg-gray-900">
|
|
<Canvas camera={{ position: [30, 20, 30], fov: 50 }}>
|
|
<Suspense fallback={null}>
|
|
<Scene
|
|
warehouses={warehouses}
|
|
materials={materials}
|
|
onSelectWarehouse={setSelectedWarehouse}
|
|
onSelectMaterial={setSelectedMaterial}
|
|
selectedWarehouse={selectedWarehouse}
|
|
selectedMaterial={selectedMaterial}
|
|
/>
|
|
</Suspense>
|
|
</Canvas>
|
|
</div>
|
|
|
|
{/* 정보 패널 */}
|
|
<div className="w-80 space-y-4 overflow-y-auto">
|
|
{/* 선택된 창고 정보 */}
|
|
{selectedWarehouse && (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="flex items-center gap-2 text-sm">
|
|
<Info className="h-4 w-4" />
|
|
창고 정보
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2 text-sm">
|
|
<div>
|
|
<span className="font-semibold">이름:</span> {selectedWarehouse.name}
|
|
</div>
|
|
<div>
|
|
<span className="font-semibold">ID:</span> {selectedWarehouse.id}
|
|
</div>
|
|
<div>
|
|
<span className="font-semibold">용량:</span> {selectedWarehouse.current_usage} /{" "}
|
|
{selectedWarehouse.capacity}
|
|
</div>
|
|
<div>
|
|
<span className="font-semibold">사용률:</span>{" "}
|
|
{((selectedWarehouse.current_usage / selectedWarehouse.capacity) * 100).toFixed(1)}%
|
|
</div>
|
|
<div>
|
|
<span className="font-semibold">상태:</span>{" "}
|
|
<Badge variant={selectedWarehouse.status === "active" ? "default" : "secondary"}>
|
|
{selectedWarehouse.status}
|
|
</Badge>
|
|
</div>
|
|
{selectedWarehouse.description && (
|
|
<div>
|
|
<span className="font-semibold">설명:</span> {selectedWarehouse.description}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* 선택된 자재 정보 */}
|
|
{selectedMaterial && (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="flex items-center gap-2 text-sm">
|
|
<Info className="h-4 w-4" />
|
|
자재 정보
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2 text-sm">
|
|
<div>
|
|
<span className="font-semibold">이름:</span> {selectedMaterial.name}
|
|
</div>
|
|
<div>
|
|
<span className="font-semibold">코드:</span> {selectedMaterial.material_code}
|
|
</div>
|
|
<div>
|
|
<span className="font-semibold">수량:</span> {selectedMaterial.quantity} {selectedMaterial.unit}
|
|
</div>
|
|
<div>
|
|
<span className="font-semibold">위치:</span>{" "}
|
|
{warehouses.find((w) => w.id === selectedMaterial.warehouse_id)?.name}
|
|
</div>
|
|
<div>
|
|
<span className="font-semibold">상태:</span>{" "}
|
|
<Badge
|
|
variant={
|
|
selectedMaterial.status === "urgent"
|
|
? "destructive"
|
|
: selectedMaterial.status === "reserved"
|
|
? "secondary"
|
|
: "default"
|
|
}
|
|
>
|
|
{selectedMaterial.status}
|
|
</Badge>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* 창고 목록 */}
|
|
{!selectedWarehouse && !selectedMaterial && (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-sm">창고 목록</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2">
|
|
{warehouses.map((warehouse) => {
|
|
const warehouseMaterials = materials.filter((m) => m.warehouse_id === warehouse.id);
|
|
return (
|
|
<button
|
|
key={warehouse.id}
|
|
onClick={() => setSelectedWarehouse(warehouse)}
|
|
className="w-full rounded-lg border p-2 text-left transition-colors hover:bg-gray-50"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<span className="font-semibold">{warehouse.name}</span>
|
|
<Badge variant="outline">{warehouseMaterials.length}개</Badge>
|
|
</div>
|
|
<div className="mt-1 text-xs text-gray-500">
|
|
{((warehouse.current_usage / warehouse.capacity) * 100).toFixed(0)}% 사용중
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|