2025-10-14 11:55:31 +09:00
|
|
|
|
"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>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 차량 상태 요약 */}
|
2025-10-14 16:36:00 +09:00
|
|
|
|
<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>
|
2025-10-14 11:55:31 +09:00
|
|
|
|
</div>
|
2025-10-14 16:36:00 +09:00
|
|
|
|
<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>
|
2025-10-14 11:55:31 +09:00
|
|
|
|
</div>
|
2025-10-14 16:36:00 +09:00
|
|
|
|
<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>
|
2025-10-14 11:55:31 +09:00
|
|
|
|
</div>
|
2025-10-14 16:36:00 +09:00
|
|
|
|
<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>
|
2025-10-14 11:55:31 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-10-14 16:36:00 +09:00
|
|
|
|
<div className="flex h-[calc(100%-120px)] gap-3">
|
2025-10-14 11:55:31 +09:00
|
|
|
|
{/* 지도 영역 - 브이월드 타일맵 */}
|
2025-10-14 16:36:00 +09:00
|
|
|
|
<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">
|
2025-10-14 11:55:31 +09:00
|
|
|
|
{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='© <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>
|
|
|
|
|
|
|
2025-10-14 16:36:00 +09:00
|
|
|
|
{/* 우측 사이드 패널 */}
|
|
|
|
|
|
<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>
|
2025-10-14 11:55:31 +09:00
|
|
|
|
|
2025-10-14 16:36:00 +09:00
|
|
|
|
<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">
|
2025-10-14 11:55:31 +09:00
|
|
|
|
{vehicle.name}
|
|
|
|
|
|
</span>
|
2025-10-14 16:36:00 +09:00
|
|
|
|
<span
|
|
|
|
|
|
className="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
|
|
|
|
|
style={{ backgroundColor: getStatusColor(vehicle.status) }}
|
|
|
|
|
|
>
|
|
|
|
|
|
{getStatusText(vehicle.status)}
|
|
|
|
|
|
</span>
|
2025-10-14 11:55:31 +09:00
|
|
|
|
</div>
|
2025-10-14 16:36:00 +09:00
|
|
|
|
<div className="text-xs text-gray-600 flex items-center gap-1">
|
2025-10-14 11:55:31 +09:00
|
|
|
|
<Navigation className="h-3 w-3" />
|
2025-10-14 16:36:00 +09:00
|
|
|
|
<span className="truncate">{vehicle.destination}</span>
|
2025-10-14 11:55:31 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-10-14 16:36:00 +09:00
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2025-10-14 11:55:31 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 선택된 차량 상세 정보 */}
|
2025-10-14 16:36:00 +09:00
|
|
|
|
{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>
|
2025-10-14 11:55:31 +09:00
|
|
|
|
</div>
|
2025-10-14 16:36:00 +09:00
|
|
|
|
<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)}
|
2025-10-14 11:55:31 +09:00
|
|
|
|
</span>
|
2025-10-14 16:36:00 +09:00
|
|
|
|
<span className="text-sm text-gray-400">{selectedVehicle.id}</span>
|
2025-10-14 11:55:31 +09:00
|
|
|
|
</div>
|
2025-10-14 16:36:00 +09:00
|
|
|
|
</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>
|
2025-10-14 11:55:31 +09:00
|
|
|
|
</div>
|
2025-10-14 16:36:00 +09:00
|
|
|
|
</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>
|
2025-10-14 11:55:31 +09:00
|
|
|
|
</div>
|
2025-10-14 16:36:00 +09:00
|
|
|
|
</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>
|
2025-10-14 11:55:31 +09:00
|
|
|
|
</div>
|
2025-10-14 16:36:00 +09:00
|
|
|
|
<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>
|
2025-10-14 11:55:31 +09:00
|
|
|
|
</div>
|
2025-10-14 16:36:00 +09:00
|
|
|
|
<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>
|
2025-10-14 11:55:31 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-10-14 16:36:00 +09:00
|
|
|
|
</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">
|
2025-10-14 11:55:31 +09:00
|
|
|
|
{selectedVehicle.temperature}°C
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2025-10-14 16:36:00 +09:00
|
|
|
|
<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>
|
2025-10-14 11:55:31 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-10-14 16:36:00 +09:00
|
|
|
|
</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>
|
2025-10-14 11:55:31 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|