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, InfoWindow, Map as GoogleMap, Marker, useMap, } from "@vis.gl/react-google-maps"; import { Activity, Pencil, Plus, Radio, Trash2 } from "lucide-react"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { useToast } from "@/hooks/use-toast"; import { useAuth } from "@/auth/useAuth"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { vehiclesService } from "@/services/vehicles.service"; import { gpsTrackingService, type GpsDevice } from "@/services/gps-tracking.service"; import { freightBrand } from "@/theme/freight-brand"; // 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 }) => ( {label} {value} ); type LatLng = { lat: number; lng: number }; /** Flip a flag once the map (and thus the Maps JS classes) is loaded. */ function ReadyProbe({ onReady }: { onReady: () => void }) { const map = useMap(); useEffect(() => { if (map) onReady(); }, [map, onReady]); return null; } /** 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; } /** Lazily reverse-geocode a coordinate to a human address. */ function useAddress(lat: number, lng: number): string | null { const [addr, setAddr] = useState(null); useEffect(() => { if (typeof google === "undefined" || !google.maps?.Geocoder) return; setAddr(null); let cancelled = false; new google.maps.Geocoder().geocode({ location: { lat, lng } }, (res, status) => { if (cancelled) return; setAddr(status === "OK" && res?.[0] ? res[0].formatted_address : "Unknown location"); }); return () => { cancelled = true; }; }, [lat, lng]); return addr; } /** Hover popup: label, coordinates, speed/course, and the reverse-geocoded place. */ function HoverInfo({ device, lat, lng, onClose, }: { device: GpsDevice; lat: number; lng: number; onClose: () => void; }) { const address = useAddress(lat, lng); return ( {deviceLabel(device)} {lat.toFixed(5)}, {lng.toFixed(5)} {toNum(device.lastSpeed) ?? 0} km/h · {device.lastCourse ?? 0}° {address ?? "Locating…"} ); } /** 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; } export function TrackingPage() { const { toast } = useToast(); const qc = useQueryClient(); const { user } = useAuth(); const canManage = hasPermission(user, FREIGHT_PERMS.tracking.manage); const [selectedId, setSelectedId] = useState(null); const [hoverId, setHoverId] = useState(null); const [mapsReady, setMapsReady] = useState(false); const [modalOpen, setModalOpen] = useState(false); const [editDevice, setEditDevice] = useState(null); const [form, setForm] = useState({ imei: "", name: "", vehicleId: "" }); const openRegister = () => { setEditDevice(null); setForm({ imei: "", name: "", vehicleId: "" }); setModalOpen(true); }; const openEdit = (d: GpsDevice) => { setEditDevice(d); setForm({ imei: d.imei, name: d.name ?? "", vehicleId: d.vehicleId ?? "" }); setModalOpen(true); }; // 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], ); // Teardrop pin colored by state with a white truck glyph inside. const markerIcon = (d: GpsDevice, selectedFlag: boolean): google.maps.Icon | undefined => { // Maps API loads async — Size/Point classes may not exist yet at first render. if (typeof google === "undefined" || !google.maps?.Size || !mapsReady) return undefined; const color = selectedFlag ? freightBrand.primary : d.online ? "#2f80ed" : "#95a5a6"; const svg = ` `; return { url: `data:image/svg+xml,${encodeURIComponent(svg)}`, scaledSize: new google.maps.Size(40, 48), anchor: new google.maps.Point(20, 48), }; }; const saveMutation = useMutation({ mutationFn: () => editDevice ? gpsTrackingService.update(editDevice.id, { name: form.name.trim() || undefined, vehicleId: form.vehicleId || null, }) : gpsTrackingService.register({ imei: form.imei.trim(), name: form.name.trim() || undefined, vehicleId: form.vehicleId || null, }), onSuccess: () => { toast({ title: editDevice ? "Tracker updated" : "Tracker registered" }); setModalOpen(false); setEditDevice(null); 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: editDevice ? "Update failed" : "Registration failed", description, variant: "destructive" }); }, }); 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" }), }); 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 ( Real-Time Vehicle Tracking Live GPS positions from GT06 trackers {canManage && ( } color="edr-green" onClick={openRegister}> Register tracker )} {/* Map */} Live Map }> {onlineCount} online · {positioned.length} located setMapsReady(true)} /> {positioned.map(({ d, lat, lng }) => ( setSelectedId(d.id)} onMouseOver={() => setHoverId(d.id)} /> ))} {(() => { const h = positioned.find((p) => p.d.id === hoverId); return h ? ( setHoverId(null)} /> ) : null; })()} ({ lat: p.lat, lng: p.lng }))} /> {selected?.vehicleId && trail.length > 1 && } {positioned.length === 0 && ( No located trackers yet — waiting for GPS fixes. )} {/* Sidebar */} {selected && ( {deviceLabel(selected)} }> {selected.online ? "Live" : "Offline"} {canManage && ( deleteMutation.mutate(selected.id)}> )} IMEI {selected.imei} Last fix {fmtTime(selected.lastFixAt)} {selected.vehicleId && ( Showing last {trail.length} fixes as a route trail. )} assignMutation.mutate({ id: selected.id, vehicleId: v })} disabled={!canManage} searchable clearable /> )} Trackers ({devices.length}) {devices.map((d) => ( setSelectedId(d.id)} > {deviceLabel(d)} {toNum(d.lastSpeed) ?? 0} km/h · {fmtTime(d.lastFixAt)} {d.online ? "Live" : "Offline"} {canManage && ( { e.stopPropagation(); openEdit(d); }} > )} ))} {devices.length === 0 && ( No trackers registered yet. )} {/* Register / edit modal */} setModalOpen(false)} title={editDevice ? "Edit GPS tracker" : "Register GPS tracker"} radius="lg" centered > setForm({ ...form, imei: e.currentTarget.value })} /> setForm({ ...form, name: e.currentTarget.value })} /> setForm({ ...form, vehicleId: v ?? "" })} searchable clearable /> setModalOpen(false)}>Cancel saveMutation.mutate()} > {editDevice ? "Save" : "Register"} ); } export default TrackingPage;