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:
2026-02-03 22:50:24 +01:00
parent ca2b341f01
commit 714cac5d10
3 changed files with 294 additions and 38 deletions

View File

@@ -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<Driver | null>(null);
const [isChatOpen, setIsChatOpen] = useState(false);
const [locationDriver, setLocationDriver] = useState<DriverLocation | null>(null);
const [isLocationOpen, setIsLocationOpen] = useState(false);
// Refs for smooth auto-scrolling
const activeScrollRef = useRef<HTMLDivElement>(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<string, DriverLocation>();
driverLocations?.forEach(loc => map.set(loc.driverId, loc));
return map;
}, [driverLocations]);
const { data: events, refetch: refetchEvents } = useQuery<Event[]>({
queryKey: ['events'],
queryFn: async () => {
@@ -643,6 +668,11 @@ export function CommandCenter() {
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
{trip.driver.name}
<GpsIndicatorDot
driverLocation={driverLocationMap.get(trip.driver.id)}
onClick={openLocationModal}
className="ml-0.5"
/>
<DriverChatBubble
unreadCount={unreadCounts?.[trip.driver.id] || 0}
awaitingResponse={showAwaitingGlow}
@@ -735,6 +765,11 @@ export function CommandCenter() {
<span className="flex items-center gap-1 text-muted-foreground">
<Users className="h-3 w-3" />
{trip.driver.name}
<GpsIndicatorDot
driverLocation={driverLocationMap.get(trip.driver.id)}
onClick={openLocationModal}
className="ml-0.5"
/>
<DriverChatBubble
unreadCount={unreadCounts?.[trip.driver.id] || 0}
onClick={() => openChat(trip.driver!)}
@@ -877,8 +912,14 @@ export function CommandCenter() {
<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>
<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 && (
<GpsIndicatorDot
driverLocation={driverLocationMap.get(activeEvent.driver.id)}
onClick={openLocationModal}
/>
)}
</p>
</div>
);
@@ -888,42 +929,66 @@ export function CommandCenter() {
</div>
</div>
{/* Quick Links */}
<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">
<h2 className="font-semibold text-white text-sm">Quick Actions</h2>
</div>
<div className="p-2 grid grid-cols-2 gap-2">
<Link
to="/events"
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"
>
<Calendar className="h-3 w-3" />
Events
</Link>
<Link
to="/fleet?tab=drivers"
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"
>
<Users className="h-3 w-3" />
Drivers
</Link>
<Link
to="/vips"
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"
>
<Users className="h-3 w-3" />
VIPs
</Link>
<Link
to="/fleet?tab=vehicles"
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"
>
<Car className="h-3 w-3" />
Vehicles
</Link>
</div>
</div>
{/* 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 (
<div className="bg-card border border-border rounded-lg shadow-soft overflow-hidden">
<div className="bg-teal-600 px-3 py-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<Radio className="h-4 w-4 text-white" />
<h2 className="font-semibold text-white text-sm">Active Drivers</h2>
</div>
<span className="text-white/80 text-xs">{activeDrivers.length}</span>
</div>
<div className="max-h-[108px] overflow-y-auto scrollbar-hide">
{activeDrivers.length === 0 ? (
<div className="p-4 text-center text-muted-foreground">
<p className="text-xs">No active GPS drivers</p>
</div>
) : (
<div className="divide-y divide-border">
{activeDrivers.map((loc) => (
<button
key={loc.driverId}
onClick={() => openLocationModal(loc)}
className="w-full px-3 py-2 text-left hover:bg-accent/50 transition-colors"
>
<div className="flex items-center justify-between">
<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">
{loc.location?.speed?.toFixed(0) || 0} mph
</span>
</div>
<p className="text-[10px] text-muted-foreground">
{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>
{/* Driver Chat Modal */}
@@ -932,6 +997,13 @@ export function CommandCenter() {
isOpen={isChatOpen}
onClose={closeChat}
/>
{/* Driver Location Modal */}
<DriverLocationModal
driverLocation={locationDriver}
isOpen={isLocationOpen}
onClose={closeLocationModal}
/>
</div>
);
}