diff --git a/frontend/src/pages/GpsTracking.tsx b/frontend/src/pages/GpsTracking.tsx new file mode 100644 index 0000000..89699b2 --- /dev/null +++ b/frontend/src/pages/GpsTracking.tsx @@ -0,0 +1,853 @@ +import { useState, useEffect, useMemo } from 'react'; +import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet'; +import L from 'leaflet'; +import 'leaflet/dist/leaflet.css'; +import { QRCodeSVG } from 'qrcode.react'; +import { + MapPin, + Settings, + Users, + RefreshCw, + Navigation, + Battery, + Clock, + ExternalLink, + X, + Search, + AlertCircle, + CheckCircle, + XCircle, + UserPlus, + UserMinus, + Gauge, + Activity, + Smartphone, + Route, + Car, + Copy, + QrCode, +} from 'lucide-react'; +import { + useGpsStatus, + useGpsSettings, + useUpdateGpsSettings, + useDriverLocations, + useGpsDevices, + useEnrollDriver, + useUnenrollDriver, + useDriverStats, + useTraccarSetupStatus, + useTraccarSetup, + useOpenTraccarAdmin, +} from '@/hooks/useGps'; +import { Loading } from '@/components/Loading'; +import { ErrorMessage } from '@/components/ErrorMessage'; +import { useQuery } from '@tanstack/react-query'; +import { api } from '@/lib/api'; +import { useAuth } from '@/contexts/AuthContext'; +import type { Driver } from '@/types'; +import type { DriverLocation } from '@/types/gps'; +import toast from 'react-hot-toast'; +import { formatDistanceToNow } from 'date-fns'; + +// Fix Leaflet default marker icons +delete (L.Icon.Default.prototype as any)._getIconUrl; +L.Icon.Default.mergeOptions({ + iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png', + iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png', + shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png', +}); + +// Custom driver marker icon +const createDriverIcon = (isActive: boolean) => { + return L.divIcon({ + className: 'custom-driver-marker', + html: ` +
+ + + +
+ `, + iconSize: [32, 32], + iconAnchor: [16, 32], + popupAnchor: [0, -32], + }); +}; + +// Map auto-fit component +function MapFitBounds({ locations }: { locations: DriverLocation[] }) { + const map = useMap(); + + useEffect(() => { + const validLocations = locations.filter(d => d.location); + if (validLocations.length > 0) { + const bounds = L.latLngBounds( + validLocations.map(loc => [loc.location!.latitude, loc.location!.longitude]) + ); + map.fitBounds(bounds, { padding: [50, 50], maxZoom: 14 }); + } + }, [locations, map]); + + return null; +} + +export function GpsTracking() { + const { backendUser } = useAuth(); + const [activeTab, setActiveTab] = useState<'map' | 'devices' | 'settings' | 'stats'>('map'); + const [showEnrollModal, setShowEnrollModal] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + const [selectedDriverId, setSelectedDriverId] = useState(''); + const [enrollmentResult, setEnrollmentResult] = useState(null); + + // Check admin access + if (backendUser?.role !== 'ADMINISTRATOR') { + return ( +
+ +

Access Denied

+

+ Only Administrators can access GPS tracking. +

+
+ ); + } + + // Data hooks + const { data: status, isLoading: statusLoading } = useGpsStatus(); + const { data: settings, isLoading: settingsLoading } = useGpsSettings(); + const { data: locations, isLoading: locationsLoading, refetch: refetchLocations } = useDriverLocations(); + const { data: devices, isLoading: devicesLoading } = useGpsDevices(); + const { data: traccarStatus } = useTraccarSetupStatus(); + const { data: driverStats } = useDriverStats(selectedDriverId); + + // Mutations + const updateSettings = useUpdateGpsSettings(); + const traccarSetup = useTraccarSetup(); + const openTraccar = useOpenTraccarAdmin(); + const enrollDriver = useEnrollDriver(); + const unenrollDriver = useUnenrollDriver(); + + // Get all drivers for enrollment + const { data: allDrivers } = useQuery({ + queryKey: ['drivers'], + queryFn: async () => { + const { data } = await api.get('/drivers'); + return data; + }, + }); + + // Filter unenrolled drivers + const enrolledDriverIds = new Set(devices?.map((d: any) => d.driverId) || []); + const unenrolledDrivers = allDrivers?.filter(d => !enrolledDriverIds.has(d.id)) || []; + const filteredUnenrolled = unenrolledDrivers.filter(d => + d.name.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + // Calculate center for map + const mapCenter: [number, number] = useMemo(() => { + const validLocations = locations?.filter(l => l.location) || []; + if (validLocations.length > 0) { + return [ + validLocations.reduce((sum, loc) => sum + loc.location!.latitude, 0) / validLocations.length, + validLocations.reduce((sum, loc) => sum + loc.location!.longitude, 0) / validLocations.length, + ]; + } + return [36.0, -79.0]; // Default to North Carolina area + }, [locations]); + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + toast.success('Copied to clipboard'); + }; + + const handleEnroll = async (driverId: string) => { + try { + const result = await enrollDriver.mutateAsync({ driverId, sendSignalMessage: true }); + setEnrollmentResult(result); + } catch (error) { + // Error handled by hook + } + }; + + if (statusLoading) { + return ; + } + + // Check if Traccar needs setup + if (traccarStatus?.needsSetup && traccarStatus?.isAvailable) { + return ( +
+
+
+ +
+

GPS Tracking Setup

+

+ The GPS tracking system needs to be configured before you can start tracking drivers. + This will set up the Traccar server connection. +

+ +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

GPS Tracking

+

Monitor driver locations in real-time

+
+
+ + {status?.traccarAvailable && ( + + )} +
+
+ + {/* Traccar Status */} + {!status?.traccarAvailable && ( +
+
+ +
+

Traccar Server Offline

+

GPS tracking is unavailable until the server is online.

+
+
+
+ )} + + {/* Status Cards */} +
+
+
+
+

Total Enrolled

+

{status?.enrolledDrivers || 0}

+
+
+ +
+
+
+ +
+
+
+

Active Now

+

{status?.activeDrivers || 0}

+
+
+ +
+
+
+ +
+
+
+

Update Interval

+

{status?.settings?.updateIntervalSeconds || 60}s

+
+
+ +
+
+
+ +
+
+
+

Shift Hours

+

{status?.settings?.shiftStartTime || '4:00'} - {status?.settings?.shiftEndTime || '1:00'}

+
+
+ +
+
+
+
+ + {/* Tabs */} +
+
+ {[ + { id: 'map', label: 'Live Map', icon: MapPin }, + { id: 'devices', label: 'Devices', icon: Smartphone }, + { id: 'stats', label: 'Stats', icon: Gauge }, + { id: 'settings', label: 'Settings', icon: Settings }, + ].map(tab => ( + + ))} +
+
+ + {/* Tab Content */} +
+ {/* Map Tab */} + {activeTab === 'map' && ( +
+
+ {locationsLoading ? ( +
+ +
+ ) : ( + + + {locations && } + {locations?.filter(l => l.location).map((driver) => ( + + +
+

{driver.driverName}

+ {driver.driverPhone && ( +

{driver.driverPhone}

+ )} +
+
+
+ + {driver.location?.speed?.toFixed(1) || 0} mph +
+ {driver.location?.battery && ( +
+ + {Math.round(driver.location.battery)}% +
+ )} +
+ + {formatDistanceToNow(new Date(driver.location!.timestamp), { addSuffix: true })} +
+
+
+
+
+ ))} +
+ )} + + {/* Map Legend */} +
+

Legend

+
+
+
+ Active +
+
+
+ Inactive +
+
+
+
+ + {/* Active Drivers List */} +
+

Active Drivers ({locations?.filter(l => l.location).length || 0})

+ {!locations || locations.filter(l => l.location).length === 0 ? ( +

No active drivers reporting location

+ ) : ( +
+ {locations.filter(l => l.location).map((driver) => ( +
+
+

{driver.driverName}

+

+ {driver.location?.speed?.toFixed(0) || 0} mph +

+
+ + Online + +
+ ))} +
+ )} +
+
+ )} + + {/* Devices Tab */} + {activeTab === 'devices' && ( +
+
+

Enrolled Devices

+ +
+ + {devicesLoading ? ( + + ) : devices && devices.length > 0 ? ( +
+ + + + + + + + + + + + + {devices.map((device: any) => ( + + + + + + + + + ))} + +
DriverDevice IDStatusConsentLast ActiveActions
+

{device.driver?.name || 'Unknown'}

+ {device.driver?.phone &&

{device.driver.phone}

} +
{device.deviceIdentifier} + + {device.isActive ? 'Active' : 'Inactive'} + + + {device.consentGiven ? ( + + ) : ( + + )} + + {device.lastActive ? formatDistanceToNow(new Date(device.lastActive), { addSuffix: true }) : 'Never'} + + +
+
+ ) : ( +
+ +

No devices enrolled

+

Start by enrolling drivers for GPS tracking

+
+ )} +
+ )} + + {/* Stats Tab */} + {activeTab === 'stats' && ( +
+

Driver Statistics

+ +
+ + +
+ + {selectedDriverId && driverStats ? ( +
+
+
+ +
+

{driverStats.stats?.totalMiles || 0}

+

Miles Driven

+
+
+
+
+
+ +
+

{driverStats.stats?.topSpeedMph || 0} mph

+

Top Speed

+
+
+
+
+
+ +
+

{driverStats.stats?.averageSpeedMph || 0} mph

+

Avg Speed

+
+
+
+
+
+ +
+

{driverStats.stats?.totalTrips || 0}

+

Total Trips

+
+
+
+
+ ) : selectedDriverId ? ( + + ) : ( +

Select a driver to view their statistics

+ )} +
+ )} + + {/* Settings Tab */} + {activeTab === 'settings' && ( +
+

GPS Settings

+ + {settingsLoading ? ( + + ) : settings ? ( +
+
+ + { + const value = parseInt(e.target.value); + if (value >= 30 && value <= 300 && value !== settings.updateIntervalSeconds) { + updateSettings.mutate({ updateIntervalSeconds: value }); + } + }} + className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground focus:ring-primary focus:border-primary" + /> +

How often drivers report their location (30-300 seconds)

+
+ +
+ + { + const value = parseInt(e.target.value); + if (value >= 7 && value <= 90 && value !== settings.retentionDays) { + updateSettings.mutate({ retentionDays: value }); + } + }} + className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground focus:ring-primary focus:border-primary" + /> +

How long to keep location history (7-90 days)

+
+ +
+ +
+
+ + { + const [hours, minutes] = e.target.value.split(':').map(Number); + if (!isNaN(hours) && !isNaN(minutes)) { + updateSettings.mutate({ + shiftStartHour: hours, + shiftStartMinute: minutes, + }); + } + }} + className="mt-1 px-3 py-2 border border-input rounded-md bg-background text-foreground focus:ring-primary focus:border-primary" + /> +
+ to +
+ + { + const [hours, minutes] = e.target.value.split(':').map(Number); + if (!isNaN(hours) && !isNaN(minutes)) { + updateSettings.mutate({ + shiftEndHour: hours, + shiftEndMinute: minutes, + }); + } + }} + className="mt-1 px-3 py-2 border border-input rounded-md bg-background text-foreground focus:ring-primary focus:border-primary" + /> +
+
+

+ Drivers are only tracked during these hours. +

+
+
+ ) : ( + + )} +
+ )} +
+ + {/* Enroll Driver Modal */} + {showEnrollModal && ( +
+
+
+

+ {enrollmentResult ? 'Driver Enrolled!' : 'Enroll Driver for GPS'} +

+ +
+ + {!enrollmentResult ? ( + <> +
+
+ + setSearchTerm(e.target.value)} + className="w-full pl-10 pr-4 py-2 border border-input rounded-md bg-background text-foreground focus:ring-primary focus:border-primary" + /> +
+ + {filteredUnenrolled.length === 0 ? ( +
+ +

+ {searchTerm ? 'No matching drivers found' : 'All drivers are already enrolled'} +

+
+ ) : ( +
+ {filteredUnenrolled.map((driver) => ( +
+
+

{driver.name}

+

{driver.phone || 'No phone'}

+
+ +
+ ))} +
+ )} +
+ +
+

+ + Enrolling a driver will send them setup instructions via Signal message. +

+
+ + ) : ( +
+
+ + Driver Enrolled Successfully! +
+ + {enrollmentResult.signalMessageSent && ( +
+ + Setup instructions sent via Signal +
+ )} + + {/* QR Code for Traccar Client app */} +
+
+ + Scan with Traccar Client +
+
+ {/* Traccar Client expects: serverUrl?id=xxx&interval=xxx&accuracy=high */} + +
+

+ Open Traccar Client → Settings → Scan QR Code +

+
+ +
+
+ +
+ + {enrollmentResult.deviceIdentifier} + + +
+
+
+ +
+ + {enrollmentResult.serverUrl} + + +
+
+
+ +
+

Driver Instructions:

+
    +
  1. Download "Traccar Client" from App Store or Play Store
  2. +
  3. Open app and enter the Device ID and Server URL
  4. +
  5. Set frequency to {settings?.updateIntervalSeconds || 60} seconds
  6. +
  7. Tap "Service Status" to start tracking
  8. +
+
+ + +
+ )} +
+
+ )} +
+ ); +}