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

547 lines
22 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-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>
);
}