Routes now follow actual roads instead of cutting through buildings: - New OsrmService calls free OSRM Match API to snap GPS points to roads - Position history endpoint accepts ?matched=true for road-snapped geometry - Stats use OSRM road distance instead of Haversine crow-flies distance - Frontend shows solid blue polylines for matched routes, dashed for raw - Handles chunking (100 coord limit), rate limiting, graceful fallback - Distance badge shows accurate road miles on route trails Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
373 lines
10 KiB
TypeScript
373 lines
10 KiB
TypeScript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { api } from '@/lib/api';
|
|
import type {
|
|
DriverLocation,
|
|
GpsDevice,
|
|
DriverStats,
|
|
GpsStatus,
|
|
GpsSettings,
|
|
EnrollmentResponse,
|
|
MyGpsStatus,
|
|
DeviceQrInfo,
|
|
LocationHistoryResponse,
|
|
} from '@/types/gps';
|
|
import toast from 'react-hot-toast';
|
|
import { queryKeys } from '@/lib/query-keys';
|
|
|
|
// ============================================
|
|
// Admin GPS Hooks
|
|
// ============================================
|
|
|
|
/**
|
|
* Get GPS system status
|
|
*/
|
|
export function useGpsStatus() {
|
|
return useQuery<GpsStatus>({
|
|
queryKey: queryKeys.gps.status,
|
|
queryFn: async () => {
|
|
const { data } = await api.get('/gps/status');
|
|
return data;
|
|
},
|
|
refetchInterval: 30000, // Refresh every 30 seconds
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get GPS settings
|
|
*/
|
|
export function useGpsSettings() {
|
|
return useQuery<GpsSettings>({
|
|
queryKey: queryKeys.gps.settings,
|
|
queryFn: async () => {
|
|
const { data } = await api.get('/gps/settings');
|
|
return data;
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Update GPS settings
|
|
*/
|
|
export function useUpdateGpsSettings() {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: async (settings: Partial<GpsSettings>) => {
|
|
const { data } = await api.patch('/gps/settings', settings);
|
|
return data;
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.gps.settings });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.gps.status });
|
|
toast.success('GPS settings updated');
|
|
},
|
|
onError: (error: any) => {
|
|
toast.error(error.response?.data?.message || 'Failed to update GPS settings');
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get all enrolled GPS devices
|
|
*/
|
|
export function useGpsDevices() {
|
|
return useQuery<GpsDevice[]>({
|
|
queryKey: queryKeys.gps.devices,
|
|
queryFn: async () => {
|
|
const { data } = await api.get('/gps/devices');
|
|
return data;
|
|
},
|
|
refetchInterval: 30000, // Refresh every 30 seconds to update lastActive
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get QR code info for an enrolled device (on demand)
|
|
*/
|
|
export function useDeviceQr(driverId: string | null) {
|
|
return useQuery<DeviceQrInfo>({
|
|
queryKey: driverId ? queryKeys.gps.deviceQr(driverId) : ['gps', 'devices', null, 'qr'],
|
|
queryFn: async () => {
|
|
const { data } = await api.get(`/gps/devices/${driverId}/qr`);
|
|
return data;
|
|
},
|
|
enabled: !!driverId,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get all active driver locations (for map)
|
|
*/
|
|
export function useDriverLocations() {
|
|
return useQuery<DriverLocation[]>({
|
|
queryKey: queryKeys.gps.locations.all,
|
|
queryFn: async () => {
|
|
const { data } = await api.get('/gps/locations');
|
|
return data;
|
|
},
|
|
refetchInterval: 15000, // Refresh every 15 seconds
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get a specific driver's location
|
|
*/
|
|
export function useDriverLocation(driverId: string) {
|
|
return useQuery<DriverLocation>({
|
|
queryKey: queryKeys.gps.locations.detail(driverId),
|
|
queryFn: async () => {
|
|
const { data } = await api.get(`/gps/locations/${driverId}`);
|
|
return data;
|
|
},
|
|
enabled: !!driverId,
|
|
refetchInterval: 15000,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get driver location history (for route trail display)
|
|
* By default, requests road-snapped routes from OSRM map matching
|
|
*/
|
|
export function useDriverLocationHistory(driverId: string | null, from?: string, to?: string) {
|
|
return useQuery({
|
|
queryKey: ['gps', 'locations', driverId, 'history', from, to],
|
|
queryFn: async () => {
|
|
const params = new URLSearchParams();
|
|
if (from) params.append('from', from);
|
|
if (to) params.append('to', to);
|
|
params.append('matched', 'true'); // Request road-snapped route
|
|
const { data } = await api.get(`/gps/locations/${driverId}/history?${params}`);
|
|
return data;
|
|
},
|
|
enabled: !!driverId,
|
|
refetchInterval: 60000, // Match routes less frequently (60s) since OSRM has rate limits
|
|
staleTime: 30000, // Consider data fresh for 30s
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get driver stats
|
|
*/
|
|
export function useDriverStats(driverId: string, from?: string, to?: string) {
|
|
return useQuery<DriverStats>({
|
|
queryKey: queryKeys.gps.stats(driverId, from, to),
|
|
queryFn: async () => {
|
|
const params = new URLSearchParams();
|
|
if (from) params.append('from', from);
|
|
if (to) params.append('to', to);
|
|
const { data } = await api.get(`/gps/stats/${driverId}?${params.toString()}`);
|
|
return data;
|
|
},
|
|
enabled: !!driverId,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Enroll a driver for GPS tracking
|
|
*/
|
|
export function useEnrollDriver() {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation<EnrollmentResponse, Error, { driverId: string; sendSignalMessage?: boolean }>({
|
|
mutationFn: async ({ driverId, sendSignalMessage = true }) => {
|
|
const { data } = await api.post(`/gps/enroll/${driverId}`, { sendSignalMessage });
|
|
return data;
|
|
},
|
|
onSuccess: (data) => {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.gps.devices });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.gps.status });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.drivers.all });
|
|
if (data.signalMessageSent) {
|
|
toast.success('Driver enrolled! Setup instructions sent via Signal.');
|
|
} else {
|
|
toast.success('Driver enrolled! Share the setup instructions with them.');
|
|
}
|
|
},
|
|
onError: (error: any) => {
|
|
toast.error(error.response?.data?.message || 'Failed to enroll driver');
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Unenroll a driver from GPS tracking
|
|
*/
|
|
export function useUnenrollDriver() {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: async (driverId: string) => {
|
|
const { data } = await api.delete(`/gps/devices/${driverId}`);
|
|
return data;
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.gps.devices });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.gps.status });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.gps.locations.all });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.drivers.all });
|
|
toast.success('Driver unenrolled from GPS tracking');
|
|
},
|
|
onError: (error: any) => {
|
|
toast.error(error.response?.data?.message || 'Failed to unenroll driver');
|
|
},
|
|
});
|
|
}
|
|
|
|
// ============================================
|
|
// Driver Self-Service Hooks
|
|
// ============================================
|
|
|
|
/**
|
|
* Get my GPS enrollment status (for drivers)
|
|
*/
|
|
export function useMyGpsStatus() {
|
|
return useQuery<MyGpsStatus>({
|
|
queryKey: queryKeys.gps.me.status,
|
|
queryFn: async () => {
|
|
const { data } = await api.get('/gps/me');
|
|
return data;
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get my GPS stats (for drivers)
|
|
*/
|
|
export function useMyGpsStats(from?: string, to?: string) {
|
|
return useQuery<DriverStats>({
|
|
queryKey: queryKeys.gps.me.stats(from, to),
|
|
queryFn: async () => {
|
|
const params = new URLSearchParams();
|
|
if (from) params.append('from', from);
|
|
if (to) params.append('to', to);
|
|
const { data } = await api.get(`/gps/me/stats?${params.toString()}`);
|
|
return data;
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get my current location (for drivers)
|
|
*/
|
|
export function useMyLocation() {
|
|
return useQuery<DriverLocation>({
|
|
queryKey: queryKeys.gps.me.location,
|
|
queryFn: async () => {
|
|
const { data } = await api.get('/gps/me/location');
|
|
return data;
|
|
},
|
|
refetchInterval: 30000,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Confirm/revoke GPS tracking consent (for drivers)
|
|
*/
|
|
export function useUpdateGpsConsent() {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: async (consentGiven: boolean) => {
|
|
const { data } = await api.post('/gps/me/consent', { consentGiven });
|
|
return data;
|
|
},
|
|
onSuccess: (data) => {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.gps.me.status });
|
|
toast.success(data.message);
|
|
},
|
|
onError: (error: any) => {
|
|
toast.error(error.response?.data?.message || 'Failed to update consent');
|
|
},
|
|
});
|
|
}
|
|
|
|
// ============================================
|
|
// Traccar Admin Hooks
|
|
// ============================================
|
|
|
|
/**
|
|
* Check Traccar setup status
|
|
*/
|
|
export function useTraccarSetupStatus() {
|
|
return useQuery<{ needsSetup: boolean; isAvailable: boolean }>({
|
|
queryKey: queryKeys.gps.traccar.status,
|
|
queryFn: async () => {
|
|
const { data } = await api.get('/gps/traccar/status');
|
|
return data;
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Perform initial Traccar setup
|
|
*/
|
|
export function useTraccarSetup() {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: async () => {
|
|
const { data } = await api.post('/gps/traccar/setup');
|
|
return data;
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.gps.traccar.status });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.gps.status });
|
|
toast.success('Traccar setup complete!');
|
|
},
|
|
onError: (error: any) => {
|
|
toast.error(error.response?.data?.message || 'Failed to setup Traccar');
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Sync VIP admins to Traccar
|
|
*/
|
|
export function useSyncAdminsToTraccar() {
|
|
return useMutation({
|
|
mutationFn: async () => {
|
|
const { data } = await api.post('/gps/traccar/sync-admins');
|
|
return data;
|
|
},
|
|
onSuccess: (data: { synced: number; failed: number }) => {
|
|
toast.success(`Synced ${data.synced} admins to Traccar`);
|
|
},
|
|
onError: (error: any) => {
|
|
toast.error(error.response?.data?.message || 'Failed to sync admins');
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get Traccar admin URL for current user
|
|
*/
|
|
export function useTraccarAdminUrl() {
|
|
return useQuery<{ url: string; directAccess: boolean }>({
|
|
queryKey: queryKeys.gps.traccar.adminUrl,
|
|
queryFn: async () => {
|
|
const { data } = await api.get('/gps/traccar/admin-url');
|
|
return data;
|
|
},
|
|
enabled: false, // Only fetch when explicitly called
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Open Traccar admin (fetches URL and opens in new tab)
|
|
*/
|
|
export function useOpenTraccarAdmin() {
|
|
return useMutation({
|
|
mutationFn: async () => {
|
|
const { data } = await api.get('/gps/traccar/admin-url');
|
|
return data;
|
|
},
|
|
onSuccess: (data: { url: string; directAccess: boolean }) => {
|
|
// Open Traccar in new tab
|
|
window.open(data.url, '_blank');
|
|
},
|
|
onError: (error: any) => {
|
|
toast.error(error.response?.data?.message || 'Failed to open Traccar admin');
|
|
},
|
|
});
|
|
}
|