mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 08:20:58 +00:00
512 lines
19 KiB
TypeScript
512 lines
19 KiB
TypeScript
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 }) => (
|
|
<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 };
|
|
|
|
/** 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<string | null>(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 (
|
|
<InfoWindow position={{ lat, lng }} pixelOffset={[0, -46]} onCloseClick={onClose}>
|
|
<div style={{ minWidth: 190, fontSize: 13 }}>
|
|
<div style={{ fontWeight: 600, marginBottom: 2 }}>{deviceLabel(device)}</div>
|
|
<div style={{ fontFamily: "monospace" }}>
|
|
{lat.toFixed(5)}, {lng.toFixed(5)}
|
|
</div>
|
|
<div style={{ color: "#555" }}>
|
|
{toNum(device.lastSpeed) ?? 0} km/h · {device.lastCourse ?? 0}°
|
|
</div>
|
|
<div style={{ color: "#777", marginTop: 4 }}>{address ?? "Locating…"}</div>
|
|
</div>
|
|
</InfoWindow>
|
|
);
|
|
}
|
|
|
|
/** 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<string | null>(null);
|
|
const [hoverId, setHoverId] = useState<string | null>(null);
|
|
const [mapsReady, setMapsReady] = useState(false);
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [editDevice, setEditDevice] = useState<GpsDevice | null>(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 = `<svg xmlns="http://www.w3.org/2000/svg" width="40" height="48" viewBox="0 0 40 48">
|
|
<path d="M20 2C10 2 2 10 2 20c0 12 18 26 18 26s18-14 18-26C38 10 30 2 20 2Z" fill="${color}" stroke="#ffffff" stroke-width="1.5"/>
|
|
<g transform="translate(8,7)" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M14 18V6a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h1"/>
|
|
<path d="M15 18H9"/>
|
|
<path d="M19 18h2a1 1 0 0 0 1-1v-3.65a1 1 0 0 0-.22-.62l-3.48-4.35A1 1 0 0 0 17.52 8H14"/>
|
|
<circle cx="7" cy="18" r="2"/>
|
|
<circle cx="17" cy="18" r="2"/>
|
|
</g>
|
|
</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 (
|
|
<Container size="xl" py="xl" px="lg">
|
|
<Breadcrumbs items={[{ label: "Fleet" }, { label: "Vehicle Tracking" }]} />
|
|
|
|
<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>
|
|
{canManage && (
|
|
<Button leftSection={<Plus size={16} />} color="edr-green" onClick={openRegister}>
|
|
Register tracker
|
|
</Button>
|
|
)}
|
|
</Group>
|
|
|
|
<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%" }}
|
|
>
|
|
<ReadyProbe onReady={() => setMapsReady(true)} />
|
|
{positioned.map(({ d, lat, lng }) => (
|
|
<Marker
|
|
key={d.id}
|
|
position={{ lat, lng }}
|
|
title={`${deviceLabel(d)}\n${lat.toFixed(5)}, ${lng.toFixed(5)}`}
|
|
icon={markerIcon(d, d.id === selectedId)}
|
|
onClick={() => setSelectedId(d.id)}
|
|
onMouseOver={() => setHoverId(d.id)}
|
|
/>
|
|
))}
|
|
{(() => {
|
|
const h = positioned.find((p) => p.d.id === hoverId);
|
|
return h ? (
|
|
<HoverInfo device={h.d} lat={h.lat} lng={h.lng} onClose={() => setHoverId(null)} />
|
|
) : null;
|
|
})()}
|
|
<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">
|
|
{selected && (
|
|
<Card withBorder p="lg">
|
|
<Stack gap="md">
|
|
<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>
|
|
{canManage && (
|
|
<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 })}
|
|
disabled={!canManage}
|
|
searchable
|
|
clearable
|
|
/>
|
|
</Stack>
|
|
</Card>
|
|
)}
|
|
|
|
<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">
|
|
<Group gap={6} justify="flex-end" wrap="nowrap">
|
|
<Badge color={d.online ? "edr-green" : "gray"} size="sm">{d.online ? "Live" : "Offline"}</Badge>
|
|
{canManage && (
|
|
<ActionIcon
|
|
variant="subtle"
|
|
size="sm"
|
|
aria-label="Edit tracker"
|
|
onClick={(e) => { e.stopPropagation(); openEdit(d); }}
|
|
>
|
|
<Pencil size={15} />
|
|
</ActionIcon>
|
|
)}
|
|
</Group>
|
|
</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 / edit modal */}
|
|
<Modal
|
|
opened={modalOpen}
|
|
onClose={() => setModalOpen(false)}
|
|
title={editDevice ? "Edit GPS tracker" : "Register GPS tracker"}
|
|
radius="lg"
|
|
centered
|
|
>
|
|
<Stack gap="md">
|
|
<TextInput
|
|
label="IMEI"
|
|
placeholder="15-digit device IMEI"
|
|
required
|
|
disabled={Boolean(editDevice)}
|
|
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={() => setModalOpen(false)}>Cancel</Button>
|
|
<Button
|
|
loading={saveMutation.isPending}
|
|
disabled={!form.imei.trim()}
|
|
onClick={() => saveMutation.mutate()}
|
|
>
|
|
{editDevice ? "Save" : "Register"}
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
</Container>
|
|
);
|
|
}
|
|
|
|
export default TrackingPage;
|