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

547 lines
22 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-2 md:grid-cols-4 gap-2">
<div className="rounded-lg bg-white p-1.5 shadow-sm border-l-4 border-green-500">
<div className="text-xs text-gray-600 mb-0.5"> </div>
<div className="text-lg font-bold text-green-600">{statusStats.running}</div>
</div>
<div className="rounded-lg bg-white p-1.5 shadow-sm border-l-4 border-yellow-500">
<div className="text-xs text-gray-600 mb-0.5"></div>
<div className="text-lg font-bold text-yellow-600">{statusStats.idle}</div>
</div>
<div className="rounded-lg bg-white p-1.5 shadow-sm border-l-4 border-orange-500">
<div className="text-xs text-gray-600 mb-0.5"></div>
<div className="text-lg font-bold text-orange-600">{statusStats.maintenance}</div>
</div>
<div className="rounded-lg bg-white p-1.5 shadow-sm border-l-4 border-red-500">
<div className="text-xs text-gray-600 mb-0.5"></div>
<div className="text-lg font-bold text-red-600">{statusStats.breakdown}</div>
</div>
</div>
<div className="flex h-[calc(100%-120px)] gap-3">
{/* 지도 영역 - 브이월드 타일맵 */}
<div className="flex-1 min-w-0 overflow-auto">
<div className="relative h-full min-h-[400px] min-w-[600px] 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="w-80 flex flex-col gap-3 overflow-y-auto max-h-full">
{/* 차량 목록 */}
<div className="rounded-lg bg-white shadow-sm border border-gray-200 overflow-hidden">
<div className="bg-gray-50 border-b border-gray-200 p-3">
<h4 className="text-sm font-semibold text-gray-900 flex items-center gap-2">
<Truck className="h-4 w-4 text-gray-600" />
({vehicles.length})
</h4>
</div>
<div className="p-2 max-h-[320px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100">
{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 p-2 transition-all hover:shadow-sm ${
selectedVehicle?.id === vehicle.id
? "border-gray-900 bg-gray-50 ring-1 ring-gray-900"
: "border-gray-200 bg-white hover:border-gray-300"
}`}
>
<div className="flex items-center justify-between mb-1">
<span className="font-semibold text-sm text-gray-900">
{vehicle.name}
</span>
<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="text-xs text-gray-600 flex items-center gap-1">
<Navigation className="h-3 w-3" />
<span className="truncate">{vehicle.destination}</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* 선택된 차량 상세 정보 */}
{selectedVehicle ? (
<div className="rounded-lg bg-white shadow-sm border border-gray-200 overflow-hidden max-h-[400px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100">
{/* 헤더 */}
<div className="bg-gray-900 p-4">
<div className="flex items-center justify-between mb-2">
<h4 className="text-base font-semibold text-white flex items-center gap-2">
<Truck className="h-5 w-5" />
{selectedVehicle.name}
</h4>
<button
onClick={() => setSelectedVehicle(null)}
className="text-gray-400 hover:text-white transition-colors"
>
</button>
</div>
<div className="flex items-center gap-2">
<span
className="rounded-full px-3 py-1 text-xs font-semibold text-white"
style={{ backgroundColor: getStatusColor(selectedVehicle.status) }}
>
{getStatusText(selectedVehicle.status)}
</span>
<span className="text-sm text-gray-400">{selectedVehicle.id}</span>
</div>
</div>
{/* 기사 정보 */}
<div className="p-4 border-b border-gray-200">
<h5 className="text-xs font-semibold text-gray-500 mb-2">👤 </h5>
<div className="space-y-1.5">
<div className="flex justify-between text-sm">
<span className="text-gray-600"></span>
<span className="font-semibold text-gray-900">{selectedVehicle.driver}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">GPS </span>
<span className="font-mono text-xs text-gray-700">
{selectedVehicle.lat.toFixed(4)}, {selectedVehicle.lng.toFixed(4)}
</span>
</div>
</div>
</div>
{/* 운행 정보 */}
<div className="p-4 border-b border-gray-200">
<h5 className="text-xs font-semibold text-gray-500 mb-2">📍 </h5>
<div className="space-y-1.5">
<div className="flex justify-between text-sm">
<span className="text-gray-600"></span>
<span className="font-semibold text-gray-900">{selectedVehicle.destination}</span>
</div>
</div>
</div>
{/* 실시간 데이터 */}
<div className="p-4 border-b border-gray-200">
<h5 className="text-xs font-semibold text-gray-500 mb-2">📊 </h5>
<div className="grid grid-cols-2 gap-2">
<div className="bg-gray-50 rounded-lg p-2 border border-gray-200">
<div className="text-xs text-gray-600 mb-0.5"> </div>
<div className="text-lg font-bold text-gray-900">{selectedVehicle.speed}</div>
<div className="text-xs text-gray-500">km/h</div>
</div>
<div className="bg-gray-50 rounded-lg p-2 border border-gray-200">
<div className="text-xs text-gray-600 mb-0.5"> </div>
<div className="text-lg font-bold text-gray-900">{selectedVehicle.avgSpeed}</div>
<div className="text-xs text-gray-500">km/h</div>
</div>
<div className="bg-gray-50 rounded-lg p-2 border border-gray-200">
<div className="text-xs text-gray-600 mb-0.5"> </div>
<div className="text-lg font-bold text-gray-900">{selectedVehicle.distance}</div>
<div className="text-xs text-gray-500">km</div>
</div>
<div className="bg-gray-50 rounded-lg p-2 border border-gray-200">
<div className="text-xs text-gray-600 mb-0.5"> </div>
<div className="text-lg font-bold text-gray-900">{selectedVehicle.fuel}</div>
<div className="text-xs text-gray-500">L</div>
</div>
</div>
</div>
{/* 냉동/냉장 상태 */}
{selectedVehicle.isRefrigerated && selectedVehicle.temperature !== undefined && (
<div className="p-4">
<h5 className="text-xs font-semibold text-gray-500 mb-3"> / </h5>
<div className="rounded-lg p-4 border border-gray-200 bg-gray-50">
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-gray-600"> </span>
<span className="text-3xl font-bold text-gray-900">
{selectedVehicle.temperature}°C
</span>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600"></span>
<span className="font-semibold text-gray-900">
{selectedVehicle.temperature < -10 ? "냉동" : "냉장"}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600"> </span>
<span className="font-semibold text-gray-900">
{selectedVehicle.temperature < -10 ? "-18°C ~ -15°C" : "0°C ~ 5°C"}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600"></span>
<span className={`px-3 py-1 rounded-md text-xs font-semibold border ${
Math.abs(selectedVehicle.temperature - (selectedVehicle.temperature < -10 ? -18 : 2)) < 5
? "bg-gray-900 text-white border-gray-900"
: "bg-white text-gray-900 border-gray-300"
}`}>
{Math.abs(selectedVehicle.temperature - (selectedVehicle.temperature < -10 ? -18 : 2)) < 5
? "✓ 정상"
: "⚠ 주의"}
</span>
</div>
</div>
</div>
</div>
)}
</div>
) : (
<div className="rounded-lg bg-white shadow-lg border border-gray-200 p-8 text-center">
<Truck className="h-12 w-12 text-gray-300 mx-auto mb-3" />
<p className="text-sm text-gray-500"> </p>
<p className="text-sm text-gray-500"> </p>
</div>
)}
</div>
</div>
</div>
);
}