Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx
Hagernesh 204bfd2421 fix: let trains dispatch with cargo still in the warehouse
The export-only assertAllocatedCargoLoaded guard threw a
BadRequestException whenever a booking on the schedule had warehouse
inventory in RECEIVED, STORED or READY_FOR_LOADING, making it
impossible to dispatch a train whose cargo had not been inspected and
loaded onto a wagon.

Leaving cargo behind is an operational decision, not an error state.
The dispatch confirm dialog already lists unassigned and unloaded
bookings and offers Dispatch anyway, so the readiness signal is
preserved — only the hard block is gone.

Wagon/locomotive conflicts and the Djibouti gatepass check still block
dispatch: those are physical and legal conflicts, not cargo readiness.
2026-08-08 13:17:43 +00:00

681 lines
23 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";
// Maps JavaScript API keys are public client-side keys — lock them down by
// HTTP-referrer in the Google Cloud console. No fallback: a hardcoded default
// used to live here and expired, turning a missing env var into a blank map
// that read as broken GPS rather than absent configuration.
const GOOGLE_MAPS_API_KEY = import.meta.env.VITE_GOOGLE_MAPS_API_KEY?.trim();
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",
}}
>
{!GOOGLE_MAPS_API_KEY ? (
// Name the missing variable rather than showing an empty map: the
// device list beside this still works, so a blank panel reads as
// "no GPS fixes" instead of "no map key".
<Box
style={{
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 24,
textAlign: "center",
border: "1px solid #F0D2A8",
borderRadius: 8,
background: "#FFF9F0",
}}
>
<Text fz="sm" c="#8A5A16">
Map unavailable <code>VITE_GOOGLE_MAPS_API_KEY</code> is
not set. Add a Google Maps key with the Maps JavaScript
API and Places API enabled to this app&apos;s{" "}
<code>.env</code>, then restart the dev server. Device
positions below are unaffected.
</Text>
</Box>
) : (
<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;