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 <noreply@anthropic.com>
This commit is contained in:
147
frontend/src/components/DriverLocationModal.tsx
Normal file
147
frontend/src/components/DriverLocationModal.tsx
Normal file
@@ -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: `
|
||||||
|
<div style="
|
||||||
|
background-color: #22c55e;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid white;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="white">
|
||||||
|
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
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 (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-card border border-border rounded-lg shadow-lg w-full max-w-lg overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex justify-between items-center p-4 border-b border-border">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">{driverLocation.driverName}</h3>
|
||||||
|
{driverLocation.driverPhone && (
|
||||||
|
<p className="text-sm text-muted-foreground">{driverLocation.driverPhone}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors p-1 rounded hover:bg-accent"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Map */}
|
||||||
|
{hasLocation ? (
|
||||||
|
<>
|
||||||
|
<div className="h-[300px]">
|
||||||
|
<MapContainer
|
||||||
|
key={driverLocation.driverId}
|
||||||
|
center={[driverLocation.location!.latitude, driverLocation.location!.longitude]}
|
||||||
|
zoom={16}
|
||||||
|
style={{ height: '100%', width: '100%' }}
|
||||||
|
zoomControl={true}
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
attribution='Tiles © Esri'
|
||||||
|
url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
|
||||||
|
maxZoom={19}
|
||||||
|
/>
|
||||||
|
<Marker
|
||||||
|
position={[driverLocation.location!.latitude, driverLocation.location!.longitude]}
|
||||||
|
icon={createDriverIcon()}
|
||||||
|
/>
|
||||||
|
</MapContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Grid */}
|
||||||
|
<div className="p-4 grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Navigation className="h-4 w-4 text-blue-500 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Speed</p>
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
{driverLocation.location!.speed?.toFixed(1) || '0'} mph
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Compass className="h-4 w-4 text-indigo-500 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Heading</p>
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
{getCourseDirection(driverLocation.location!.course)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Battery className="h-4 w-4 text-green-500 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Battery</p>
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
{driverLocation.location!.battery !== null
|
||||||
|
? `${Math.round(driverLocation.location!.battery)}%`
|
||||||
|
: 'N/A'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="h-4 w-4 text-gray-500 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Last Seen</p>
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
{formatDistanceToNow(new Date(driverLocation.location!.timestamp), { addSuffix: true })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="p-8 text-center text-muted-foreground">
|
||||||
|
<Navigation className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||||
|
<p className="text-sm">No location data available for this driver.</p>
|
||||||
|
<p className="text-xs mt-1">The driver may not have reported their position yet.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
frontend/src/components/GpsIndicatorDot.tsx
Normal file
37
frontend/src/components/GpsIndicatorDot.tsx
Normal file
@@ -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 (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
onClick(driverLocation);
|
||||||
|
}}
|
||||||
|
className={`inline-flex items-center justify-center p-0.5 rounded-full transition-all hover:scale-125 ${className}`}
|
||||||
|
title={isRecentlyActive ? 'GPS active - click to view location' : 'GPS inactive - click for last known location'}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`block h-2.5 w-2.5 rounded-full ${
|
||||||
|
isRecentlyActive
|
||||||
|
? 'bg-green-500 animate-pulse shadow-[0_0_6px_rgba(34,197,94,0.6)]'
|
||||||
|
: 'bg-gray-400'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,11 +17,16 @@ import {
|
|||||||
Activity,
|
Activity,
|
||||||
Calendar,
|
Calendar,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useEffect, useState, useRef } from 'react';
|
import { useEffect, useState, useRef, useMemo } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { DriverChatBubble } from '@/components/DriverChatBubble';
|
import { DriverChatBubble } from '@/components/DriverChatBubble';
|
||||||
import { DriverChatModal } from '@/components/DriverChatModal';
|
import { DriverChatModal } from '@/components/DriverChatModal';
|
||||||
|
import { GpsIndicatorDot } from '@/components/GpsIndicatorDot';
|
||||||
|
import { DriverLocationModal } from '@/components/DriverLocationModal';
|
||||||
import { useUnreadCounts, useDriverResponseCheck } from '@/hooks/useSignalMessages';
|
import { useUnreadCounts, useDriverResponseCheck } from '@/hooks/useSignalMessages';
|
||||||
|
import { useDriverLocations } from '@/hooks/useGps';
|
||||||
|
import type { DriverLocation } from '@/types/gps';
|
||||||
|
|
||||||
interface Event {
|
interface Event {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -89,6 +94,8 @@ export function CommandCenter() {
|
|||||||
const [lastRefresh, setLastRefresh] = useState(new Date());
|
const [lastRefresh, setLastRefresh] = useState(new Date());
|
||||||
const [chatDriver, setChatDriver] = useState<Driver | null>(null);
|
const [chatDriver, setChatDriver] = useState<Driver | null>(null);
|
||||||
const [isChatOpen, setIsChatOpen] = useState(false);
|
const [isChatOpen, setIsChatOpen] = useState(false);
|
||||||
|
const [locationDriver, setLocationDriver] = useState<DriverLocation | null>(null);
|
||||||
|
const [isLocationOpen, setIsLocationOpen] = useState(false);
|
||||||
|
|
||||||
// Refs for smooth auto-scrolling
|
// Refs for smooth auto-scrolling
|
||||||
const activeScrollRef = useRef<HTMLDivElement>(null);
|
const activeScrollRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -121,6 +128,24 @@ export function CommandCenter() {
|
|||||||
setChatDriver(null);
|
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<string, DriverLocation>();
|
||||||
|
driverLocations?.forEach(loc => map.set(loc.driverId, loc));
|
||||||
|
return map;
|
||||||
|
}, [driverLocations]);
|
||||||
|
|
||||||
const { data: events, refetch: refetchEvents } = useQuery<Event[]>({
|
const { data: events, refetch: refetchEvents } = useQuery<Event[]>({
|
||||||
queryKey: ['events'],
|
queryKey: ['events'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -643,6 +668,11 @@ export function CommandCenter() {
|
|||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Users className="h-3 w-3" />
|
<Users className="h-3 w-3" />
|
||||||
{trip.driver.name}
|
{trip.driver.name}
|
||||||
|
<GpsIndicatorDot
|
||||||
|
driverLocation={driverLocationMap.get(trip.driver.id)}
|
||||||
|
onClick={openLocationModal}
|
||||||
|
className="ml-0.5"
|
||||||
|
/>
|
||||||
<DriverChatBubble
|
<DriverChatBubble
|
||||||
unreadCount={unreadCounts?.[trip.driver.id] || 0}
|
unreadCount={unreadCounts?.[trip.driver.id] || 0}
|
||||||
awaitingResponse={showAwaitingGlow}
|
awaitingResponse={showAwaitingGlow}
|
||||||
@@ -735,6 +765,11 @@ export function CommandCenter() {
|
|||||||
<span className="flex items-center gap-1 text-muted-foreground">
|
<span className="flex items-center gap-1 text-muted-foreground">
|
||||||
<Users className="h-3 w-3" />
|
<Users className="h-3 w-3" />
|
||||||
{trip.driver.name}
|
{trip.driver.name}
|
||||||
|
<GpsIndicatorDot
|
||||||
|
driverLocation={driverLocationMap.get(trip.driver.id)}
|
||||||
|
onClick={openLocationModal}
|
||||||
|
className="ml-0.5"
|
||||||
|
/>
|
||||||
<DriverChatBubble
|
<DriverChatBubble
|
||||||
unreadCount={unreadCounts?.[trip.driver.id] || 0}
|
unreadCount={unreadCounts?.[trip.driver.id] || 0}
|
||||||
onClick={() => openChat(trip.driver!)}
|
onClick={() => openChat(trip.driver!)}
|
||||||
@@ -877,8 +912,14 @@ export function CommandCenter() {
|
|||||||
<p className="text-xs font-medium text-foreground">{vehicle.name}</p>
|
<p className="text-xs font-medium text-foreground">{vehicle.name}</p>
|
||||||
<div className="h-2 w-2 rounded-full bg-blue-500 animate-pulse" />
|
<div className="h-2 w-2 rounded-full bg-blue-500 animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-muted-foreground truncate">
|
<p className="text-[10px] text-muted-foreground truncate flex items-center gap-1">
|
||||||
{activeEvent?.driver?.name || 'Driver'}
|
{activeEvent?.driver?.name || 'Driver'}
|
||||||
|
{activeEvent?.driver && (
|
||||||
|
<GpsIndicatorDot
|
||||||
|
driverLocation={driverLocationMap.get(activeEvent.driver.id)}
|
||||||
|
onClick={openLocationModal}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -888,42 +929,66 @@ export function CommandCenter() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Links */}
|
{/* Active Drivers (GPS) */}
|
||||||
<div className="bg-card border border-border rounded-lg shadow-soft overflow-hidden">
|
{(() => {
|
||||||
<div className="bg-gray-600 dark:bg-gray-700 px-3 py-2">
|
const TEN_MINUTES = 10 * 60 * 1000;
|
||||||
<h2 className="font-semibold text-white text-sm">Quick Actions</h2>
|
const activeDrivers = (driverLocations || []).filter(loc =>
|
||||||
</div>
|
loc.lastActive && (Date.now() - new Date(loc.lastActive).getTime()) < TEN_MINUTES && loc.location
|
||||||
<div className="p-2 grid grid-cols-2 gap-2">
|
);
|
||||||
<Link
|
return (
|
||||||
to="/events"
|
<div className="bg-card border border-border rounded-lg shadow-soft overflow-hidden">
|
||||||
className="flex items-center justify-center gap-1 px-2 py-2 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded text-xs font-medium hover:bg-blue-200 dark:hover:bg-blue-900/50"
|
<div className="bg-teal-600 px-3 py-2 flex items-center justify-between">
|
||||||
>
|
<div className="flex items-center gap-2">
|
||||||
<Calendar className="h-3 w-3" />
|
<Radio className="h-4 w-4 text-white" />
|
||||||
Events
|
<h2 className="font-semibold text-white text-sm">Active Drivers</h2>
|
||||||
</Link>
|
</div>
|
||||||
<Link
|
<span className="text-white/80 text-xs">{activeDrivers.length}</span>
|
||||||
to="/fleet?tab=drivers"
|
</div>
|
||||||
className="flex items-center justify-center gap-1 px-2 py-2 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded text-xs font-medium hover:bg-purple-200 dark:hover:bg-purple-900/50"
|
<div className="max-h-[108px] overflow-y-auto scrollbar-hide">
|
||||||
>
|
{activeDrivers.length === 0 ? (
|
||||||
<Users className="h-3 w-3" />
|
<div className="p-4 text-center text-muted-foreground">
|
||||||
Drivers
|
<p className="text-xs">No active GPS drivers</p>
|
||||||
</Link>
|
</div>
|
||||||
<Link
|
) : (
|
||||||
to="/vips"
|
<div className="divide-y divide-border">
|
||||||
className="flex items-center justify-center gap-1 px-2 py-2 bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 rounded text-xs font-medium hover:bg-emerald-200 dark:hover:bg-emerald-900/50"
|
{activeDrivers.map((loc) => (
|
||||||
>
|
<button
|
||||||
<Users className="h-3 w-3" />
|
key={loc.driverId}
|
||||||
VIPs
|
onClick={() => openLocationModal(loc)}
|
||||||
</Link>
|
className="w-full px-3 py-2 text-left hover:bg-accent/50 transition-colors"
|
||||||
<Link
|
>
|
||||||
to="/fleet?tab=vehicles"
|
<div className="flex items-center justify-between">
|
||||||
className="flex items-center justify-center gap-1 px-2 py-2 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 rounded text-xs font-medium hover:bg-orange-200 dark:hover:bg-orange-900/50"
|
<p className="text-xs font-medium text-foreground truncate">{loc.driverName}</p>
|
||||||
>
|
<span className="text-[10px] text-green-600 dark:text-green-400 font-medium flex-shrink-0">
|
||||||
<Car className="h-3 w-3" />
|
{loc.location?.speed?.toFixed(0) || 0} mph
|
||||||
Vehicles
|
</span>
|
||||||
</Link>
|
</div>
|
||||||
</div>
|
<p className="text-[10px] text-muted-foreground">
|
||||||
</div>
|
{loc.lastActive && formatDistanceToNow(new Date(loc.lastActive), { addSuffix: true })}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Quick Action Links - compact icon row */}
|
||||||
|
<div className="border-t border-border px-2 py-1.5 flex justify-around">
|
||||||
|
<Link to="/events" className="p-1.5 rounded hover:bg-accent transition-colors" title="Events">
|
||||||
|
<Calendar className="h-3.5 w-3.5 text-blue-600 dark:text-blue-400" />
|
||||||
|
</Link>
|
||||||
|
<Link to="/fleet?tab=drivers" className="p-1.5 rounded hover:bg-accent transition-colors" title="Drivers">
|
||||||
|
<Users className="h-3.5 w-3.5 text-purple-600 dark:text-purple-400" />
|
||||||
|
</Link>
|
||||||
|
<Link to="/vips" className="p-1.5 rounded hover:bg-accent transition-colors" title="VIPs">
|
||||||
|
<Users className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
|
||||||
|
</Link>
|
||||||
|
<Link to="/fleet?tab=vehicles" className="p-1.5 rounded hover:bg-accent transition-colors" title="Vehicles">
|
||||||
|
<Car className="h-3.5 w-3.5 text-orange-600 dark:text-orange-400" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Driver Chat Modal */}
|
{/* Driver Chat Modal */}
|
||||||
@@ -932,6 +997,13 @@ export function CommandCenter() {
|
|||||||
isOpen={isChatOpen}
|
isOpen={isChatOpen}
|
||||||
onClose={closeChat}
|
onClose={closeChat}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Driver Location Modal */}
|
||||||
|
<DriverLocationModal
|
||||||
|
driverLocation={locationDriver}
|
||||||
|
isOpen={isLocationOpen}
|
||||||
|
onClose={closeLocationModal}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user