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

414 lines
15 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 { 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 });
const Polyline = dynamic(() => import("react-leaflet").then((mod) => mod.Polyline), { 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" | "off";
speed: number;
destination: string;
userId?: string; // 이동경로 조회용
tripId?: string; // 현재 운행 ID
}
// 이동경로 좌표
interface RoutePoint {
lat: number;
lng: number;
recordedAt: string;
speed?: number;
}
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 [selectedVehicle, setSelectedVehicle] = useState<Vehicle | null>(null);
const [routePoints, setRoutePoints] = useState<RoutePoint[]>([]);
const [isRouteLoading, setIsRouteLoading] = useState(false);
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(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 형식으로 변환
console.log("🗺️ [VehicleMapOnlyWidget] 원본 데이터:", result.data.rows);
console.log("🗺️ [VehicleMapOnlyWidget] 컬럼 매핑:", { latCol, lngCol, labelCol, statusCol });
const vehiclesFromDB: Vehicle[] = result.data.rows
.map((row: any, index: number) => {
const lat = parseFloat(row[latCol]);
const lng = parseFloat(row[lngCol]);
console.log(`🗺️ [VehicleMapOnlyWidget] 차량 ${index + 1}:`, {
id: row.id || row.vehicle_number,
latRaw: row[latCol],
lngRaw: row[lngCol],
latParsed: lat,
lngParsed: lng,
status: row[statusCol],
});
return {
id: row.id || row.vehicle_number || `V${index + 1}`,
name: row[labelCol] || `차량 ${index + 1}`,
driver: row.driver_name || row.driver || "미배정",
lat,
lng,
status:
row[statusCol] === "warning"
? "warning"
: row[statusCol] === "active"
? "active"
: row[statusCol] === "maintenance"
? "maintenance"
: "inactive",
speed: parseFloat(row.speed) || 0,
destination: row.destination || "대기 중",
userId: row.user_id || row.userId || undefined,
tripId: row.trip_id || row.tripId || undefined,
};
})
// 유효한 위도/경도가 있는 차량만 필터링
.filter((v: Vehicle) => !isNaN(v.lat) && !isNaN(v.lng) && v.lat !== 0 && v.lng !== 0);
console.log("🗺️ [VehicleMapOnlyWidget] 유효한 차량 수:", vehiclesFromDB.length);
setVehicles(vehiclesFromDB);
setLastUpdate(new Date());
setIsLoading(false);
return;
}
}
} catch (error) {
console.error("차량 데이터 로드 실패:", error);
}
setIsLoading(false);
};
// 이동경로 로드 함수
const loadRoute = async (vehicle: Vehicle) => {
if (!vehicle.userId && !vehicle.tripId) {
console.log("🛣️ 이동경로 조회 불가: userId 또는 tripId 없음");
return;
}
setIsRouteLoading(true);
setSelectedVehicle(vehicle);
try {
// 오늘 날짜 기준으로 최근 이동경로 조회
const today = new Date();
const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate()).toISOString();
// trip_id가 있으면 해당 운행만, 없으면 user_id로 오늘 전체 조회
let query = "";
if (vehicle.tripId) {
query = `SELECT latitude, longitude, speed, recorded_at
FROM vehicle_location_history
WHERE trip_id = '${vehicle.tripId}'
ORDER BY recorded_at ASC`;
} else if (vehicle.userId) {
query = `SELECT latitude, longitude, speed, recorded_at
FROM vehicle_location_history
WHERE user_id = '${vehicle.userId}'
AND recorded_at >= '${startOfDay}'
ORDER BY recorded_at ASC`;
}
console.log("🛣️ 이동경로 쿼리:", query);
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 }),
});
if (response.ok) {
const result = await response.json();
if (result.success && result.data.rows.length > 0) {
const points: RoutePoint[] = result.data.rows.map((row: any) => ({
lat: parseFloat(row.latitude),
lng: parseFloat(row.longitude),
recordedAt: row.recorded_at,
speed: row.speed ? parseFloat(row.speed) : undefined,
}));
console.log(`🛣️ 이동경로 ${points.length}개 포인트 로드 완료`);
setRoutePoints(points);
} else {
console.log("🛣️ 이동경로 데이터 없음");
setRoutePoints([]);
}
}
} catch (error) {
console.error("이동경로 로드 실패:", error);
setRoutePoints([]);
}
setIsRouteLoading(false);
};
// 이동경로 숨기기
const clearRoute = () => {
setSelectedVehicle(null);
setRoutePoints([]);
};
// 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-background to-primary/10 p-4">
{/* 헤더 */}
<div className="mb-3 flex items-center justify-between">
<div>
<h3 className="text-lg font-bold text-foreground">🗺 </h3>
<p className="text-xs text-muted-foreground"> : {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 z-0 h-full overflow-hidden rounded-lg border-2 border-border bg-background">
<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}
/>
{/* 이동경로 Polyline */}
{routePoints.length > 1 && (
<Polyline
positions={routePoints.map((p) => [p.lat, p.lng] as [number, number])}
pathOptions={{
color: "#3b82f6",
weight: 4,
opacity: 0.8,
dashArray: "10, 5",
}}
/>
)}
{/* 차량 마커 */}
{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>
{/* 이동경로 버튼 */}
{(vehicle.userId || vehicle.tripId) && (
<div className="mt-2 border-t pt-2">
<button
onClick={() => loadRoute(vehicle)}
disabled={isRouteLoading}
className="w-full rounded bg-blue-500 px-2 py-1 text-xs text-white hover:bg-blue-600 disabled:opacity-50"
>
{isRouteLoading && selectedVehicle?.id === vehicle.id
? "로딩 중..."
: "🛣️ 이동경로 보기"}
</button>
</div>
)}
</div>
</Popup>
</Marker>
</React.Fragment>
))}
</MapContainer>
{/* 지도 정보 */}
<div className="absolute top-2 right-2 z-[1000] rounded-lg bg-background/90 p-2 shadow-lg backdrop-blur-sm">
<div className="text-xs text-foreground">
<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-background/90 p-2 shadow-lg backdrop-blur-sm">
{vehicles.length > 0 ? (
<div className="text-xs font-semibold text-foreground"> {vehicles.length} </div>
) : (
<div className="text-xs text-foreground"> </div>
)}
</div>
{/* 이동경로 정보 표시 - 상단으로 이동하여 주석 처리 */}
{/* {selectedVehicle && routePoints.length > 0 && (
<div className="absolute bottom-2 right-2 z-[1000] rounded-lg bg-blue-500/90 p-2 shadow-lg backdrop-blur-sm">
<div className="flex items-center gap-2">
<div className="text-xs text-white">
<div className="font-semibold">🛣️ {selectedVehicle.name} 이동경로</div>
<div>{routePoints.length}개 포인트</div>
</div>
<button
onClick={clearRoute}
className="rounded bg-white/20 px-2 py-1 text-xs text-white hover:bg-white/30"
>
숨기기
</button>
</div>
</div>
)} */}
</div>
</div>
</div>
);
}