Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management
This commit is contained in:
commit
a90ddac512
|
|
@ -43,6 +43,7 @@ const Marker = dynamic(() => import("react-leaflet").then((mod) => mod.Marker),
|
|||
const Popup = dynamic(() => import("react-leaflet").then((mod) => mod.Popup), { ssr: false });
|
||||
const Polygon = dynamic(() => import("react-leaflet").then((mod) => mod.Polygon), { ssr: false });
|
||||
const GeoJSON = dynamic(() => import("react-leaflet").then((mod) => mod.GeoJSON), { ssr: false });
|
||||
const Polyline = dynamic(() => import("react-leaflet").then((mod) => mod.Polyline), { ssr: false });
|
||||
|
||||
// 브이월드 API 키
|
||||
const VWORLD_API_KEY = "97AD30D5-FDC4-3481-99C3-158E36422033";
|
||||
|
|
@ -78,6 +79,13 @@ interface PolygonData {
|
|||
opacity?: number; // 투명도 (0.0 ~ 1.0)
|
||||
}
|
||||
|
||||
// 이동경로 타입
|
||||
interface RoutePoint {
|
||||
lat: number;
|
||||
lng: number;
|
||||
recordedAt: string;
|
||||
}
|
||||
|
||||
export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||
const [markers, setMarkers] = useState<MarkerData[]>([]);
|
||||
const prevMarkersRef = useRef<MarkerData[]>([]); // 이전 마커 위치 저장 (useRef 사용)
|
||||
|
|
@ -86,6 +94,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [geoJsonData, setGeoJsonData] = useState<any>(null);
|
||||
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
|
||||
|
||||
// 이동경로 상태
|
||||
const [routePoints, setRoutePoints] = useState<RoutePoint[]>([]);
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||
const [routeLoading, setRouteLoading] = useState(false);
|
||||
const [routeDate, setRouteDate] = useState<string>(new Date().toISOString().split('T')[0]); // YYYY-MM-DD 형식
|
||||
|
||||
// dataSources를 useMemo로 추출 (circular reference 방지)
|
||||
const dataSources = useMemo(() => {
|
||||
|
|
@ -107,6 +121,70 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
return heading;
|
||||
}, []);
|
||||
|
||||
// 이동경로 로드 함수
|
||||
const loadRoute = useCallback(async (userId: string, date?: string) => {
|
||||
if (!userId) {
|
||||
console.log("🛣️ 이동경로 조회 불가: userId 없음");
|
||||
return;
|
||||
}
|
||||
|
||||
setRouteLoading(true);
|
||||
setSelectedUserId(userId);
|
||||
|
||||
try {
|
||||
// 선택한 날짜 기준으로 이동경로 조회
|
||||
const targetDate = date || routeDate;
|
||||
const startOfDay = `${targetDate}T00:00:00.000Z`;
|
||||
const endOfDay = `${targetDate}T23:59:59.999Z`;
|
||||
|
||||
const query = `SELECT latitude, longitude, recorded_at
|
||||
FROM vehicle_location_history
|
||||
WHERE user_id = '${userId}'
|
||||
AND recorded_at >= '${startOfDay}'
|
||||
AND recorded_at <= '${endOfDay}'
|
||||
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,
|
||||
}));
|
||||
|
||||
console.log(`🛣️ 이동경로 ${points.length}개 포인트 로드 완료`);
|
||||
setRoutePoints(points);
|
||||
} else {
|
||||
console.log("🛣️ 이동경로 데이터 없음");
|
||||
setRoutePoints([]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("이동경로 로드 실패:", error);
|
||||
setRoutePoints([]);
|
||||
}
|
||||
|
||||
setRouteLoading(false);
|
||||
}, [routeDate]);
|
||||
|
||||
// 이동경로 숨기기
|
||||
const clearRoute = useCallback(() => {
|
||||
setSelectedUserId(null);
|
||||
setRoutePoints([]);
|
||||
}, []);
|
||||
|
||||
// 다중 데이터 소스 로딩
|
||||
const loadMultipleDataSources = useCallback(async () => {
|
||||
if (!dataSources || dataSources.length === 0) {
|
||||
|
|
@ -509,7 +587,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
status: row.status || row.level,
|
||||
description: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장
|
||||
source: sourceName,
|
||||
color: dataSource?.markerColor || "#3b82f6", // 사용자 지정 색상 또는 기본 파랑
|
||||
color: row.marker_color || row.color || dataSource?.markerColor || "#3b82f6", // 쿼리 색상 > 설정 색상 > 기본 파랑
|
||||
});
|
||||
} else {
|
||||
// 위도/경도가 없는 육지 지역 → 폴리곤으로 추가 (GeoJSON 매칭용)
|
||||
|
|
@ -1005,6 +1083,32 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 이동경로 날짜 선택 */}
|
||||
{selectedUserId && (
|
||||
<div className="flex items-center gap-1 rounded border bg-blue-50 px-2 py-1">
|
||||
<span className="text-xs text-blue-600">🛣️</span>
|
||||
<input
|
||||
type="date"
|
||||
value={routeDate}
|
||||
onChange={(e) => {
|
||||
setRouteDate(e.target.value);
|
||||
if (selectedUserId) {
|
||||
loadRoute(selectedUserId, e.target.value);
|
||||
}
|
||||
}}
|
||||
className="h-6 rounded border-none bg-transparent px-1 text-xs text-blue-600 focus:outline-none"
|
||||
/>
|
||||
<span className="text-xs text-blue-600">
|
||||
({routePoints.length}개)
|
||||
</span>
|
||||
<button
|
||||
onClick={clearRoute}
|
||||
className="ml-1 text-xs text-blue-400 hover:text-blue-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
@ -1305,6 +1409,14 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
// 트럭 마커
|
||||
// 트럭 아이콘이 오른쪽(90도)을 보고 있으므로, 북쪽(0도)으로 가려면 -90도 회전 필요
|
||||
const rotation = heading - 90;
|
||||
|
||||
// 회전 각도가 90~270도 범위면 차량이 뒤집어짐 (바퀴가 위로)
|
||||
// 이 경우 scaleY(-1)로 상하 반전하여 바퀴가 아래로 오도록 함
|
||||
const normalizedRotation = ((rotation % 360) + 360) % 360;
|
||||
const isFlipped = normalizedRotation > 90 && normalizedRotation < 270;
|
||||
const transformStyle = isFlipped
|
||||
? `translate(-50%, -50%) rotate(${rotation}deg) scaleY(-1)`
|
||||
: `translate(-50%, -50%) rotate(${rotation}deg)`;
|
||||
|
||||
markerIcon = L.divIcon({
|
||||
className: "custom-truck-marker",
|
||||
|
|
@ -1315,7 +1427,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform: translate(-50%, -50%) rotate(${rotation}deg);
|
||||
transform: ${transformStyle};
|
||||
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
|
||||
">
|
||||
<svg width="48" height="48" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
|
||||
|
|
@ -1528,12 +1640,51 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
<div className="text-muted-foreground border-t pt-2 text-[10px]">
|
||||
{marker.lat.toFixed(6)}, {marker.lng.toFixed(6)}
|
||||
</div>
|
||||
|
||||
{/* 이동경로 버튼 */}
|
||||
{(() => {
|
||||
try {
|
||||
const parsed = JSON.parse(marker.description || "{}");
|
||||
const userId = parsed.user_id;
|
||||
if (userId) {
|
||||
return (
|
||||
<div className="mt-2 border-t pt-2">
|
||||
<button
|
||||
onClick={() => loadRoute(userId)}
|
||||
disabled={routeLoading}
|
||||
className="w-full rounded bg-blue-500 px-2 py-1 text-xs text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{routeLoading && selectedUserId === userId
|
||||
? "로딩 중..."
|
||||
: "🛣️ 이동경로 보기"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 이동경로 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",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</MapContainer>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ const TileLayer = dynamic(() => import("react-leaflet").then((mod) => mod.TileLa
|
|||
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";
|
||||
|
|
@ -37,6 +38,16 @@ interface Vehicle {
|
|||
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 {
|
||||
|
|
@ -48,6 +59,11 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
|
|||
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);
|
||||
|
|
@ -121,6 +137,8 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
|
|||
: "inactive",
|
||||
speed: parseFloat(row.speed) || 0,
|
||||
destination: row.destination || "대기 중",
|
||||
userId: row.user_id || row.userId || undefined,
|
||||
tripId: row.trip_id || row.tripId || undefined,
|
||||
};
|
||||
})
|
||||
// 유효한 위도/경도가 있는 차량만 필터링
|
||||
|
|
@ -140,6 +158,78 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
|
|||
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();
|
||||
|
|
@ -220,6 +310,19 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
|
|||
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}>
|
||||
|
|
@ -248,6 +351,20 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
|
|||
<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>
|
||||
|
|
@ -271,6 +388,24 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
|
|||
<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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue