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

255 lines
9.3 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 } 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: "active" | "inactive" | "maintenance" | "warning";
speed: number;
destination: string;
}
interface VehicleMapOnlyWidgetProps {
element?: any; // 대시보드 요소 (dataSource, chartConfig 포함)
refreshInterval?: number;
}
export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000 }: VehicleMapOnlyWidgetProps) {
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
const loadVehicles = async () => {
setIsLoading(true);
// 설정된 쿼리가 없으면 로딩 중단
if (!element?.dataSource?.query) {
setIsLoading(false);
setVehicles([]);
return;
}
// 설정된 컬럼 매핑 확인
if (!element?.chartConfig?.latitudeColumn || !element?.chartConfig?.longitudeColumn) {
setIsLoading(false);
setVehicles([]);
return;
}
try {
const response = await fetch("/api/dashboards/execute-query", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`,
},
body: JSON.stringify({
query: element.dataSource.query,
}),
});
if (response.ok) {
const result = await response.json();
if (result.success && result.data.rows.length > 0) {
// 설정된 컬럼 매핑 가져오기
const latCol = element.chartConfig.latitudeColumn;
const lngCol = element.chartConfig.longitudeColumn;
const labelCol = element.chartConfig.labelColumn || "name";
const statusCol = element.chartConfig.statusColumn || "status";
// DB 데이터를 Vehicle 형식으로 변환
const vehiclesFromDB: Vehicle[] = result.data.rows.map((row: any, index: number) => ({
id: row.id || row.vehicle_number || `V${index + 1}`,
name: row[labelCol] || `차량 ${index + 1}`,
driver: row.driver_name || row.driver || "미배정",
lat: parseFloat(row[latCol]),
lng: parseFloat(row[lngCol]),
status:
row[statusCol] === "warning"
? "warning"
: row[statusCol] === "active"
? "active"
: row[statusCol] === "maintenance"
? "maintenance"
: "inactive",
speed: parseFloat(row.speed) || 0,
destination: row.destination || "대기 중",
}));
setVehicles(vehiclesFromDB);
setLastUpdate(new Date());
setIsLoading(false);
return;
}
}
} catch (error) {
console.error("차량 데이터 로드 실패:", error);
}
setIsLoading(false);
};
// useEffect는 항상 같은 순서로 호출되어야 함 (early return 전에 배치)
useEffect(() => {
loadVehicles();
const interval = setInterval(loadVehicles, refreshInterval);
return () => clearInterval(interval);
}, [element?.dataSource?.query, element?.chartConfig?.latitudeColumn, element?.chartConfig?.longitudeColumn, refreshInterval]);
// 쿼리 없으면 빈 지도만 표시 (안내 메시지 제거)
const getStatusColor = (status: Vehicle["status"]) => {
switch (status) {
case "active":
return "#22c55e"; // 운행 중 - 초록
case "inactive":
return "#eab308"; // 대기 - 노랑
case "maintenance":
return "#f97316"; // 정비 - 주황
case "warning":
return "#ef4444"; // 고장 - 빨강
default:
return "#6b7280"; // 기타 - 회색
}
};
const getStatusText = (status: Vehicle["status"]) => {
switch (status) {
case "active":
return "운행 중";
case "inactive":
return "대기";
case "maintenance":
return "정비";
case "warning":
return "고장";
default:
return "알 수 없음";
}
};
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="h-[calc(100%-60px)]">
<div className="relative h-full overflow-hidden rounded-lg border-2 border-gray-300 bg-white z-0">
<MapContainer
key={`vehicle-map-${element.id}`}
center={[36.5, 127.5]}
zoom={7}
style={{ height: "100%", width: "100%", zIndex: 0 }}
zoomControl={true}
preferCanvas={true}
className="z-0"
>
{/* 브이월드 타일맵 (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]}>
<Popup>
<div className="text-xs">
<div className="mb-1 text-sm font-bold">{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.destination}
</div>
</div>
</Popup>
</Marker>
</React.Fragment>
))}
</MapContainer>
{/* 지도 정보 */}
<div className="absolute right-2 top-2 z-[1000] rounded-lg bg-white/90 p-2 shadow-lg backdrop-blur-sm">
<div className="text-xs text-gray-600">
<div className="mb-1 font-semibold">🗺 (VWorld)</div>
<div className="text-xs"> </div>
</div>
</div>
{/* 차량 수 표시 또는 설정 안내 */}
<div className="absolute bottom-2 left-2 z-[1000] rounded-lg bg-white/90 p-2 shadow-lg backdrop-blur-sm">
{vehicles.length > 0 ? (
<div className="text-xs font-semibold text-gray-900"> {vehicles.length} </div>
) : (
<div className="text-xs text-gray-600">
</div>
)}
</div>
</div>
</div>
</div>
);
}