mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +00:00
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.
This commit is contained in:
@@ -30,15 +30,17 @@ 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 {
|
||||
gpsTrackingService,
|
||||
type GpsDevice,
|
||||
} from "@/services/gps-tracking.service";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
// Same default key + env override the portal's LocationPicker uses.
|
||||
// NOTE: fallback key is EXPIRED (ExpiredKeyMapError) — set
|
||||
// VITE_GOOGLE_MAPS_API_KEY (see .env.example) to a live key.
|
||||
const GOOGLE_MAPS_API_KEY =
|
||||
import.meta.env.VITE_GOOGLE_MAPS_API_KEY ||
|
||||
"AIzaSyBg4tN31-fgvH_2Ix_TPo6VSfOA2uA5CCI";
|
||||
// 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 =>
|
||||
@@ -57,8 +59,12 @@ const fmtTime = (iso?: string | null) => {
|
||||
|
||||
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>
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text fw={600} size="sm">
|
||||
{value}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -97,11 +103,20 @@ function useAddress(lat: number, lng: number): string | null {
|
||||
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; };
|
||||
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;
|
||||
}
|
||||
@@ -120,16 +135,24 @@ function HoverInfo({
|
||||
}) {
|
||||
const address = useAddress(lat, lng);
|
||||
return (
|
||||
<InfoWindow position={{ lat, lng }} pixelOffset={[0, -46]} onCloseClick={onClose}>
|
||||
<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={{ 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 style={{ color: "#777", marginTop: 4 }}>
|
||||
{address ?? "Locating…"}
|
||||
</div>
|
||||
</div>
|
||||
</InfoWindow>
|
||||
);
|
||||
@@ -184,7 +207,8 @@ export function TrackingPage() {
|
||||
|
||||
const { data: vehiclesData } = useQuery({
|
||||
queryKey: ["vehicles", "all"],
|
||||
queryFn: async () => (await vehiclesService.getAll({ limit: 1000 })).data ?? [],
|
||||
queryFn: async () =>
|
||||
(await vehiclesService.getAll({ limit: 1000 })).data ?? [],
|
||||
});
|
||||
const vehicleOptions = useMemo(
|
||||
() =>
|
||||
@@ -199,7 +223,10 @@ export function TrackingPage() {
|
||||
() =>
|
||||
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),
|
||||
.filter(
|
||||
(x): x is { d: GpsDevice; lat: number; lng: number } =>
|
||||
x.lat != null && x.lng != null,
|
||||
),
|
||||
[devices],
|
||||
);
|
||||
|
||||
@@ -209,19 +236,31 @@ export function TrackingPage() {
|
||||
// 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 ?? [],
|
||||
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]
|
||||
.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 => {
|
||||
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";
|
||||
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">
|
||||
@@ -260,8 +299,13 @@ export function TrackingPage() {
|
||||
},
|
||||
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" });
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed";
|
||||
toast({
|
||||
title: editDevice ? "Update failed" : "Registration failed",
|
||||
description,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -287,15 +331,25 @@ export function TrackingPage() {
|
||||
|
||||
return (
|
||||
<Container size="xl" py="xl" px="lg">
|
||||
<Breadcrumbs items={[{ label: "Fleet" }, { label: "Vehicle Tracking" }]} />
|
||||
<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>
|
||||
<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}>
|
||||
<Button
|
||||
leftSection={<Plus size={16} />}
|
||||
color="edr-green"
|
||||
onClick={openRegister}
|
||||
>
|
||||
Register tracker
|
||||
</Button>
|
||||
)}
|
||||
@@ -314,36 +368,82 @@ export function TrackingPage() {
|
||||
</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%" }}
|
||||
<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",
|
||||
}}
|
||||
>
|
||||
<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)}
|
||||
<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'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,
|
||||
}))}
|
||||
/>
|
||||
))}
|
||||
{(() => {
|
||||
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>
|
||||
{selected?.vehicleId && trail.length > 1 && (
|
||||
<RouteTrail path={trail} />
|
||||
)}
|
||||
</GoogleMap>
|
||||
</APIProvider>
|
||||
)}
|
||||
</Box>
|
||||
{positioned.length === 0 && (
|
||||
<Text size="sm" c="dimmed" ta="center" mt="sm">
|
||||
@@ -363,11 +463,19 @@ export function TrackingPage() {
|
||||
<Group justify="space-between">
|
||||
<Text fw={500}>{deviceLabel(selected)}</Text>
|
||||
<Group gap="xs">
|
||||
<Badge color={selected.online ? "edr-green" : "gray"} leftSection={<Activity size={12} />}>
|
||||
<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)}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label="Remove tracker"
|
||||
onClick={() => deleteMutation.mutate(selected.id)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
@@ -375,21 +483,55 @@ export function TrackingPage() {
|
||||
</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` : "—"} />
|
||||
<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>
|
||||
<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>
|
||||
<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">
|
||||
@@ -402,7 +544,9 @@ export function TrackingPage() {
|
||||
placeholder="Unassigned"
|
||||
data={vehicleOptions}
|
||||
value={selected.vehicleId ?? null}
|
||||
onChange={(v) => assignMutation.mutate({ id: selected.id, vehicleId: v })}
|
||||
onChange={(v) =>
|
||||
assignMutation.mutate({ id: selected.id, vehicleId: v })
|
||||
}
|
||||
disabled={!canManage}
|
||||
searchable
|
||||
clearable
|
||||
@@ -420,24 +564,43 @@ export function TrackingPage() {
|
||||
{devices.map((d) => (
|
||||
<Table.Tr
|
||||
key={d.id}
|
||||
style={{ cursor: "pointer", backgroundColor: d.id === selectedId ? freightBrand.mutedBg : "transparent" }}
|
||||
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>
|
||||
<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>
|
||||
<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); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openEdit(d);
|
||||
}}
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</ActionIcon>
|
||||
@@ -449,7 +612,9 @@ export function TrackingPage() {
|
||||
{devices.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={2}>
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">No trackers registered yet.</Text>
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
No trackers registered yet.
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
@@ -495,7 +660,9 @@ export function TrackingPage() {
|
||||
clearable
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setModalOpen(false)}>Cancel</Button>
|
||||
<Button variant="default" onClick={() => setModalOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
loading={saveMutation.isPending}
|
||||
disabled={!form.imei.trim()}
|
||||
|
||||
Reference in New Issue
Block a user