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>
1069 lines
49 KiB
TypeScript
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 © Esri — 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>
|
|
);
|
|
}
|