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

414 lines
15 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 });
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;
2025-12-02 15:33:45 +09:00
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 {
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 형식으로 변환
2025-12-02 15:33:45 +09:00
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,
2025-12-02 15:33:45 +09:00
};
})
// 유효한 위도/경도가 있는 차량만 필터링
.filter((v: Vehicle) => !isNaN(v.lat) && !isNaN(v.lng) && v.lat !== 0 && v.lng !== 0);
2025-12-02 15:33:45 +09:00
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);
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 (
2025-10-29 17:53:03 +09:00
<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>
2025-10-29 17:53:03 +09:00
<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)]">
2025-10-29 17:53:03 +09:00
<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"
>
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}
/>
{/* 이동경로 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",
}}
/>
)}
2025-10-22 13:40:15 +09:00
{/* 차량 마커 */}
{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>
{/* 이동경로 버튼 */}
{(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>
)}
2025-10-22 13:40:15 +09:00
</div>
</Popup>
</Marker>
</React.Fragment>
))}
</MapContainer>
{/* 지도 정보 */}
2025-10-29 17:53:03 +09:00
<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>
{/* 차량 수 표시 또는 설정 안내 */}
2025-10-29 17:53:03 +09:00
<div className="absolute bottom-2 left-2 z-[1000] rounded-lg bg-background/90 p-2 shadow-lg backdrop-blur-sm">
{vehicles.length > 0 ? (
2025-10-29 17:53:03 +09:00
<div className="text-xs font-semibold text-foreground"> {vehicles.length} </div>
) : (
2025-10-29 17:53:03 +09:00
<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>
);
}