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:
Hagernesh
2026-08-08 12:56:10 +00:00
parent 1b0978bc1e
commit 204bfd2421
3 changed files with 300 additions and 153 deletions

View File

@@ -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&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,
}))}
/>
))}
{(() => {
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()}