diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 9a03f665c..b5658e556 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -21,7 +21,6 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectDataSource } from '@nestjs/typeorm'; -import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql'; import { DataSource, EntityManager, @@ -2474,63 +2473,6 @@ export class TrainSchedulingService { return this.getTrainScheduleById(scheduleId); } - /** - * EXPORT ONLY. An export train must not leave carrying nothing while its cargo - * sits in the shed: the goods are received into the origin warehouse, GRN'd and - * loaded onto the wagons allocated to the booking, so anything still in the - * warehouse at dispatch is being left behind. Blocks dispatch when an allocated - * booking has warehouse inventory that never made it onto a wagon (received / - * stored / ready but not LOADED) — either load it from the Load-to-Train queue, - * or drop the booking's wagon allocation so it rides a later train. - * - * Import/domestic are untouched: their cargo isn't loaded out of an origin - * warehouse, so warehouse inventory says nothing about what's aboard. - * - * Bookings with no warehouse inventory at all are NOT blocked — allocating a - * wagon before the goods arrive is normal planning; they simply aren't aboard. - */ - private async assertAllocatedCargoLoaded(scheduleId: string): Promise { - const [route]: Array<{ originCountry: string | null; destinationCountry: string | null }> = - await this.dataSource.query( - `SELECT oy.country AS "originCountry", dy.country AS "destinationCountry" - FROM freight.train_schedules ts - LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id - LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id - WHERE ts.id = $1 AND ts.deleted_at IS NULL`, - [scheduleId], - ); - if (!route) return; - const direction = deriveTradeDirection( - { country: route.originCountry }, - { country: route.destinationCountry }, - ); - if (direction !== 'EXPORT') return; - - // Only bookings boarding at the schedule's ORIGIN station gate dispatch — - // a mid-corridor boarder (origin B on an A→B→C→D run) is loaded when the - // train reaches its yard, so its warehouse state says nothing at departure. - const rows: Array<{ reference: string | null; status: string }> = await this.dataSource.query( - `WITH ${SCHEDULE_BOOKINGS_CTE} - SELECT DISTINCT b.reference AS "reference", inv.status AS "status" - FROM sched_bookings sb - JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL - JOIN freight.train_schedules ts ON ts.id = sb.schedule_id - JOIN freight.warehouse_inventory inv - ON inv.booking_id = b.id AND inv.deleted_at IS NULL - WHERE sb.schedule_id = $1 - AND b.origin_yard_id = ts.origin_station_id - AND inv.status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING')`, - [scheduleId], - ); - if (rows.length) { - const refs = [...new Set(rows.map((r) => r.reference ?? '?'))].join(', '); - throw new BadRequestException( - `Cannot dispatch: cargo for booking(s) ${refs} is in the warehouse but not loaded onto a wagon. ` + - `Load it from the warehouse Load-to-Train queue, or remove the booking's wagon allocation so it travels on a later train.`, - ); - } - } - async dispatchSchedule(scheduleId: string) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { @@ -2540,8 +2482,8 @@ export class TrainSchedulingService { throw new BadRequestException('Only SCHEDULED trains can be dispatched'); } await this.assertImportDjiboutiMayDepart(schedule); - // Export only: don't leave received cargo behind in the warehouse. - await this.assertAllocatedCargoLoaded(scheduleId); + // Cargo readiness (in the warehouse, not inspected, not loaded onto a wagon) + // never blocks departure — the dispatch confirm dialog warns and staff decide. // A locomotive may sit on many future schedules, but it can only pull one train // at a time — block dispatch while any set locomotive is out on a dispatched train. const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id); diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx index b773e9c7c..cb64d3b22 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx @@ -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 }) => ( - {label} - {value} + + {label} + + + {value} + ); @@ -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 ( - +
-
{deviceLabel(device)}
+
+ {deviceLabel(device)} +
{lat.toFixed(5)}, {lng.toFixed(5)}
{toNum(device.lastSpeed) ?? 0} km/h · {device.lastCourse ?? 0}°
-
{address ?? "Locating…"}
+
+ {address ?? "Locating…"} +
); @@ -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 = ` @@ -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 ( - +
- Real-Time Vehicle Tracking - Live GPS positions from GT06 trackers + + Real-Time Vehicle Tracking + + + Live GPS positions from GT06 trackers +
{canManage && ( - )} @@ -314,36 +368,82 @@ export function TrackingPage() { - - - + {!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". + - setMapsReady(true)} /> - {positioned.map(({ d, lat, lng }) => ( - setSelectedId(d.id)} - onMouseOver={() => setHoverId(d.id)} + + Map unavailable — VITE_GOOGLE_MAPS_API_KEY is + not set. Add a Google Maps key with the Maps JavaScript + API and Places API enabled to this app's{" "} + .env, then restart the dev server. Device + positions below are unaffected. + + + ) : ( + + + 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, + }))} /> - ))} - {(() => { - 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 && } - - + {selected?.vehicleId && trail.length > 1 && ( + + )} + + + )} {positioned.length === 0 && ( @@ -363,11 +463,19 @@ export function TrackingPage() { {deviceLabel(selected)} - }> + } + > {selected.online ? "Live" : "Offline"} {canManage && ( - deleteMutation.mutate(selected.id)}> + deleteMutation.mutate(selected.id)} + > )} @@ -375,21 +483,55 @@ export function TrackingPage() { - - - - - - + + + + + +
- IMEI - {selected.imei} + + IMEI + + + {selected.imei} +
- Last fix - {fmtTime(selected.lastFixAt)} + + Last fix + + + {fmtTime(selected.lastFixAt)} +
{selected.vehicleId && ( @@ -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) => ( setSelectedId(d.id)} > - {deviceLabel(d)} - {toNum(d.lastSpeed) ?? 0} km/h · {fmtTime(d.lastFixAt)} + + {deviceLabel(d)} + + + {toNum(d.lastSpeed) ?? 0} km/h ·{" "} + {fmtTime(d.lastFixAt)} + - {d.online ? "Live" : "Offline"} + + {d.online ? "Live" : "Offline"} + {canManage && ( { e.stopPropagation(); openEdit(d); }} + onClick={(e) => { + e.stopPropagation(); + openEdit(d); + }} > @@ -449,7 +612,9 @@ export function TrackingPage() { {devices.length === 0 && ( - No trackers registered yet. + + No trackers registered yet. + )} @@ -495,7 +660,9 @@ export function TrackingPage() { clearable /> - +