Files
vip-coordinator/frontend/src/pages/GpsTracking.tsx
kyle 4dbb899409 fix: improve GPS position sync reliability and add route trails (#21)
Backend:
- Increase sync overlap buffer from 5s to 30s to catch late-arriving positions
- Add position history endpoint GET /gps/locations/:driverId/history
- Add logging for position sync counts (returned vs inserted)

Frontend:
- Add useDriverLocationHistory hook for fetching position trails
- Draw Polyline route trails on GPS map for each tracked driver
- Historical positions shown as semi-transparent paths behind live markers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 16:42:41 +01:00

1069 lines
49 KiB
TypeScript

import { useState, useEffect, useRef, useMemo } from 'react';
import { MapContainer, TileLayer, Marker, Popup, Polyline, 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,
useDeviceQr,
useDriverLocationHistory,
} 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: `
<div style="
background-color: ${isActive ? '#22c55e' : '#94a3b8'};
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],
});
};
// Map auto-fit component — only fits bounds on initial load, not on every refresh
function MapFitBounds({ locations }: { locations: DriverLocation[] }) {
const map = useMap();
const hasFitted = useRef(false);
useEffect(() => {
if (hasFitted.current) return;
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 });
hasFitted.current = true;
}
}, [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<string>('');
const [enrollmentResult, setEnrollmentResult] = useState<any>(null);
const [showQrDriverId, setShowQrDriverId] = useState<string | null>(null);
const [selectedDriverForTrail, setSelectedDriverForTrail] = useState<string | null>(null);
const [showTrails, setShowTrails] = useState(true);
// Check admin access
if (backendUser?.role !== 'ADMINISTRATOR') {
return (
<div className="flex flex-col items-center justify-center h-[60vh] text-center">
<AlertCircle className="h-16 w-16 text-muted-foreground mb-4" />
<h2 className="text-2xl font-bold mb-2 text-foreground">Access Denied</h2>
<p className="text-muted-foreground">
Only Administrators can access GPS tracking.
</p>
</div>
);
}
// 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);
const { data: qrInfo, isLoading: qrLoading } = useDeviceQr(showQrDriverId);
// Fetch location history for trail display
// If a specific driver is selected, show only their trail. Otherwise, fetch for all active drivers.
const activeDriverIds = useMemo(() => {
return locations?.filter(l => l.location).map(l => l.driverId) || [];
}, [locations]);
// For simplicity, fetch history for the selected driver or the first active driver
const driverIdForTrail = selectedDriverForTrail || (showTrails ? activeDriverIds[0] : null);
const { data: locationHistory } = useDriverLocationHistory(driverIdForTrail, undefined, undefined);
// 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<Driver[]>({
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 <Loading message="Loading GPS status..." />;
}
// Check if Traccar needs setup
if (traccarStatus?.needsSetup && traccarStatus?.isAvailable) {
return (
<div className="max-w-2xl mx-auto">
<div className="bg-card border border-border rounded-lg shadow-soft p-8 text-center">
<div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-6">
<MapPin className="h-8 w-8 text-primary" />
</div>
<h2 className="text-2xl font-bold text-foreground mb-4">GPS Tracking Setup</h2>
<p className="text-muted-foreground mb-6">
The GPS tracking system needs to be configured before you can start tracking drivers.
This will set up the Traccar server connection.
</p>
<button
onClick={() => traccarSetup.mutate()}
disabled={traccarSetup.isPending}
className="inline-flex items-center px-6 py-3 bg-primary text-white font-medium rounded-lg hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{traccarSetup.isPending ? (
<>
<RefreshCw className="h-5 w-5 mr-2 animate-spin" />
Setting up...
</>
) : (
<>
<Settings className="h-5 w-5 mr-2" />
Complete Setup
</>
)}
</button>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-foreground">GPS Tracking</h1>
<p className="text-muted-foreground mt-1">Monitor driver locations in real-time</p>
</div>
<div className="flex gap-2">
<button
onClick={() => refetchLocations()}
className="inline-flex items-center px-4 py-2 border border-input rounded-md text-foreground bg-card hover:bg-accent transition-colors"
style={{ minHeight: '44px' }}
>
<RefreshCw className="h-5 w-5 mr-2" />
Refresh
</button>
{status?.traccarAvailable && (
<button
onClick={() => openTraccar.mutate()}
disabled={openTraccar.isPending}
className="inline-flex items-center px-4 py-2 bg-primary text-white rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors"
style={{ minHeight: '44px' }}
>
<ExternalLink className="h-5 w-5 mr-2" />
Traccar Admin
</button>
)}
</div>
</div>
{/* Traccar Status */}
{!status?.traccarAvailable && (
<div className="bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="flex items-center gap-3">
<AlertCircle className="h-6 w-6 text-red-600" />
<div>
<h3 className="font-semibold text-red-800 dark:text-red-200">Traccar Server Offline</h3>
<p className="text-sm text-red-700 dark:text-red-300">GPS tracking is unavailable until the server is online.</p>
</div>
</div>
</div>
)}
{/* Status Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-card border border-border rounded-lg shadow-soft p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Total Enrolled</p>
<p className="text-2xl font-bold text-foreground">{status?.enrolledDrivers || 0}</p>
</div>
<div className="h-12 w-12 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center">
<Users className="h-6 w-6 text-blue-600 dark:text-blue-400" />
</div>
</div>
</div>
<div className="bg-card border border-border rounded-lg shadow-soft p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Active Now</p>
<p className="text-2xl font-bold text-green-600 dark:text-green-400">{status?.activeDrivers || 0}</p>
</div>
<div className="h-12 w-12 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center">
<Activity className="h-6 w-6 text-green-600 dark:text-green-400" />
</div>
</div>
</div>
<div className="bg-card border border-border rounded-lg shadow-soft p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Update Interval</p>
<p className="text-2xl font-bold text-foreground">{status?.settings?.updateIntervalSeconds || 60}s</p>
</div>
<div className="h-12 w-12 bg-orange-100 dark:bg-orange-900/30 rounded-full flex items-center justify-center">
<Clock className="h-6 w-6 text-orange-600 dark:text-orange-400" />
</div>
</div>
</div>
<div className="bg-card border border-border rounded-lg shadow-soft p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Shift Hours</p>
<p className="text-lg font-bold text-foreground">{status?.settings?.shiftStartTime || '4:00'} - {status?.settings?.shiftEndTime || '1:00'}</p>
</div>
<div className="h-12 w-12 bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center">
<Clock className="h-6 w-6 text-purple-600 dark:text-purple-400" />
</div>
</div>
</div>
</div>
{/* Tabs */}
<div className="border-b border-border">
<div className="flex gap-1 -mb-px">
{[
{ 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 => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`px-4 py-3 text-sm font-medium transition-colors flex items-center gap-2 border-b-2 ${
activeTab === tab.id
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
<tab.icon className="h-4 w-4" />
{tab.label}
</button>
))}
</div>
</div>
{/* Tab Content */}
<div className="bg-card border border-border rounded-lg shadow-soft overflow-hidden">
{/* Map Tab */}
{activeTab === 'map' && (
<div>
<div className="h-[500px] relative">
{locationsLoading ? (
<div className="h-full flex items-center justify-center">
<Loading message="Loading driver locations..." />
</div>
) : (
<MapContainer
center={mapCenter}
zoom={10}
style={{ height: '100%', width: '100%' }}
>
<TileLayer
attribution='Tiles &copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community'
url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
maxZoom={19}
/>
{locations && <MapFitBounds locations={locations} />}
{/* Route trail polyline */}
{showTrails && locationHistory && locationHistory.length > 1 && (
<Polyline
positions={locationHistory.map(loc => [loc.latitude, loc.longitude])}
pathOptions={{
color: '#3b82f6',
weight: 3,
opacity: 0.6,
}}
/>
)}
{/* Current position markers */}
{locations?.filter(l => l.location).map((driver) => (
<Marker
key={driver.driverId}
position={[driver.location!.latitude, driver.location!.longitude]}
icon={createDriverIcon(true)}
>
<Popup>
<div className="min-w-[180px]">
<h3 className="font-semibold text-base">{driver.driverName}</h3>
{driver.driverPhone && (
<p className="text-xs text-gray-600">{driver.driverPhone}</p>
)}
<hr className="my-2" />
<div className="space-y-1 text-sm">
<div className="flex items-center gap-2">
<Navigation className="h-4 w-4 text-blue-500" />
<span>{driver.location?.speed?.toFixed(1) || 0} mph</span>
</div>
{driver.location?.battery && (
<div className="flex items-center gap-2">
<Battery className="h-4 w-4 text-green-500" />
<span>{Math.round(driver.location.battery)}%</span>
</div>
)}
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-gray-500" />
<span>{formatDistanceToNow(new Date(driver.location!.timestamp), { addSuffix: true })}</span>
</div>
</div>
<button
onClick={() => setSelectedDriverForTrail(driver.driverId)}
className="mt-2 w-full px-2 py-1 text-xs bg-blue-500 text-white rounded hover:bg-blue-600"
>
Show Route Trail
</button>
</div>
</Popup>
</Marker>
))}
</MapContainer>
)}
{/* Map Legend & Controls */}
<div className="absolute bottom-4 left-4 bg-card/90 backdrop-blur-sm border border-border rounded-lg p-3 z-[1000]">
<h4 className="text-sm font-medium text-foreground mb-2">Map Controls</h4>
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-500"></div>
<span className="text-xs text-muted-foreground">Active</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-gray-400"></div>
<span className="text-xs text-muted-foreground">Inactive</span>
</div>
<div className="flex items-center gap-2">
<div className="w-12 h-0.5 bg-blue-500 opacity-60"></div>
<span className="text-xs text-muted-foreground">Route Trail</span>
</div>
<hr className="border-border" />
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={showTrails}
onChange={(e) => setShowTrails(e.target.checked)}
className="rounded border-gray-300 text-primary focus:ring-primary"
/>
<span className="text-xs text-foreground">Show Trails</span>
</label>
{selectedDriverForTrail && (
<button
onClick={() => setSelectedDriverForTrail(null)}
className="text-xs text-primary hover:text-primary/80 underline w-full text-left"
>
Clear selected trail
</button>
)}
</div>
</div>
</div>
{/* Active Drivers List */}
<div className="p-4 border-t border-border">
<h3 className="font-semibold text-foreground mb-3">Active Drivers ({locations?.filter(l => l.location).length || 0})</h3>
{!locations || locations.filter(l => l.location).length === 0 ? (
<p className="text-muted-foreground text-center py-4">No active drivers reporting location</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{locations.filter(l => l.location).map((driver) => (
<div key={driver.driverId} className="bg-muted/30 rounded-lg p-3 flex items-center justify-between">
<div>
<p className="font-medium text-foreground">{driver.driverName}</p>
<p className="text-sm text-muted-foreground">
{driver.location?.speed?.toFixed(0) || 0} mph
</p>
</div>
<span className="px-2 py-1 text-xs font-medium rounded-full bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
Online
</span>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* Devices Tab */}
{activeTab === 'devices' && (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h3 className="text-lg font-semibold text-foreground">Enrolled Devices</h3>
<button
onClick={() => { setShowEnrollModal(true); setEnrollmentResult(null); }}
className="inline-flex items-center px-4 py-2 bg-primary text-white rounded-md hover:bg-primary/90 transition-colors"
>
<UserPlus className="h-5 w-5 mr-2" />
Enroll Driver
</button>
</div>
{devicesLoading ? (
<Loading message="Loading devices..." />
) : devices && devices.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted/30">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase">Driver</th>
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase">Device ID</th>
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase">Status</th>
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase">Last Active</th>
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{devices.map((device: any) => (
<tr key={device.id} className="hover:bg-accent transition-colors">
<td className="px-4 py-3">
<p className="font-medium text-foreground">{device.driver?.name || 'Unknown'}</p>
{device.driver?.phone && <p className="text-sm text-muted-foreground">{device.driver.phone}</p>}
</td>
<td className="px-4 py-3 text-sm text-muted-foreground font-mono">{device.deviceIdentifier}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center px-2 py-1 text-xs font-medium rounded-full ${
device.isActive
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-400'
}`}>
{device.isActive ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
{device.lastActive ? formatDistanceToNow(new Date(device.lastActive), { addSuffix: true }) : 'Never'}
</td>
<td className="px-4 py-3 flex items-center gap-2">
<button
onClick={() => setShowQrDriverId(device.driverId)}
className="inline-flex items-center px-3 py-1 text-primary hover:text-primary/80 transition-colors"
title="Show QR code"
>
<QrCode className="h-4 w-4 mr-1" />
QR
</button>
<button
onClick={() => {
if (confirm(`Unenroll ${device.driver?.name} from GPS tracking?`)) {
unenrollDriver.mutate(device.driverId);
}
}}
disabled={unenrollDriver.isPending}
className="inline-flex items-center px-3 py-1 text-red-600 hover:text-red-800 transition-colors"
>
<UserMinus className="h-4 w-4 mr-1" />
Unenroll
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-12">
<Smartphone className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">No devices enrolled</h3>
<p className="text-muted-foreground mb-4">Start by enrolling drivers for GPS tracking</p>
</div>
)}
</div>
)}
{/* Stats Tab */}
{activeTab === 'stats' && (
<div className="p-6">
<h3 className="text-lg font-semibold text-foreground mb-4">Driver Statistics</h3>
<div className="mb-6">
<label className="block text-sm font-medium text-foreground mb-2">Select Driver</label>
<select
value={selectedDriverId}
onChange={(e) => setSelectedDriverId(e.target.value)}
className="w-full max-w-xs px-3 py-2 border border-input rounded-md bg-background text-foreground focus:ring-primary focus:border-primary"
>
<option value="">Choose a driver...</option>
{devices?.map((device: any) => (
<option key={device.driverId} value={device.driverId}>
{device.driver?.name || 'Unknown'}
</option>
))}
</select>
</div>
{selectedDriverId && driverStats ? (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-muted/30 rounded-lg p-4">
<div className="flex items-center gap-3">
<Route className="h-8 w-8 text-blue-500" />
<div>
<p className="text-2xl font-bold text-foreground">{driverStats.stats?.totalMiles || 0}</p>
<p className="text-sm text-muted-foreground">Miles Driven</p>
</div>
</div>
</div>
<div className="bg-muted/30 rounded-lg p-4">
<div className="flex items-center gap-3">
<Gauge className="h-8 w-8 text-red-500" />
<div>
<p className="text-2xl font-bold text-foreground">{driverStats.stats?.topSpeedMph || 0} mph</p>
<p className="text-sm text-muted-foreground">Top Speed</p>
</div>
</div>
</div>
<div className="bg-muted/30 rounded-lg p-4">
<div className="flex items-center gap-3">
<Navigation className="h-8 w-8 text-green-500" />
<div>
<p className="text-2xl font-bold text-foreground">{driverStats.stats?.averageSpeedMph || 0} mph</p>
<p className="text-sm text-muted-foreground">Avg Speed</p>
</div>
</div>
</div>
<div className="bg-muted/30 rounded-lg p-4">
<div className="flex items-center gap-3">
<Car className="h-8 w-8 text-purple-500" />
<div>
<p className="text-2xl font-bold text-foreground">{driverStats.stats?.totalTrips || 0}</p>
<p className="text-sm text-muted-foreground">Total Trips</p>
</div>
</div>
</div>
</div>
) : selectedDriverId ? (
<Loading message="Loading stats..." />
) : (
<p className="text-muted-foreground text-center py-8">Select a driver to view their statistics</p>
)}
</div>
)}
{/* Settings Tab */}
{activeTab === 'settings' && (
<div className="p-6 space-y-6">
<h3 className="text-lg font-semibold text-foreground">GPS Settings</h3>
{settingsLoading ? (
<Loading message="Loading settings..." />
) : settings ? (
<div className="grid gap-6 md:grid-cols-2">
<div className="bg-muted/30 rounded-lg p-4">
<label className="block text-sm font-medium text-foreground mb-2">
Update Interval (seconds)
</label>
<input
type="number"
min={10}
max={300}
defaultValue={settings.updateIntervalSeconds}
onBlur={(e) => {
const value = parseInt(e.target.value);
if (value >= 10 && 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"
/>
<p className="mt-1 text-xs text-muted-foreground">How often drivers report their location (10-300 seconds)</p>
<div className="mt-2 p-2 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 rounded text-xs text-amber-800 dark:text-amber-200">
<strong>Tip:</strong> 15-30s recommended for active events (smooth routes), 60s for routine use (saves battery). Changing this only affects new QR code enrollments.
</div>
</div>
<div className="bg-muted/30 rounded-lg p-4">
<label className="block text-sm font-medium text-foreground mb-2">
Data Retention (days)
</label>
<input
type="number"
min={7}
max={90}
defaultValue={settings.retentionDays}
onBlur={(e) => {
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"
/>
<p className="mt-1 text-xs text-muted-foreground">How long to keep location history (7-90 days)</p>
</div>
<div className="bg-muted/30 rounded-lg p-4 md:col-span-2">
<label className="block text-sm font-medium text-foreground mb-2">
Tracking Hours (Shift-Based)
</label>
<div className="flex items-center gap-4">
<div>
<label className="text-xs text-muted-foreground">Start</label>
<input
type="time"
defaultValue={`${String(settings.shiftStartHour).padStart(2, '0')}:${String(settings.shiftStartMinute).padStart(2, '0')}`}
onBlur={(e) => {
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"
/>
</div>
<span className="text-muted-foreground mt-6">to</span>
<div>
<label className="text-xs text-muted-foreground">End</label>
<input
type="time"
defaultValue={`${String(settings.shiftEndHour).padStart(2, '0')}:${String(settings.shiftEndMinute).padStart(2, '0')}`}
onBlur={(e) => {
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"
/>
</div>
</div>
<p className="mt-2 text-xs text-muted-foreground">
Drivers are only tracked during these hours.
</p>
</div>
</div>
) : (
<ErrorMessage message="Failed to load settings" />
)}
</div>
)}
</div>
{/* Enroll Driver Modal */}
{showEnrollModal && (
<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-md">
<div className="flex justify-between items-center p-4 border-b border-border">
<h3 className="text-lg font-semibold text-foreground">
{enrollmentResult ? 'Driver Enrolled!' : 'Enroll Driver for GPS'}
</h3>
<button
onClick={() => { setShowEnrollModal(false); setEnrollmentResult(null); }}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
{!enrollmentResult ? (
<>
<div className="p-4">
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-muted-foreground" />
<input
type="text"
placeholder="Search drivers..."
value={searchTerm}
onChange={(e) => 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"
/>
</div>
{filteredUnenrolled.length === 0 ? (
<div className="text-center py-8">
<Users className="h-10 w-10 text-muted-foreground mx-auto mb-3" />
<p className="text-muted-foreground">
{searchTerm ? 'No matching drivers found' : 'All drivers are already enrolled'}
</p>
</div>
) : (
<div className="max-h-64 overflow-y-auto space-y-2">
{filteredUnenrolled.map((driver) => (
<div
key={driver.id}
className="flex items-center justify-between p-3 bg-muted/30 rounded-lg hover:bg-muted/50 transition-colors"
>
<div>
<p className="font-medium text-foreground">{driver.name}</p>
<p className="text-sm text-muted-foreground">{driver.phone || 'No phone'}</p>
</div>
<button
onClick={() => handleEnroll(driver.id)}
disabled={enrollDriver.isPending}
className="inline-flex items-center px-3 py-1 text-sm bg-primary text-white rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
<UserPlus className="h-4 w-4 mr-1" />
Enroll
</button>
</div>
))}
</div>
)}
</div>
<div className="p-4 border-t border-border bg-muted/30">
<p className="text-xs text-muted-foreground">
<AlertCircle className="h-4 w-4 inline mr-1" />
Enrolling a driver will send them setup instructions via Signal message.
</p>
</div>
</>
) : (
<div className="p-4 space-y-4">
<div className="flex items-center gap-2 text-green-600">
<CheckCircle className="h-5 w-5" />
<span className="font-medium">Driver Enrolled Successfully!</span>
</div>
{enrollmentResult.signalMessageSent && (
<div className="bg-green-50 dark:bg-green-950/30 p-3 rounded-lg text-sm">
<CheckCircle className="h-4 w-4 inline mr-2 text-green-600" />
Setup instructions sent via Signal
</div>
)}
{/* QR Code - scan with Traccar Client app to auto-configure */}
<div className="bg-white dark:bg-gray-900 p-4 rounded-lg border border-border text-center">
<div className="flex items-center justify-center gap-2 mb-3">
<QrCode className="h-5 w-5 text-primary" />
<span className="text-sm font-medium">Scan with Traccar Client</span>
</div>
<div className="flex justify-center mb-3">
<QRCodeSVG
value={enrollmentResult.qrCodeUrl}
size={200}
level="M"
includeMargin
/>
</div>
<p className="text-xs text-muted-foreground">
Open Traccar Client app tap the QR icon scan this code
</p>
</div>
{/* Download links */}
<div className="bg-blue-50 dark:bg-blue-950/30 p-3 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-center gap-2 mb-2">
<Smartphone className="h-4 w-4 text-blue-600" />
<span className="text-sm font-medium text-blue-800 dark:text-blue-200">Download Traccar Client</span>
</div>
<div className="flex gap-2 justify-center">
<a
href="https://apps.apple.com/app/traccar-client/id843156974"
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700"
>
iOS App Store
</a>
<a
href="https://play.google.com/store/apps/details?id=org.traccar.client"
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1 bg-green-600 text-white text-xs rounded hover:bg-green-700"
>
Google Play
</a>
</div>
</div>
{/* Manual fallback - collapsible */}
<details className="bg-muted/30 rounded-lg border border-border">
<summary className="p-3 text-sm font-medium cursor-pointer hover:bg-muted/50 transition-colors">
Manual Setup (if QR doesn't work)
</summary>
<div className="px-3 pb-3 space-y-2">
<div>
<label className="text-xs text-muted-foreground">Device ID</label>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted p-2 rounded text-sm font-mono">
{enrollmentResult.deviceIdentifier}
</code>
<button
onClick={() => copyToClipboard(enrollmentResult.deviceIdentifier)}
className="p-2 hover:bg-muted rounded transition-colors"
>
<Copy className="h-4 w-4" />
</button>
</div>
</div>
<div>
<label className="text-xs text-muted-foreground">Server URL</label>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted p-2 rounded text-sm font-mono text-xs">
{enrollmentResult.serverUrl}
</code>
<button
onClick={() => copyToClipboard(enrollmentResult.serverUrl)}
className="p-2 hover:bg-muted rounded transition-colors"
>
<Copy className="h-4 w-4" />
</button>
</div>
</div>
<ol className="text-xs space-y-1 list-decimal list-inside text-muted-foreground mt-2">
<li>Open Traccar Client and enter Device ID and Server URL above</li>
<li>Set frequency to {settings?.updateIntervalSeconds || 60} seconds</li>
<li>Tap "Service Status" to start tracking</li>
</ol>
</div>
</details>
<button
onClick={() => { setShowEnrollModal(false); setEnrollmentResult(null); }}
className="w-full px-4 py-2 bg-primary text-white rounded-md hover:bg-primary/90 transition-colors"
>
Done
</button>
</div>
)}
</div>
</div>
)}
{/* Device QR Code Modal */}
{showQrDriverId && (
<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-md">
<div className="flex justify-between items-center p-4 border-b border-border">
<h3 className="text-lg font-semibold text-foreground">
{qrInfo ? `${qrInfo.driverName} - Setup QR` : 'Device QR Code'}
</h3>
<button
onClick={() => setShowQrDriverId(null)}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="p-4 space-y-4">
{qrLoading ? (
<Loading message="Loading QR code..." />
) : qrInfo ? (
<>
{/* QR Code */}
<div className="bg-white dark:bg-gray-900 p-4 rounded-lg border border-border text-center">
<div className="flex items-center justify-center gap-2 mb-3">
<QrCode className="h-5 w-5 text-primary" />
<span className="text-sm font-medium">Scan with Traccar Client</span>
</div>
<div className="flex justify-center mb-3">
<QRCodeSVG
value={qrInfo.qrCodeUrl}
size={200}
level="M"
includeMargin
/>
</div>
<p className="text-xs text-muted-foreground">
Open Traccar Client app {''} tap the QR icon {''} scan this code
</p>
</div>
{/* Download links */}
<div className="bg-blue-50 dark:bg-blue-950/30 p-3 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-center gap-2 mb-2">
<Smartphone className="h-4 w-4 text-blue-600" />
<span className="text-sm font-medium text-blue-800 dark:text-blue-200">Download Traccar Client</span>
</div>
<div className="flex gap-2 justify-center">
<a
href="https://apps.apple.com/app/traccar-client/id843156974"
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700"
>
iOS App Store
</a>
<a
href="https://play.google.com/store/apps/details?id=org.traccar.client"
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1 bg-green-600 text-white text-xs rounded hover:bg-green-700"
>
Google Play
</a>
</div>
</div>
{/* Manual fallback */}
<details className="bg-muted/30 rounded-lg border border-border">
<summary className="p-3 text-sm font-medium cursor-pointer hover:bg-muted/50 transition-colors">
Manual Setup (if QR doesn't work)
</summary>
<div className="px-3 pb-3 space-y-2">
<div>
<label className="text-xs text-muted-foreground">Device ID</label>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted p-2 rounded text-sm font-mono">
{qrInfo.deviceIdentifier}
</code>
<button
onClick={() => copyToClipboard(qrInfo.deviceIdentifier)}
className="p-2 hover:bg-muted rounded transition-colors"
>
<Copy className="h-4 w-4" />
</button>
</div>
</div>
<div>
<label className="text-xs text-muted-foreground">Server URL</label>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted p-2 rounded text-sm font-mono text-xs">
{qrInfo.serverUrl}
</code>
<button
onClick={() => copyToClipboard(qrInfo.serverUrl)}
className="p-2 hover:bg-muted rounded transition-colors"
>
<Copy className="h-4 w-4" />
</button>
</div>
</div>
<ol className="text-xs space-y-1 list-decimal list-inside text-muted-foreground mt-2">
<li>Open Traccar Client and enter Device ID and Server URL above</li>
<li>Set frequency to {qrInfo.updateIntervalSeconds} seconds</li>
<li>Tap "Service Status" to start tracking</li>
</ol>
</div>
</details>
</>
) : (
<ErrorMessage message="Failed to load QR code info" />
)}
<button
onClick={() => setShowQrDriverId(null)}
className="w-full px-4 py-2 bg-primary text-white rounded-md hover:bg-primary/90 transition-colors"
>
Done
</button>
</div>
</div>
</div>
)}
</div>
);
}