From 714cac5d1002927935bb706a0503ef381a764eb0 Mon Sep 17 00:00:00 2001 From: kyle Date: Tue, 3 Feb 2026 22:50:24 +0100 Subject: [PATCH] feat: add GPS location indicators and driver map modal to War Room Add real-time GPS status dots on driver names throughout the Command Center: - Green pulsing dot for drivers seen within 10 minutes, gray for inactive - Clickable dots open a satellite map modal centered on the driver's position - GPS dots appear in Active NOW cards, Upcoming cards, and In Use vehicles - Replace Quick Actions panel with Active Drivers panel showing GPS-active drivers with speed and last seen time, with compact quick-link icons below - New DriverLocationModal shows Leaflet satellite map at zoom 16 with speed, heading, battery, and last seen info grid Co-Authored-By: Claude Opus 4.5 --- .../src/components/DriverLocationModal.tsx | 147 +++++++++++++++++ frontend/src/components/GpsIndicatorDot.tsx | 37 +++++ frontend/src/pages/CommandCenter.tsx | 148 +++++++++++++----- 3 files changed, 294 insertions(+), 38 deletions(-) create mode 100644 frontend/src/components/DriverLocationModal.tsx create mode 100644 frontend/src/components/GpsIndicatorDot.tsx diff --git a/frontend/src/components/DriverLocationModal.tsx b/frontend/src/components/DriverLocationModal.tsx new file mode 100644 index 0000000..60ddad2 --- /dev/null +++ b/frontend/src/components/DriverLocationModal.tsx @@ -0,0 +1,147 @@ +import { X, Navigation, Battery, Compass, Clock } from 'lucide-react'; +import { MapContainer, TileLayer, Marker } from 'react-leaflet'; +import L from 'leaflet'; +import 'leaflet/dist/leaflet.css'; +import { formatDistanceToNow } from 'date-fns'; +import type { DriverLocation } from '@/types/gps'; + +interface DriverLocationModalProps { + driverLocation: DriverLocation | null; + isOpen: boolean; + onClose: () => void; +} + +// Custom driver marker icon (same as GpsTracking page) +const createDriverIcon = () => { + return L.divIcon({ + className: 'custom-driver-marker', + html: ` +
+ + + +
+ `, + iconSize: [32, 32], + iconAnchor: [16, 32], + popupAnchor: [0, -32], + }); +}; + +function getCourseDirection(course: number | null): string { + if (course === null || course === undefined) return 'N/A'; + const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; + const index = Math.round(course / 45) % 8; + return `${directions[index]} (${Math.round(course)}°)`; +} + +export function DriverLocationModal({ driverLocation, isOpen, onClose }: DriverLocationModalProps) { + if (!isOpen || !driverLocation) return null; + + const hasLocation = driverLocation.location !== null; + + return ( +
+
+ {/* Header */} +
+
+

{driverLocation.driverName}

+ {driverLocation.driverPhone && ( +

{driverLocation.driverPhone}

+ )} +
+ +
+ + {/* Map */} + {hasLocation ? ( + <> +
+ + + + +
+ + {/* Info Grid */} +
+
+ +
+

Speed

+

+ {driverLocation.location!.speed?.toFixed(1) || '0'} mph +

+
+
+
+ +
+

Heading

+

+ {getCourseDirection(driverLocation.location!.course)} +

+
+
+
+ +
+

Battery

+

+ {driverLocation.location!.battery !== null + ? `${Math.round(driverLocation.location!.battery)}%` + : 'N/A'} +

+
+
+
+ +
+

Last Seen

+

+ {formatDistanceToNow(new Date(driverLocation.location!.timestamp), { addSuffix: true })} +

+
+
+
+ + ) : ( +
+ +

No location data available for this driver.

+

The driver may not have reported their position yet.

+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/GpsIndicatorDot.tsx b/frontend/src/components/GpsIndicatorDot.tsx new file mode 100644 index 0000000..6d3c6e7 --- /dev/null +++ b/frontend/src/components/GpsIndicatorDot.tsx @@ -0,0 +1,37 @@ +import type { DriverLocation } from '@/types/gps'; + +interface GpsIndicatorDotProps { + driverLocation: DriverLocation | undefined; + onClick: (loc: DriverLocation) => void; + className?: string; +} + +const TEN_MINUTES = 10 * 60 * 1000; + +export function GpsIndicatorDot({ driverLocation, onClick, className = '' }: GpsIndicatorDotProps) { + // Don't render if driver has no GPS enrollment + if (!driverLocation) return null; + + const isRecentlyActive = driverLocation.lastActive && + (Date.now() - new Date(driverLocation.lastActive).getTime()) < TEN_MINUTES; + + return ( + + ); +} diff --git a/frontend/src/pages/CommandCenter.tsx b/frontend/src/pages/CommandCenter.tsx index fd543be..7e5944c 100644 --- a/frontend/src/pages/CommandCenter.tsx +++ b/frontend/src/pages/CommandCenter.tsx @@ -17,11 +17,16 @@ import { Activity, Calendar, } from 'lucide-react'; -import { useEffect, useState, useRef } from 'react'; +import { useEffect, useState, useRef, useMemo } from 'react'; import { Link } from 'react-router-dom'; +import { formatDistanceToNow } from 'date-fns'; import { DriverChatBubble } from '@/components/DriverChatBubble'; import { DriverChatModal } from '@/components/DriverChatModal'; +import { GpsIndicatorDot } from '@/components/GpsIndicatorDot'; +import { DriverLocationModal } from '@/components/DriverLocationModal'; import { useUnreadCounts, useDriverResponseCheck } from '@/hooks/useSignalMessages'; +import { useDriverLocations } from '@/hooks/useGps'; +import type { DriverLocation } from '@/types/gps'; interface Event { id: string; @@ -89,6 +94,8 @@ export function CommandCenter() { const [lastRefresh, setLastRefresh] = useState(new Date()); const [chatDriver, setChatDriver] = useState(null); const [isChatOpen, setIsChatOpen] = useState(false); + const [locationDriver, setLocationDriver] = useState(null); + const [isLocationOpen, setIsLocationOpen] = useState(false); // Refs for smooth auto-scrolling const activeScrollRef = useRef(null); @@ -121,6 +128,24 @@ export function CommandCenter() { setChatDriver(null); }; + const openLocationModal = (loc: DriverLocation) => { + setLocationDriver(loc); + setIsLocationOpen(true); + }; + + const closeLocationModal = () => { + setIsLocationOpen(false); + setLocationDriver(null); + }; + + // GPS driver locations + const { data: driverLocations } = useDriverLocations(); + const driverLocationMap = useMemo(() => { + const map = new Map(); + driverLocations?.forEach(loc => map.set(loc.driverId, loc)); + return map; + }, [driverLocations]); + const { data: events, refetch: refetchEvents } = useQuery({ queryKey: ['events'], queryFn: async () => { @@ -643,6 +668,11 @@ export function CommandCenter() { {trip.driver.name} + {trip.driver.name} + openChat(trip.driver!)} @@ -877,8 +912,14 @@ export function CommandCenter() {

{vehicle.name}

-

+

{activeEvent?.driver?.name || 'Driver'} + {activeEvent?.driver && ( + + )}

); @@ -888,42 +929,66 @@ export function CommandCenter() { - {/* Quick Links */} -
-
-

Quick Actions

-
-
- - - Events - - - - Drivers - - - - VIPs - - - - Vehicles - -
-
+ {/* Active Drivers (GPS) */} + {(() => { + const TEN_MINUTES = 10 * 60 * 1000; + const activeDrivers = (driverLocations || []).filter(loc => + loc.lastActive && (Date.now() - new Date(loc.lastActive).getTime()) < TEN_MINUTES && loc.location + ); + return ( +
+
+
+ +

Active Drivers

+
+ {activeDrivers.length} +
+
+ {activeDrivers.length === 0 ? ( +
+

No active GPS drivers

+
+ ) : ( +
+ {activeDrivers.map((loc) => ( + + ))} +
+ )} +
+ {/* Quick Action Links - compact icon row */} +
+ + + + + + + + + + + + +
+
+ ); + })()} {/* Driver Chat Modal */} @@ -932,6 +997,13 @@ export function CommandCenter() { isOpen={isChatOpen} onClose={closeChat} /> + + {/* Driver Location Modal */} + ); }