fix tracker

This commit is contained in:
natib21
2026-07-07 06:39:02 +00:00
parent 8328543686
commit 75427ea63a
15 changed files with 1148 additions and 337 deletions

View File

@@ -1,368 +1,391 @@
import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Container, Grid, Card, Stack, Group, Select, Text, Badge, Button, Box, Table, SimpleGrid } from '@mantine/core';
import { MapPin, Navigation, Radio, Activity } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
import { vehiclesService } from '@/services/vehicles.service';
import { freightBrand } from '@/theme/freight-brand';
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Container,
Grid,
Group,
Modal,
Select,
SimpleGrid,
Stack,
Table,
Text,
TextInput,
} from "@mantine/core";
import {
APIProvider,
Map as GoogleMap,
Marker,
useMap,
} from "@vis.gl/react-google-maps";
import { Activity, Plus, Radio, Trash2 } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { useToast } from "@/hooks/use-toast";
import { vehiclesService } from "@/services/vehicles.service";
import { gpsTrackingService, type GpsDevice } from "@/services/gps-tracking.service";
import { freightBrand } from "@/theme/freight-brand";
interface Vehicle {
id: string;
registrationNumber: string;
plateNumber: string;
manufacturer: string;
model: string;
status?: string;
// Same default key + env override the portal's LocationPicker uses.
const GOOGLE_MAPS_API_KEY =
import.meta.env.VITE_GOOGLE_MAPS_API_KEY ||
"AIzaSyBg4tN31-fgvH_2Ix_TPo6VSfOA2uA5CCI";
const DEFAULT_CENTER = { lat: 9.03, lng: 38.74 }; // Addis Ababa
const toNum = (v: number | string | null | undefined): number | null =>
v == null || v === "" ? null : Number(v);
const deviceLabel = (d: GpsDevice) =>
d.vehicle
? [d.vehicle.code, d.vehicle.plateNumber].filter(Boolean).join(" · ")
: d.name || d.imei;
const fmtTime = (iso?: string | null) => {
if (!iso) return "—";
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString();
};
const StatBox = ({ label, value }: { label: string; value: string }) => (
<Box p="sm" style={{ backgroundColor: "#f8f9fa", borderRadius: 8 }}>
<Text size="xs" c="dimmed">{label}</Text>
<Text fw={600} size="sm">{value}</Text>
</Box>
);
type LatLng = { lat: number; lng: number };
/** Fit the map to the current markers (or center on a single one). */
function FitBounds({ points }: { points: LatLng[] }) {
const map = useMap();
useEffect(() => {
if (!map || points.length === 0 || typeof google === "undefined") return;
if (points.length === 1) {
map.setCenter(points[0]);
map.setZoom(14);
return;
}
const b = new google.maps.LatLngBounds();
points.forEach((p) => b.extend(p));
map.fitBounds(b, 60);
}, [map, points]);
return null;
}
interface GPSLocation {
lat: number;
lng: number;
speed?: number;
heading?: number;
lastUpdate?: string;
/** Draw the selected vehicle's recent path as a polyline. */
function RouteTrail({ path }: { path: LatLng[] }) {
const map = useMap();
useEffect(() => {
if (!map || path.length < 2 || typeof google === "undefined") return;
const line = new google.maps.Polyline({
path,
strokeColor: freightBrand.primary,
strokeOpacity: 0.85,
strokeWeight: 4,
});
line.setMap(map);
return () => line.setMap(null);
}, [map, path]);
return null;
}
// Mock GPS data for demo (no real GPS backend exists — these are simulated values)
const generateMockGPS = (): GPSLocation => ({
lat: 9.0 + Math.random() * 0.5,
lng: 38.7 + Math.random() * 0.5,
speed: Math.floor(Math.random() * 120),
heading: Math.floor(Math.random() * 360),
lastUpdate: new Date(Date.now() - Math.random() * 300000).toLocaleTimeString(),
});
export function TrackingPage() {
const [selectedVehicleId, setSelectedVehicleId] = useState<string | null>(null);
const [mapCenter] = useState({ lat: 9.0, lng: 38.8 });
const { toast } = useToast();
const qc = useQueryClient();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [registerOpen, setRegisterOpen] = useState(false);
const [form, setForm] = useState({ imei: "", name: "", vehicleId: "" });
const { data: vehicles = [] } = useQuery({
queryKey: QUERY_KEYS.VEHICLES.list(),
queryFn: async () => {
const res = await vehiclesService.getAll({ limit: 1000 });
return res.data || [];
// Poll every 10s so the map tracks live movement.
const { data: devices = [] } = useQuery({
queryKey: ["gps", "devices"],
queryFn: async () => (await gpsTrackingService.listDevices()).data ?? [],
refetchInterval: 10_000,
});
const { data: vehiclesData } = useQuery({
queryKey: ["vehicles", "all"],
queryFn: async () => (await vehiclesService.getAll({ limit: 1000 })).data ?? [],
});
const vehicleOptions = useMemo(
() =>
(vehiclesData ?? []).map((v) => ({
value: v.id,
label: `${v.plateNumber} - ${v.manufacturer} ${v.model}`,
})),
[vehiclesData],
);
const positioned = useMemo(
() =>
devices
.map((d) => ({ d, lat: toNum(d.lastLat), lng: toNum(d.lastLng) }))
.filter((x): x is { d: GpsDevice; lat: number; lng: number } => x.lat != null && x.lng != null),
[devices],
);
const selected = devices.find((d) => d.id === selectedId) ?? null;
const onlineCount = devices.filter((d) => d.online).length;
// Route history for the selected device's vehicle (chronological trail).
const { data: history = [] } = useQuery({
queryKey: ["gps", "history", selected?.vehicleId],
queryFn: async () => (await gpsTrackingService.history(selected!.vehicleId!, 300)).data ?? [],
enabled: Boolean(selected?.vehicleId),
});
const trail = useMemo(
() => [...history].reverse().map((h) => ({ lat: Number(h.lat), lng: Number(h.lng) })),
[history],
);
const markerIcon = (d: GpsDevice, selectedFlag: boolean) => {
if (typeof google === "undefined") return undefined;
return {
path: "M 0,-9 L 6,9 L 0,4 L -6,9 Z", // arrow
rotation: d.lastCourse ?? 0,
fillColor: selectedFlag ? freightBrand.primary : d.online ? "#2f80ed" : "#95a5a6",
fillOpacity: 1,
strokeColor: "#ffffff",
strokeWeight: 1.5,
scale: 1.4,
} as google.maps.Symbol;
};
const registerMutation = useMutation({
mutationFn: () =>
gpsTrackingService.register({
imei: form.imei.trim(),
name: form.name.trim() || undefined,
vehicleId: form.vehicleId || null,
}),
onSuccess: () => {
toast({ title: "Tracker registered" });
setRegisterOpen(false);
setForm({ imei: "", name: "", vehicleId: "" });
void qc.invalidateQueries({ queryKey: ["gps", "devices"] });
},
onError: (err: unknown) => {
const description =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? "Failed";
toast({ title: "Registration failed", description, variant: "destructive" });
},
});
// Generate mock GPS data for each vehicle
const vehiclesWithGPS = useMemo(() => {
return (vehicles as Vehicle[]).map((v) => ({
...v,
gps: generateMockGPS(),
}));
}, [vehicles]);
const assignMutation = useMutation({
mutationFn: ({ id, vehicleId }: { id: string; vehicleId: string | null }) =>
gpsTrackingService.update(id, { vehicleId }),
onSuccess: () => {
toast({ title: "Tracker updated" });
void qc.invalidateQueries({ queryKey: ["gps", "devices"] });
},
onError: () => toast({ title: "Update failed", variant: "destructive" }),
});
// For demo: show all vehicles as trackable (or filter by ACTIVE if status data available)
const trackableVehicles = useMemo(
() => vehiclesWithGPS.slice(0, 10), // Limit to first 10 for demo
[vehiclesWithGPS]
);
const selectedVehicle = trackableVehicles.find(v => v.id === selectedVehicleId);
const vehicleOptions = useMemo(
() => trackableVehicles.map(v => ({ label: v.registrationNumber, value: v.id })),
[trackableVehicles]
);
// Map dimensions
const mapWidth = 800;
const mapHeight = 500;
const pixelsPerLat = mapHeight / 0.6;
const pixelsPerLng = mapWidth / 0.6;
const getMapCoords = (lat: number, lng: number) => ({
x: ((lng - (mapCenter.lng - 0.3)) * pixelsPerLng),
y: ((mapCenter.lat + 0.3 - lat) * pixelsPerLat),
const deleteMutation = useMutation({
mutationFn: (id: string) => gpsTrackingService.remove(id),
onSuccess: () => {
toast({ title: "Tracker removed" });
setSelectedId(null);
void qc.invalidateQueries({ queryKey: ["gps", "devices"] });
},
onError: () => toast({ title: "Delete failed", variant: "destructive" }),
});
return (
<Container size="xl" py="xl" px="lg">
<Breadcrumbs items={[{ label: 'Fleet' }, { label: 'Vehicle Tracking' }]} />
<Breadcrumbs items={[{ label: "Fleet" }, { label: "Vehicle Tracking" }]} />
<Stack gap="xl">
<Group justify="space-between">
<div>
<Group gap="xs" align="center">
<Text fw={700} size="xl">
Real-Time Vehicle Tracking
</Text>
<Badge color="yellow" variant="light">
Simulated GPS
</Badge>
</Group>
<Text c="dimmed" size="sm">
Monitor vehicle locations, speed, and status
</Text>
</div>
</Group>
<Group justify="space-between" mb="xl">
<div>
<Text fw={700} size="xl">Real-Time Vehicle Tracking</Text>
<Text c="dimmed" size="sm">Live GPS positions from GT06 trackers</Text>
</div>
<Button leftSection={<Plus size={16} />} color="edr-green" onClick={() => setRegisterOpen(true)}>
Register tracker
</Button>
</Group>
<Grid>
{/* Map Section */}
<Grid.Col span={{ base: 12, lg: 8 }}>
<Card withBorder p="lg">
<Card.Section p="md" withBorder>
<Group justify="space-between">
<Text fw={500}>Map View</Text>
<Group gap="xs">
<Badge color="edr-green" leftSection={<Radio size={12} />}>
{trackableVehicles.length} Tracked
</Badge>
</Group>
</Group>
</Card.Section>
<Card.Section p="md">
<Box style={{ overflowX: 'auto', maxWidth: '100%' }}>
<Box
pos="relative"
style={{
width: mapWidth,
height: mapHeight,
backgroundColor: '#f0f8f7',
border: `2px solid ${freightBrand.primary}`,
borderRadius: '8px',
overflow: 'hidden',
}}
<Grid>
{/* Map */}
<Grid.Col span={{ base: 12, lg: 8 }}>
<Card withBorder p="lg">
<Card.Section p="md" withBorder>
<Group justify="space-between">
<Text fw={500}>Live Map</Text>
<Badge color="edr-green" leftSection={<Radio size={12} />}>
{onlineCount} online · {positioned.length} located
</Badge>
</Group>
</Card.Section>
<Card.Section p="md">
<Box style={{ height: 500, width: "100%", borderRadius: 8, overflow: "hidden" }}>
<APIProvider apiKey={GOOGLE_MAPS_API_KEY}>
<GoogleMap
defaultCenter={DEFAULT_CENTER}
defaultZoom={7}
gestureHandling="greedy"
disableDefaultUI={false}
style={{ width: "100%", height: "100%" }}
>
{/* Grid background */}
<svg
width={mapWidth}
height={mapHeight}
style={{ position: 'absolute', top: 0, left: 0 }}
>
{/* Latitude lines */}
{[0, 1, 2, 3, 4, 5, 6].map(i => (
<line
key={`lat-${i}`}
x1={0}
y1={(i / 6) * mapHeight}
x2={mapWidth}
y2={(i / 6) * mapHeight}
stroke="#e0e0e0"
strokeWidth={1}
{positioned.map(({ d, lat, lng }) => (
<Marker
key={d.id}
position={{ lat, lng }}
title={deviceLabel(d)}
icon={markerIcon(d, d.id === selectedId)}
onClick={() => setSelectedId(d.id)}
/>
))}
{/* Longitude lines */}
{[0, 1, 2, 3, 4, 5, 6].map(i => (
<line
key={`lng-${i}`}
x1={(i / 6) * mapWidth}
y1={0}
x2={(i / 6) * mapWidth}
y2={mapHeight}
stroke="#e0e0e0"
strokeWidth={1}
/>
))}
</svg>
{/* Vehicle markers */}
{trackableVehicles.map((vehicle) => {
const coords = getMapCoords(vehicle.gps.lat, vehicle.gps.lng);
const isSelected = vehicle.id === selectedVehicleId;
return (
<Box
key={vehicle.id}
pos="absolute"
style={{
left: coords.x - 15,
top: coords.y - 15,
width: 30,
height: 30,
cursor: 'pointer',
zIndex: isSelected ? 100 : 10,
}}
onClick={() => setSelectedVehicleId(vehicle.id)}
title={vehicle.registrationNumber}
>
<Box
pos="absolute"
inset={0}
style={{
backgroundColor: isSelected ? freightBrand.primary : '#3498db',
borderRadius: '50%',
border: isSelected ? `3px solid ${freightBrand.primaryDark}` : 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontSize: '16px',
boxShadow: isSelected ? `0 0 0 8px ${freightBrand.ring}` : 'none',
}}
>
<Navigation size={16} />
</Box>
</Box>
);
})}
{/* Map labels */}
<Box pos="absolute" bottom={8} left={8} style={{ zIndex: 50 }}>
<Text size="xs" c="dimmed">
📍 Addis Ababa, Ethiopia
</Text>
</Box>
</Box>
</Box>
<Text size="xs" c="dimmed" mt="xs">
Simulated map coordinates, speed, and heading are demo values, not live GPS.
<FitBounds points={positioned.map((p) => ({ lat: p.lat, lng: p.lng }))} />
{selected?.vehicleId && trail.length > 1 && <RouteTrail path={trail} />}
</GoogleMap>
</APIProvider>
</Box>
{positioned.length === 0 && (
<Text size="sm" c="dimmed" ta="center" mt="sm">
No located trackers yet waiting for GPS fixes.
</Text>
</Card.Section>
</Card>
</Grid.Col>
{/* Sidebar */}
<Grid.Col span={{ base: 12, lg: 4 }}>
<Stack gap="md">
{/* Vehicle Selector */}
<Card withBorder p="lg">
<Stack gap="md">
<Select
label="Track Vehicle"
placeholder="Select a vehicle to track"
data={vehicleOptions}
value={selectedVehicleId}
onChange={setSelectedVehicleId}
searchable
/>
{selectedVehicle && (
<Box p="md" style={{ backgroundColor: freightBrand.mutedBg, borderRadius: '8px' }}>
<Stack gap="sm">
<div>
<Text size="sm" c="dimmed">
Registration
</Text>
<Text fw={600}>{selectedVehicle.registrationNumber}</Text>
</div>
<div>
<Text size="sm" c="dimmed">
Vehicle
</Text>
<Text fw={600}>
{selectedVehicle.manufacturer} {selectedVehicle.model}
</Text>
</div>
<div>
<Text size="sm" c="dimmed">
Status
</Text>
<Badge color={selectedVehicle.status === 'ACTIVE' ? 'edr-green' : 'gray'}>
{selectedVehicle.status || 'Unknown'}
</Badge>
</div>
</Stack>
</Box>
)}
</Stack>
</Card>
{/* GPS Details */}
{selectedVehicle && (
<Card withBorder p="lg">
<Stack gap="md">
<Group justify="space-between">
<Text fw={500}>GPS Location</Text>
<Badge color="edr-green" leftSection={<Activity size={12} />}>
Live
</Badge>
</Group>
<SimpleGrid cols={2} spacing="sm">
<Box p="sm" style={{ backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
<Text size="xs" c="dimmed">
Latitude
</Text>
<Text fw={600} size="sm">
{selectedVehicle.gps.lat.toFixed(4)}°
</Text>
</Box>
<Box p="sm" style={{ backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
<Text size="xs" c="dimmed">
Longitude
</Text>
<Text fw={600} size="sm">
{selectedVehicle.gps.lng.toFixed(4)}°
</Text>
</Box>
<Box p="sm" style={{ backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
<Text size="xs" c="dimmed">
Speed
</Text>
<Text fw={600} size="sm">
{selectedVehicle.gps.speed} km/h
</Text>
</Box>
<Box p="sm" style={{ backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
<Text size="xs" c="dimmed">
Heading
</Text>
<Text fw={600} size="sm">
{selectedVehicle.gps.heading}°
</Text>
</Box>
</SimpleGrid>
<div>
<Text size="xs" c="dimmed">
Last Update
</Text>
<Text fw={500}>{selectedVehicle.gps.lastUpdate}</Text>
</div>
<Button color="edr-green" fullWidth leftSection={<MapPin size={16} />}>
View Full History
</Button>
</Stack>
</Card>
)}
</Card.Section>
</Card>
</Grid.Col>
{/* Tracked Vehicles List */}
{/* Sidebar */}
<Grid.Col span={{ base: 12, lg: 4 }}>
<Stack gap="md">
{selected && (
<Card withBorder p="lg">
<Stack gap="md">
<Text fw={500}>Tracked Vehicles ({trackableVehicles.length})</Text>
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
<Table>
<Table.Tbody>
{trackableVehicles.map(v => (
<Table.Tr
key={v.id}
style={{
cursor: 'pointer',
backgroundColor: v.id === selectedVehicleId ? freightBrand.mutedBg : 'transparent',
}}
onClick={() => setSelectedVehicleId(v.id)}
>
<Table.Td>
<Stack gap={0}>
<Text size="sm" fw={600}>
{v.registrationNumber}
</Text>
<Text size="xs" c="dimmed">
{v.gps.speed} km/h
</Text>
</Stack>
</Table.Td>
<Table.Td align="right">
<Badge
color={v.status === 'ACTIVE' ? 'edr-green' : 'gray'}
size="sm"
>
{v.status || 'N/A'}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<Group justify="space-between">
<Text fw={500}>{deviceLabel(selected)}</Text>
<Group gap="xs">
<Badge color={selected.online ? "edr-green" : "gray"} leftSection={<Activity size={12} />}>
{selected.online ? "Live" : "Offline"}
</Badge>
<ActionIcon variant="subtle" color="red" aria-label="Remove tracker" onClick={() => deleteMutation.mutate(selected.id)}>
<Trash2 size={16} />
</ActionIcon>
</Group>
</Group>
<SimpleGrid cols={2} spacing="sm">
<StatBox label="Latitude" value={toNum(selected.lastLat)?.toFixed(5) ?? "—"} />
<StatBox label="Longitude" value={toNum(selected.lastLng)?.toFixed(5) ?? "—"} />
<StatBox label="Speed" value={`${toNum(selected.lastSpeed) ?? 0} km/h`} />
<StatBox label="Course" value={`${selected.lastCourse ?? 0}°`} />
<StatBox label="Voltage" value={selected.voltageLevel != null ? `${selected.voltageLevel}/6` : "—"} />
<StatBox label="GSM" value={selected.gsmLevel != null ? `${selected.gsmLevel}/4` : "—"} />
</SimpleGrid>
<div>
<Text size="xs" c="dimmed">IMEI</Text>
<Text fw={500} size="sm">{selected.imei}</Text>
</div>
<div>
<Text size="xs" c="dimmed">Last fix</Text>
<Text fw={500} size="sm">{fmtTime(selected.lastFixAt)}</Text>
</div>
{selected.vehicleId && (
<Text size="xs" c="dimmed">
Showing last {trail.length} fixes as a route trail.
</Text>
)}
<Select
label="Assigned vehicle"
placeholder="Unassigned"
data={vehicleOptions}
value={selected.vehicleId ?? null}
onChange={(v) => assignMutation.mutate({ id: selected.id, vehicleId: v })}
searchable
clearable
/>
</Stack>
</Card>
</Stack>
</Grid.Col>
</Grid>
</Stack>
)}
<Card withBorder p="lg">
<Stack gap="md">
<Text fw={500}>Trackers ({devices.length})</Text>
<div style={{ maxHeight: 340, overflowY: "auto" }}>
<Table>
<Table.Tbody>
{devices.map((d) => (
<Table.Tr
key={d.id}
style={{ cursor: "pointer", backgroundColor: d.id === selectedId ? freightBrand.mutedBg : "transparent" }}
onClick={() => setSelectedId(d.id)}
>
<Table.Td>
<Stack gap={0}>
<Text size="sm" fw={600}>{deviceLabel(d)}</Text>
<Text size="xs" c="dimmed">{toNum(d.lastSpeed) ?? 0} km/h · {fmtTime(d.lastFixAt)}</Text>
</Stack>
</Table.Td>
<Table.Td align="right">
<Badge color={d.online ? "edr-green" : "gray"} size="sm">{d.online ? "Live" : "Offline"}</Badge>
</Table.Td>
</Table.Tr>
))}
{devices.length === 0 && (
<Table.Tr>
<Table.Td colSpan={2}>
<Text size="sm" c="dimmed" ta="center" py="md">No trackers registered yet.</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</div>
</Stack>
</Card>
</Stack>
</Grid.Col>
</Grid>
{/* Register modal */}
<Modal opened={registerOpen} onClose={() => setRegisterOpen(false)} title="Register GPS tracker" radius="lg" centered>
<Stack gap="md">
<TextInput
label="IMEI"
placeholder="15-digit device IMEI"
required
value={form.imei}
onChange={(e) => setForm({ ...form, imei: e.currentTarget.value })}
/>
<TextInput
label="Name"
placeholder="Optional label"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.currentTarget.value })}
/>
<Select
label="Vehicle"
placeholder="Assign to a vehicle (optional)"
data={vehicleOptions}
value={form.vehicleId || null}
onChange={(v) => setForm({ ...form, vehicleId: v ?? "" })}
searchable
clearable
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setRegisterOpen(false)}>Cancel</Button>
<Button
loading={registerMutation.isPending}
disabled={!form.imei.trim()}
onClick={() => registerMutation.mutate()}
>
Register
</Button>
</Group>
</Stack>
</Modal>
</Container>
);
}
export default TrackingPage;

View File

@@ -0,0 +1,48 @@
import { api } from "@/auth/http";
export interface GpsDevice {
id: string;
imei: string;
name?: string | null;
vehicleId?: string | null;
vehicle?: {
id: string;
plateNumber?: string;
code?: string | null;
manufacturer?: string;
model?: string;
} | null;
status: string;
online: boolean;
lastSeenAt?: string | null;
lastLat?: number | string | null;
lastLng?: number | string | null;
lastSpeed?: number | string | null;
lastCourse?: number | null;
lastFixAt?: string | null;
voltageLevel?: number | null;
gsmLevel?: number | null;
}
export interface GpsPosition {
id: string;
lat: number | string;
lng: number | string;
speed: number | string;
course: number;
satellites: number;
gpsTime: string;
alarm: number;
}
export const gpsTrackingService = {
latest: () => api.get<GpsDevice[]>("/gps/positions/latest"),
listDevices: () => api.get<GpsDevice[]>("/gps/devices"),
history: (vehicleId: string, limit = 200) =>
api.get<GpsPosition[]>(`/gps/positions/${vehicleId}/history?limit=${limit}`),
register: (data: { imei: string; name?: string; vehicleId?: string | null }) =>
api.post<GpsDevice>("/gps/devices", data),
update: (id: string, data: { name?: string; vehicleId?: string | null }) =>
api.patch<GpsDevice>(`/gps/devices/${id}`, data),
remove: (id: string) => api.delete<void>(`/gps/devices/${id}`),
};