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

258 lines
9.2 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 } from "lucide-react";
2025-10-24 16:08:57 +09:00
import { getApiUrl } from "@/lib/utils/apiUrl";
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 {
2025-10-24 16:08:57 +09:00
const response = await fetch(getApiUrl("/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);
2025-10-22 13:40:15 +09:00
}, [
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)]">
2025-10-22 13:40:15 +09:00
<div className="relative z-0 h-full overflow-hidden rounded-lg border-2 border-gray-300 bg-white">
<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"
>
2025-10-22 13:40:15 +09:00
{/* 브이월드 타일맵 (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>
2025-10-22 13:40:15 +09:00
<div>
<strong>:</strong> {vehicle.destination}
</div>
</div>
</Popup>
</Marker>
</React.Fragment>
))}
</MapContainer>
{/* 지도 정보 */}
2025-10-22 13:40:15 +09:00
<div className="absolute top-2 right-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>
) : (
2025-10-22 13:40:15 +09:00
<div className="text-xs text-gray-600"> </div>
)}
</div>
</div>
</div>
</div>
);
}