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

537 lines
21 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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