ERP-node/frontend/components/dashboard/widgets/VehicleMapWidget.tsx

537 lines
21 KiB
TypeScript
Raw Normal View History

"use client";
import React, { useState, useEffect } from "react";
import dynamic from "next/dynamic";
import { Button } from "@/components/ui/button";
import { RefreshCw, Truck, Navigation } from "lucide-react";
import "leaflet/dist/leaflet.css";
// Leaflet 아이콘 경로 설정 (엑박 방지)
if (typeof window !== "undefined") {
const L = require("leaflet");
delete (L.Icon.Default.prototype as any)._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
});
}
// Leaflet 동적 import (SSR 방지)
const MapContainer = dynamic(
() => import("react-leaflet").then((mod) => mod.MapContainer),
{ ssr: false }
);
const TileLayer = dynamic(
() => import("react-leaflet").then((mod) => mod.TileLayer),
{ ssr: false }
);
const Marker = dynamic(
() => import("react-leaflet").then((mod) => mod.Marker),
{ ssr: false }
);
const Popup = dynamic(
() => import("react-leaflet").then((mod) => mod.Popup),
{ ssr: false }
);
const Circle = dynamic(
() => import("react-leaflet").then((mod) => mod.Circle),
{ ssr: false }
);
// 브이월드 API 키
const VWORLD_API_KEY = "97AD30D5-FDC4-3481-99C3-158E36422033";
interface Vehicle {
id: string;
name: string;
driver: string;
lat: number;
lng: number;
status: "running" | "idle" | "maintenance" | "breakdown";
speed: number;
destination: string;
distance: number;
fuel: number;
avgSpeed: number;
temperature?: number;
isRefrigerated: boolean;
}
interface VehicleMapWidgetProps {
refreshInterval?: number;
}
export default function VehicleMapWidget({ refreshInterval = 30000 }: VehicleMapWidgetProps) {
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
const [selectedVehicle, setSelectedVehicle] = useState<Vehicle | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
const loadVehicles = async () => {
setIsLoading(true);
const dummyVehicles: Vehicle[] = [
{
id: "V001",
name: "냉동차 1호",
driver: "김기사",
lat: 35.1796 + (Math.random() - 0.5) * 0.05, // 부산
lng: 129.0756 + (Math.random() - 0.5) * 0.05,
status: "running",
speed: 55 + Math.floor(Math.random() * 20),
destination: "부산 → 울산",
distance: 45 + Math.floor(Math.random() * 20),
fuel: 85 + Math.floor(Math.random() * 15),
avgSpeed: 62,
temperature: -18 + Math.floor(Math.random() * 3),
isRefrigerated: true,
},
{
id: "V002",
name: "일반 화물차 2호",
driver: "이기사",
lat: 37.4563, // 인천
lng: 126.7052,
status: "idle",
speed: 0,
destination: "대기 중",
distance: 0,
fuel: 5,
avgSpeed: 0,
isRefrigerated: false,
},
{
id: "V003",
name: "냉장차 3호",
driver: "박기사",
lat: 36.3504 + (Math.random() - 0.5) * 0.05, // 대전
lng: 127.3845 + (Math.random() - 0.5) * 0.05,
status: "running",
speed: 40 + Math.floor(Math.random() * 15),
destination: "대전 → 세종",
distance: 22 + Math.floor(Math.random() * 10),
fuel: 42 + Math.floor(Math.random() * 10),
avgSpeed: 58,
temperature: 2 + Math.floor(Math.random() * 4),
isRefrigerated: true,
},
{
id: "V004",
name: "일반 화물차 4호",
driver: "최기사",
lat: 35.8714, // 대구
lng: 128.6014,
status: "maintenance",
speed: 0,
destination: "정비소",
distance: 0,
fuel: 0,
avgSpeed: 0,
isRefrigerated: false,
},
{
id: "V005",
name: "냉동차 5호",
driver: "정기사",
lat: 33.4996 + (Math.random() - 0.5) * 0.05, // 제주
lng: 126.5312 + (Math.random() - 0.5) * 0.05,
status: "running",
speed: 45 + Math.floor(Math.random() * 15),
destination: "제주 → 서귀포",
distance: 28 + Math.floor(Math.random() * 10),
fuel: 52 + Math.floor(Math.random() * 10),
avgSpeed: 54,
temperature: -20 + Math.floor(Math.random() * 2),
isRefrigerated: true,
},
{
id: "V006",
name: "일반 화물차 6호",
driver: "강기사",
lat: 35.1595, // 광주
lng: 126.8526,
status: "breakdown",
speed: 0,
destination: "고장 (견인 대기)",
distance: 65,
fuel: 18,
avgSpeed: 0,
isRefrigerated: false,
},
{
id: "V007",
name: "냉장차 7호",
driver: "윤기사",
lat: 37.5665 + (Math.random() - 0.5) * 0.05, // 서울
lng: 126.9780 + (Math.random() - 0.5) * 0.05,
status: "running",
speed: 60 + Math.floor(Math.random() * 15),
destination: "서울 → 수원",
distance: 35 + Math.floor(Math.random() * 10),
fuel: 68 + Math.floor(Math.random() * 10),
avgSpeed: 65,
temperature: 3 + Math.floor(Math.random() * 3),
isRefrigerated: true,
},
{
id: "V008",
name: "일반 화물차 8호",
driver: "한기사",
lat: 37.8813 + (Math.random() - 0.5) * 0.05, // 춘천
lng: 127.7300 + (Math.random() - 0.5) * 0.05,
status: "running",
speed: 50 + Math.floor(Math.random() * 15),
destination: "춘천 → 강릉",
distance: 95 + Math.floor(Math.random() * 20),
fuel: 75 + Math.floor(Math.random() * 15),
avgSpeed: 58,
isRefrigerated: false,
},
];
setTimeout(() => {
setVehicles(dummyVehicles);
setLastUpdate(new Date());
setIsLoading(false);
}, 500);
};
useEffect(() => {
loadVehicles();
const interval = setInterval(loadVehicles, refreshInterval);
return () => clearInterval(interval);
}, [refreshInterval]);
const getStatusColor = (status: Vehicle["status"]) => {
switch (status) {
case "running":
return "#22c55e";
case "idle":
return "#eab308";
case "maintenance":
return "#f97316";
case "breakdown":
return "#ef4444";
default:
return "#6b7280";
}
};
const getStatusText = (status: Vehicle["status"]) => {
switch (status) {
case "running":
return "운행 중";
case "idle":
return "대기";
case "maintenance":
return "정비";
case "breakdown":
return "고장";
default:
return "알 수 없음";
}
};
const statusStats = {
running: vehicles.filter((v) => v.status === "running").length,
idle: vehicles.filter((v) => v.status === "idle").length,
maintenance: vehicles.filter((v) => v.status === "maintenance").length,
breakdown: vehicles.filter((v) => v.status === "breakdown").length,
};
return (
<div className="h-full w-full bg-gradient-to-br from-slate-50 to-blue-50 p-4">
{/* 헤더 */}
<div className="mb-3 flex items-center justify-between">
<div>
<h3 className="text-lg font-bold text-gray-900">🚚 </h3>
<p className="text-xs text-gray-500">
: {lastUpdate.toLocaleTimeString("ko-KR")}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={loadVehicles}
disabled={isLoading}
className="h-8 w-8 p-0"
>
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
</Button>
</div>
{/* 차량 상태 요약 */}
<div className="mb-3 grid grid-cols-4 gap-2">
<div className="rounded-lg bg-white p-2 shadow-sm border-l-4 border-green-500">
<div className="text-xs text-gray-600"> </div>
<div className="text-xl font-bold text-green-600">{statusStats.running}</div>
</div>
<div className="rounded-lg bg-white p-2 shadow-sm border-l-4 border-yellow-500">
<div className="text-xs text-gray-600"></div>
<div className="text-xl font-bold text-yellow-600">{statusStats.idle}</div>
</div>
<div className="rounded-lg bg-white p-2 shadow-sm border-l-4 border-orange-500">
<div className="text-xs text-gray-600"></div>
<div className="text-xl font-bold text-orange-600">{statusStats.maintenance}</div>
</div>
<div className="rounded-lg bg-white p-2 shadow-sm border-l-4 border-red-500">
<div className="text-xs text-gray-600"></div>
<div className="text-xl font-bold text-red-600">{statusStats.breakdown}</div>
</div>
</div>
<div className="grid h-[calc(100%-120px)] gap-3 lg:grid-cols-3">
{/* 지도 영역 - 브이월드 타일맵 */}
<div className="lg:col-span-2">
<div className="relative h-full rounded-lg overflow-hidden border-2 border-gray-300 bg-white">
{typeof window !== "undefined" && (
<MapContainer
center={[36.5, 127.5]}
zoom={7}
style={{ height: "100%", width: "100%" }}
zoomControl={true}
preferCanvas={true}
>
{/* 브이월드 타일맵 (HTTPS, 캐싱 적용) */}
<TileLayer
url={`https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`}
attribution='&copy; <a href="https://www.vworld.kr">VWorld (국토교통부)</a>'
maxZoom={19}
minZoom={7}
updateWhenIdle={true}
updateWhenZooming={false}
keepBuffer={2}
/>
{/* 차량 마커 */}
{vehicles.map((vehicle) => (
<React.Fragment key={vehicle.id}>
<Circle
center={[vehicle.lat, vehicle.lng]}
radius={150}
pathOptions={{
color: getStatusColor(vehicle.status),
fillColor: getStatusColor(vehicle.status),
fillOpacity: 0.3,
}}
/>
<Marker
position={[vehicle.lat, vehicle.lng]}
eventHandlers={{
click: () => setSelectedVehicle(vehicle),
}}
>
<Popup>
<div className="text-xs">
<div className="font-bold text-sm mb-1">{vehicle.name}</div>
<div><strong>:</strong> {vehicle.driver}</div>
<div><strong>:</strong> {getStatusText(vehicle.status)}</div>
<div><strong>:</strong> {vehicle.speed} km/h</div>
<div><strong>:</strong> {vehicle.distance} km</div>
<div><strong>:</strong> {vehicle.fuel} L</div>
{vehicle.isRefrigerated && vehicle.temperature !== undefined && (
<div><strong>:</strong> {vehicle.temperature}°C</div>
)}
</div>
</Popup>
</Marker>
</React.Fragment>
))}
</MapContainer>
)}
{/* 지도 정보 */}
<div className="absolute top-2 right-2 bg-white/90 backdrop-blur-sm rounded-lg p-2 shadow-lg z-[1000]">
<div className="text-xs text-gray-600">
<div className="font-semibold mb-1">🗺 (VWorld)</div>
<div className="text-xs"> </div>
</div>
</div>
{/* 차량 수 표시 */}
<div className="absolute bottom-2 left-2 bg-white/90 backdrop-blur-sm rounded-lg p-2 shadow-lg z-[1000]">
<div className="text-xs font-semibold text-gray-900">
{vehicles.length}
</div>
</div>
</div>
</div>
{/* 차량 목록 */}
<div className="flex flex-col gap-2 overflow-y-auto">
<div className="rounded-lg bg-white/70 p-3">
<h4 className="mb-2 text-sm font-bold text-gray-700">
({vehicles.length})
</h4>
{vehicles.length === 0 ? (
<div className="py-8 text-center text-sm text-gray-500">
</div>
) : (
<div className="space-y-2">
{vehicles.map((vehicle) => (
<div
key={vehicle.id}
onClick={() => setSelectedVehicle(vehicle)}
className={`cursor-pointer rounded-lg border-2 p-3 transition-all hover:shadow-md ${
selectedVehicle?.id === vehicle.id
? "border-blue-500 bg-blue-50"
: "border-gray-200 bg-white hover:border-gray-300"
}`}
>
<div className="mb-2 flex items-start justify-between">
<div className="flex items-center gap-2">
<Truck className="h-4 w-4 text-gray-600" />
<span className="font-semibold text-gray-900">
{vehicle.name}
</span>
</div>
<span
className="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
style={{ backgroundColor: getStatusColor(vehicle.status) }}
>
{getStatusText(vehicle.status)}
</span>
</div>
<div className="space-y-1 text-xs text-gray-600">
<div className="flex items-center gap-1">
<span className="font-medium">:</span>
<span>{vehicle.driver}</span>
</div>
<div className="flex items-center gap-1">
<Navigation className="h-3 w-3" />
<span>{vehicle.destination}</span>
</div>
{vehicle.status === "running" && (
<>
<div className="flex items-center gap-1">
<span className="font-medium">:</span>
<span className="text-blue-600 font-semibold">
{vehicle.speed} km/h
</span>
</div>
<div className="flex items-center gap-1">
<span className="font-medium">:</span>
<span>{vehicle.distance} km</span>
</div>
<div className="flex items-center gap-1">
<span className="font-medium">:</span>
<span>{vehicle.fuel} L</span>
</div>
</>
)}
{vehicle.isRefrigerated && vehicle.temperature !== undefined && (
<div className="flex items-center gap-1 mt-1 pt-1 border-t border-gray-200">
<span className="font-medium">:</span>
<span className={`font-semibold ${
vehicle.temperature < -15 ? "text-blue-600" :
vehicle.temperature < 5 ? "text-cyan-600" :
"text-orange-600"
}`}>
{vehicle.temperature}°C
</span>
<span className="text-xs text-gray-500">
({vehicle.temperature < -10 ? "냉동" : "냉장"})
</span>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* 선택된 차량 상세 정보 */}
{selectedVehicle && (
<div className="rounded-lg bg-blue-50 border-2 border-blue-200 p-3">
<h4 className="mb-2 text-sm font-bold text-blue-900">
📍 {selectedVehicle.name}
</h4>
<div className="space-y-2 text-xs text-gray-700">
<div className="flex justify-between">
<span> ID:</span>
<span className="font-semibold">{selectedVehicle.id}</span>
</div>
<div className="flex justify-between">
<span>:</span>
<span className="font-semibold">{selectedVehicle.driver}</span>
</div>
<div className="flex justify-between">
<span>:</span>
<span className="font-mono text-xs">
{selectedVehicle.lat.toFixed(4)}, {selectedVehicle.lng.toFixed(4)}
</span>
</div>
<div className="flex justify-between">
<span>:</span>
<span className="font-semibold">{selectedVehicle.destination}</span>
</div>
<div className="border-t border-blue-300 pt-2 mt-2">
<div className="font-semibold mb-1 text-blue-900"> </div>
<div className="flex justify-between">
<span> :</span>
<span className="font-semibold text-blue-600">{selectedVehicle.speed} km/h</span>
</div>
<div className="flex justify-between">
<span> :</span>
<span>{selectedVehicle.avgSpeed} km/h</span>
</div>
<div className="flex justify-between">
<span> :</span>
<span>{selectedVehicle.distance} km</span>
</div>
<div className="flex justify-between">
<span> :</span>
<span>{selectedVehicle.fuel} L</span>
</div>
</div>
{selectedVehicle.isRefrigerated && selectedVehicle.temperature !== undefined && (
<div className="border-t border-blue-300 pt-2 mt-2">
<div className="font-semibold mb-1 text-blue-900">/ </div>
<div className="flex justify-between">
<span> :</span>
<span className={`font-bold ${
selectedVehicle.temperature < -15 ? "text-blue-600" :
selectedVehicle.temperature < 5 ? "text-cyan-600" :
"text-orange-600"
}`}>
{selectedVehicle.temperature}°C
</span>
</div>
<div className="flex justify-between">
<span> :</span>
<span className="text-gray-600">
{selectedVehicle.temperature < -10 ? "-18°C ~ -15°C" : "0°C ~ 5°C"}
</span>
</div>
<div className="flex justify-between">
<span>:</span>
<span className={`font-semibold ${
Math.abs(selectedVehicle.temperature - (selectedVehicle.temperature < -10 ? -18 : 2)) < 5
? "text-green-600"
: "text-orange-600"
}`}>
{Math.abs(selectedVehicle.temperature - (selectedVehicle.temperature < -10 ? -18 : 2)) < 5
? "정상"
: "주의"}
</span>
</div>
</div>
)}
</div>
</div>
)}
</div>
</div>
</div>
);
}