ERP-node/frontend/components/admin/dashboard/widgets/Warehouse3DWidget.tsx

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>
);
}