diff --git a/backend/src/gps/gps.controller.ts b/backend/src/gps/gps.controller.ts index 8ebf708..66b6283 100644 --- a/backend/src/gps/gps.controller.ts +++ b/backend/src/gps/gps.controller.ts @@ -130,6 +130,21 @@ export class GpsController { return location; } + /** + * Get a driver's location history (for route trail display) + */ + @Get('locations/:driverId/history') + @Roles(Role.ADMINISTRATOR) + async getDriverLocationHistory( + @Param('driverId') driverId: string, + @Query('from') fromStr?: string, + @Query('to') toStr?: string, + ) { + const from = fromStr ? new Date(fromStr) : undefined; + const to = toStr ? new Date(toStr) : undefined; + return this.gpsService.getDriverLocationHistory(driverId, from, to); + } + /** * Get a driver's stats (Admin viewing any driver) */ diff --git a/backend/src/gps/gps.service.ts b/backend/src/gps/gps.service.ts index c7ef98b..da509dd 100644 --- a/backend/src/gps/gps.service.ts +++ b/backend/src/gps/gps.service.ts @@ -471,6 +471,49 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour} }; } + /** + * Get driver location history (for route trail display) + */ + async getDriverLocationHistory( + driverId: string, + fromDate?: Date, + toDate?: Date, + ): Promise { + const device = await this.prisma.gpsDevice.findUnique({ + where: { driverId }, + }); + + if (!device) { + throw new NotFoundException('Driver is not enrolled for GPS tracking'); + } + + // Default to last 4 hours if no date range specified + const to = toDate || new Date(); + const from = fromDate || new Date(to.getTime() - 4 * 60 * 60 * 1000); + + const locations = await this.prisma.gpsLocationHistory.findMany({ + where: { + deviceId: device.id, + timestamp: { + gte: from, + lte: to, + }, + }, + orderBy: { timestamp: 'asc' }, + }); + + return locations.map((loc) => ({ + latitude: loc.latitude, + longitude: loc.longitude, + altitude: loc.altitude, + speed: loc.speed, + course: loc.course, + accuracy: loc.accuracy, + battery: loc.battery, + timestamp: loc.timestamp, + })); + } + /** * Get driver's own stats (for driver self-view) */ @@ -605,28 +648,37 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour} }, }); - if (devices.length === 0) return; + if (devices.length === 0) { + this.logger.debug('[GPS Sync] No active devices to sync'); + return; + } const now = new Date(); + this.logger.log(`[GPS Sync] Starting sync for ${devices.length} active devices`); for (const device of devices) { try { - // Calculate "since" from device's last active time with 5s overlap buffer + // Calculate "since" from device's last active time with 30s overlap buffer + // (increased from 5s to catch late-arriving positions) // Falls back to 2 minutes ago if no lastActive const since = device.lastActive - ? new Date(device.lastActive.getTime() - 5000) + ? new Date(device.lastActive.getTime() - 30000) : new Date(now.getTime() - 120000); + this.logger.debug(`[GPS Sync] Fetching positions for device ${device.traccarDeviceId} since ${since.toISOString()}`); + const positions = await this.traccarClient.getPositionHistory( device.traccarDeviceId, since, now, ); + this.logger.log(`[GPS Sync] Device ${device.traccarDeviceId}: Retrieved ${positions.length} positions from Traccar`); + if (positions.length === 0) continue; // Batch insert with skipDuplicates (unique constraint on deviceId+timestamp) - await this.prisma.gpsLocationHistory.createMany({ + const insertResult = await this.prisma.gpsLocationHistory.createMany({ data: positions.map((p) => ({ deviceId: device.id, latitude: p.latitude, @@ -641,6 +693,13 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour} skipDuplicates: true, }); + const inserted = insertResult.count; + const skipped = positions.length - inserted; + this.logger.log( + `[GPS Sync] Device ${device.traccarDeviceId}: ` + + `Inserted ${inserted} new positions, skipped ${skipped} duplicates` + ); + // Update lastActive to the latest position timestamp const latestPosition = positions.reduce((latest, p) => new Date(p.deviceTime) > new Date(latest.deviceTime) ? p : latest @@ -650,9 +709,11 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour} data: { lastActive: new Date(latestPosition.deviceTime) }, }); } catch (error) { - this.logger.error(`Failed to sync positions for device ${device.traccarDeviceId}: ${error}`); + this.logger.error(`[GPS Sync] Failed to sync positions for device ${device.traccarDeviceId}: ${error}`); } } + + this.logger.log('[GPS Sync] Sync completed'); } /** diff --git a/frontend/src/hooks/useGps.ts b/frontend/src/hooks/useGps.ts index aa3346d..05d5f27 100644 --- a/frontend/src/hooks/useGps.ts +++ b/frontend/src/hooks/useGps.ts @@ -123,6 +123,24 @@ export function useDriverLocation(driverId: string) { }); } +/** + * Get driver location history (for route trail display) + */ +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); + const { data } = await api.get(`/gps/locations/${driverId}/history?${params}`); + return data; + }, + enabled: !!driverId, + refetchInterval: 30000, // Refresh every 30 seconds + }); +} + /** * Get driver stats */ diff --git a/frontend/src/pages/GpsTracking.tsx b/frontend/src/pages/GpsTracking.tsx index 5c4f800..de07806 100644 --- a/frontend/src/pages/GpsTracking.tsx +++ b/frontend/src/pages/GpsTracking.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef, useMemo } from 'react'; -import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet'; +import { MapContainer, TileLayer, Marker, Popup, Polyline, useMap } from 'react-leaflet'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import { QRCodeSVG } from 'qrcode.react'; @@ -40,6 +40,7 @@ import { useTraccarSetup, useOpenTraccarAdmin, useDeviceQr, + useDriverLocationHistory, } from '@/hooks/useGps'; import { Loading } from '@/components/Loading'; import { ErrorMessage } from '@/components/ErrorMessage'; @@ -115,6 +116,8 @@ export function GpsTracking() { const [selectedDriverId, setSelectedDriverId] = useState(''); const [enrollmentResult, setEnrollmentResult] = useState(null); const [showQrDriverId, setShowQrDriverId] = useState(null); + const [selectedDriverForTrail, setSelectedDriverForTrail] = useState(null); + const [showTrails, setShowTrails] = useState(true); // Check admin access if (backendUser?.role !== 'ADMINISTRATOR') { @@ -138,6 +141,16 @@ export function GpsTracking() { 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(); @@ -368,6 +381,20 @@ export function GpsTracking() { maxZoom={19} /> {locations && } + + {/* Route trail polyline */} + {showTrails && locationHistory && locationHistory.length > 1 && ( + [loc.latitude, loc.longitude])} + pathOptions={{ + color: '#3b82f6', + weight: 3, + opacity: 0.6, + }} + /> + )} + + {/* Current position markers */} {locations?.filter(l => l.location).map((driver) => ( {formatDistanceToNow(new Date(driver.location!.timestamp), { addSuffix: true })} + @@ -404,18 +437,40 @@ export function GpsTracking() { )} - {/* Map Legend */} + {/* Map Legend & Controls */}
-

Legend

-
+

Map Controls

+
- Active + Active
- Inactive + Inactive
+
+
+ Route Trail +
+
+ + {selectedDriverForTrail && ( + + )}