mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 01:48:12 +00:00
Merge pull request #1185 from Tria-plc/eims-integration
Eims integration
This commit is contained in:
@@ -21,7 +21,6 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql';
|
|
||||||
import {
|
import {
|
||||||
DataSource,
|
DataSource,
|
||||||
EntityManager,
|
EntityManager,
|
||||||
@@ -2474,63 +2473,6 @@ export class TrainSchedulingService {
|
|||||||
return this.getTrainScheduleById(scheduleId);
|
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<void> {
|
|
||||||
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) {
|
async dispatchSchedule(scheduleId: string) {
|
||||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||||
if (!schedule) {
|
if (!schedule) {
|
||||||
@@ -2540,8 +2482,8 @@ export class TrainSchedulingService {
|
|||||||
throw new BadRequestException('Only SCHEDULED trains can be dispatched');
|
throw new BadRequestException('Only SCHEDULED trains can be dispatched');
|
||||||
}
|
}
|
||||||
await this.assertImportDjiboutiMayDepart(schedule);
|
await this.assertImportDjiboutiMayDepart(schedule);
|
||||||
// Export only: don't leave received cargo behind in the warehouse.
|
// Cargo readiness (in the warehouse, not inspected, not loaded onto a wagon)
|
||||||
await this.assertAllocatedCargoLoaded(scheduleId);
|
// 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
|
// 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.
|
// 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);
|
const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
|
||||||
|
|||||||
@@ -1697,7 +1697,6 @@ export class WarehouseInventoryService {
|
|||||||
/** EXPORT inventory rows at a given status (route-derived direction), with booking detail. */
|
/** EXPORT inventory rows at a given status (route-derived direction), with booking detail. */
|
||||||
private async exportInventoryByStatus(
|
private async exportInventoryByStatus(
|
||||||
status: WarehouseInventoryStatus,
|
status: WarehouseInventoryStatus,
|
||||||
requireInspectionPassed = false,
|
|
||||||
): Promise<ReadyToLoadRow[]> {
|
): Promise<ReadyToLoadRow[]> {
|
||||||
const rows: Array<
|
const rows: Array<
|
||||||
ReadyToLoadRow & { originCountry: string | null; destinationCountry: string | null }
|
ReadyToLoadRow & { originCountry: string | null; destinationCountry: string | null }
|
||||||
@@ -1726,7 +1725,6 @@ export class WarehouseInventoryService {
|
|||||||
LEFT JOIN freight.containers ct ON ct.id = inv.container_id
|
LEFT JOIN freight.containers ct ON ct.id = inv.container_id
|
||||||
WHERE inv.deleted_at IS NULL
|
WHERE inv.deleted_at IS NULL
|
||||||
AND inv.status = $1
|
AND inv.status = $1
|
||||||
${requireInspectionPassed ? `AND inv.inspection_status = 'PASSED'` : ''}
|
|
||||||
ORDER BY inv.created_at DESC`,
|
ORDER BY inv.created_at DESC`,
|
||||||
[status],
|
[status],
|
||||||
);
|
);
|
||||||
@@ -1739,9 +1737,13 @@ export class WarehouseInventoryService {
|
|||||||
.map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => rest);
|
.map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => rest);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** EXPORT inventory that passed inspection and is waiting to be loaded (READY_FOR_LOADING). */
|
/**
|
||||||
|
* EXPORT inventory waiting to be loaded (READY_FOR_LOADING). Inspection state
|
||||||
|
* rides along on each row for the UI to show, but does not filter the queue —
|
||||||
|
* uninspected cargo must still be loadable.
|
||||||
|
*/
|
||||||
async readyToLoadExport(): Promise<ReadyToLoadRow[]> {
|
async readyToLoadExport(): Promise<ReadyToLoadRow[]> {
|
||||||
return this.exportInventoryByStatus('READY_FOR_LOADING', true);
|
return this.exportInventoryByStatus('READY_FOR_LOADING');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** EXPORT inventory received at the facility and awaiting inspection. */
|
/** EXPORT inventory received at the facility and awaiting inspection. */
|
||||||
@@ -3130,9 +3132,8 @@ export class WarehouseInventoryService {
|
|||||||
if (!item.bookingId || !item.warehouseId || !item.yardId || !item.zoneId) {
|
if (!item.bookingId || !item.warehouseId || !item.yardId || !item.zoneId) {
|
||||||
throw new BadRequestException('Inventory must have booking, warehouse, yard and zone before loading prep');
|
throw new BadRequestException('Inventory must have booking, warehouse, yard and zone before loading prep');
|
||||||
}
|
}
|
||||||
if (item.inspectionStatus !== 'PASSED') {
|
// Inspection is tracked, not enforced — uninspected cargo may still be
|
||||||
throw new BadRequestException('Inventory must pass inspection before it can be marked ready for loading');
|
// marked ready and loaded so a train is never held for paperwork.
|
||||||
}
|
|
||||||
return this.transition(id, 'READY_FOR_LOADING', {
|
return this.transition(id, 'READY_FOR_LOADING', {
|
||||||
timestampField: 'readyForLoadingAt',
|
timestampField: 'readyForLoadingAt',
|
||||||
activityType: 'READY_FOR_LOADING',
|
activityType: 'READY_FOR_LOADING',
|
||||||
|
|||||||
@@ -30,15 +30,17 @@ import { useToast } from "@/hooks/use-toast";
|
|||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
import { vehiclesService } from "@/services/vehicles.service";
|
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";
|
import { freightBrand } from "@/theme/freight-brand";
|
||||||
|
|
||||||
// Same default key + env override the portal's LocationPicker uses.
|
// Maps JavaScript API keys are public client-side keys — lock them down by
|
||||||
// NOTE: fallback key is EXPIRED (ExpiredKeyMapError) — set
|
// HTTP-referrer in the Google Cloud console. No fallback: a hardcoded default
|
||||||
// VITE_GOOGLE_MAPS_API_KEY (see .env.example) to a live key.
|
// used to live here and expired, turning a missing env var into a blank map
|
||||||
const GOOGLE_MAPS_API_KEY =
|
// that read as broken GPS rather than absent configuration.
|
||||||
import.meta.env.VITE_GOOGLE_MAPS_API_KEY ||
|
const GOOGLE_MAPS_API_KEY = import.meta.env.VITE_GOOGLE_MAPS_API_KEY?.trim();
|
||||||
"AIzaSyBg4tN31-fgvH_2Ix_TPo6VSfOA2uA5CCI";
|
|
||||||
const DEFAULT_CENTER = { lat: 9.03, lng: 38.74 }; // Addis Ababa
|
const DEFAULT_CENTER = { lat: 9.03, lng: 38.74 }; // Addis Ababa
|
||||||
|
|
||||||
const toNum = (v: number | string | null | undefined): number | null =>
|
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 }) => (
|
const StatBox = ({ label, value }: { label: string; value: string }) => (
|
||||||
<Box p="sm" style={{ backgroundColor: "#f8f9fa", borderRadius: 8 }}>
|
<Box p="sm" style={{ backgroundColor: "#f8f9fa", borderRadius: 8 }}>
|
||||||
<Text size="xs" c="dimmed">{label}</Text>
|
<Text size="xs" c="dimmed">
|
||||||
<Text fw={600} size="sm">{value}</Text>
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -97,11 +103,20 @@ function useAddress(lat: number, lng: number): string | null {
|
|||||||
if (typeof google === "undefined" || !google.maps?.Geocoder) return;
|
if (typeof google === "undefined" || !google.maps?.Geocoder) return;
|
||||||
setAddr(null);
|
setAddr(null);
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
new google.maps.Geocoder().geocode({ location: { lat, lng } }, (res, status) => {
|
new google.maps.Geocoder().geocode(
|
||||||
if (cancelled) return;
|
{ location: { lat, lng } },
|
||||||
setAddr(status === "OK" && res?.[0] ? res[0].formatted_address : "Unknown location");
|
(res, status) => {
|
||||||
});
|
if (cancelled) return;
|
||||||
return () => { cancelled = true; };
|
setAddr(
|
||||||
|
status === "OK" && res?.[0]
|
||||||
|
? res[0].formatted_address
|
||||||
|
: "Unknown location",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [lat, lng]);
|
}, [lat, lng]);
|
||||||
return addr;
|
return addr;
|
||||||
}
|
}
|
||||||
@@ -120,16 +135,24 @@ function HoverInfo({
|
|||||||
}) {
|
}) {
|
||||||
const address = useAddress(lat, lng);
|
const address = useAddress(lat, lng);
|
||||||
return (
|
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={{ 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" }}>
|
<div style={{ fontFamily: "monospace" }}>
|
||||||
{lat.toFixed(5)}, {lng.toFixed(5)}
|
{lat.toFixed(5)}, {lng.toFixed(5)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: "#555" }}>
|
<div style={{ color: "#555" }}>
|
||||||
{toNum(device.lastSpeed) ?? 0} km/h · {device.lastCourse ?? 0}°
|
{toNum(device.lastSpeed) ?? 0} km/h · {device.lastCourse ?? 0}°
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: "#777", marginTop: 4 }}>{address ?? "Locating…"}</div>
|
<div style={{ color: "#777", marginTop: 4 }}>
|
||||||
|
{address ?? "Locating…"}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</InfoWindow>
|
</InfoWindow>
|
||||||
);
|
);
|
||||||
@@ -184,7 +207,8 @@ export function TrackingPage() {
|
|||||||
|
|
||||||
const { data: vehiclesData } = useQuery({
|
const { data: vehiclesData } = useQuery({
|
||||||
queryKey: ["vehicles", "all"],
|
queryKey: ["vehicles", "all"],
|
||||||
queryFn: async () => (await vehiclesService.getAll({ limit: 1000 })).data ?? [],
|
queryFn: async () =>
|
||||||
|
(await vehiclesService.getAll({ limit: 1000 })).data ?? [],
|
||||||
});
|
});
|
||||||
const vehicleOptions = useMemo(
|
const vehicleOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -199,7 +223,10 @@ export function TrackingPage() {
|
|||||||
() =>
|
() =>
|
||||||
devices
|
devices
|
||||||
.map((d) => ({ d, lat: toNum(d.lastLat), lng: toNum(d.lastLng) }))
|
.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],
|
[devices],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -209,19 +236,31 @@ export function TrackingPage() {
|
|||||||
// Route history for the selected device's vehicle (chronological trail).
|
// Route history for the selected device's vehicle (chronological trail).
|
||||||
const { data: history = [] } = useQuery({
|
const { data: history = [] } = useQuery({
|
||||||
queryKey: ["gps", "history", selected?.vehicleId],
|
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),
|
enabled: Boolean(selected?.vehicleId),
|
||||||
});
|
});
|
||||||
const trail = useMemo(
|
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],
|
[history],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Teardrop pin colored by state with a white truck glyph inside.
|
// 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.
|
// Maps API loads async — Size/Point classes may not exist yet at first render.
|
||||||
if (typeof google === "undefined" || !google.maps?.Size || !mapsReady) return undefined;
|
if (typeof google === "undefined" || !google.maps?.Size || !mapsReady)
|
||||||
const color = selectedFlag ? freightBrand.primary : d.online ? "#2f80ed" : "#95a5a6";
|
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">
|
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"/>
|
<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">
|
<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) => {
|
onError: (err: unknown) => {
|
||||||
const description =
|
const description =
|
||||||
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? "Failed";
|
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||||
toast({ title: editDevice ? "Update failed" : "Registration failed", description, variant: "destructive" });
|
?.message ?? "Failed";
|
||||||
|
toast({
|
||||||
|
title: editDevice ? "Update failed" : "Registration failed",
|
||||||
|
description,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -287,15 +331,25 @@ export function TrackingPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="xl" py="xl" px="lg">
|
<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">
|
<Group justify="space-between" mb="xl">
|
||||||
<div>
|
<div>
|
||||||
<Text fw={700} size="xl">Real-Time Vehicle Tracking</Text>
|
<Text fw={700} size="xl">
|
||||||
<Text c="dimmed" size="sm">Live GPS positions from GT06 trackers</Text>
|
Real-Time Vehicle Tracking
|
||||||
|
</Text>
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
Live GPS positions from GT06 trackers
|
||||||
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<Button leftSection={<Plus size={16} />} color="edr-green" onClick={openRegister}>
|
<Button
|
||||||
|
leftSection={<Plus size={16} />}
|
||||||
|
color="edr-green"
|
||||||
|
onClick={openRegister}
|
||||||
|
>
|
||||||
Register tracker
|
Register tracker
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -314,36 +368,82 @@ export function TrackingPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Card.Section>
|
</Card.Section>
|
||||||
<Card.Section p="md">
|
<Card.Section p="md">
|
||||||
<Box style={{ height: 500, width: "100%", borderRadius: 8, overflow: "hidden" }}>
|
<Box
|
||||||
<APIProvider apiKey={GOOGLE_MAPS_API_KEY}>
|
style={{
|
||||||
<GoogleMap
|
height: 500,
|
||||||
defaultCenter={DEFAULT_CENTER}
|
width: "100%",
|
||||||
defaultZoom={7}
|
borderRadius: 8,
|
||||||
gestureHandling="greedy"
|
overflow: "hidden",
|
||||||
disableDefaultUI={false}
|
}}
|
||||||
style={{ width: "100%", height: "100%" }}
|
>
|
||||||
|
{!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)} />
|
<Text fz="sm" c="#8A5A16">
|
||||||
{positioned.map(({ d, lat, lng }) => (
|
Map unavailable — <code>VITE_GOOGLE_MAPS_API_KEY</code> is
|
||||||
<Marker
|
not set. Add a Google Maps key with the Maps JavaScript
|
||||||
key={d.id}
|
API and Places API enabled to this app's{" "}
|
||||||
position={{ lat, lng }}
|
<code>.env</code>, then restart the dev server. Device
|
||||||
title={`${deviceLabel(d)}\n${lat.toFixed(5)}, ${lng.toFixed(5)}`}
|
positions below are unaffected.
|
||||||
icon={markerIcon(d, d.id === selectedId)}
|
</Text>
|
||||||
onClick={() => setSelectedId(d.id)}
|
</Box>
|
||||||
onMouseOver={() => setHoverId(d.id)}
|
) : (
|
||||||
|
<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} />
|
||||||
const h = positioned.find((p) => p.d.id === hoverId);
|
)}
|
||||||
return h ? (
|
</GoogleMap>
|
||||||
<HoverInfo device={h.d} lat={h.lat} lng={h.lng} onClose={() => setHoverId(null)} />
|
</APIProvider>
|
||||||
) : null;
|
)}
|
||||||
})()}
|
|
||||||
<FitBounds points={positioned.map((p) => ({ lat: p.lat, lng: p.lng }))} />
|
|
||||||
{selected?.vehicleId && trail.length > 1 && <RouteTrail path={trail} />}
|
|
||||||
</GoogleMap>
|
|
||||||
</APIProvider>
|
|
||||||
</Box>
|
</Box>
|
||||||
{positioned.length === 0 && (
|
{positioned.length === 0 && (
|
||||||
<Text size="sm" c="dimmed" ta="center" mt="sm">
|
<Text size="sm" c="dimmed" ta="center" mt="sm">
|
||||||
@@ -363,11 +463,19 @@ export function TrackingPage() {
|
|||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text fw={500}>{deviceLabel(selected)}</Text>
|
<Text fw={500}>{deviceLabel(selected)}</Text>
|
||||||
<Group gap="xs">
|
<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"}
|
{selected.online ? "Live" : "Offline"}
|
||||||
</Badge>
|
</Badge>
|
||||||
{canManage && (
|
{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} />
|
<Trash2 size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
)}
|
)}
|
||||||
@@ -375,21 +483,55 @@ export function TrackingPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<SimpleGrid cols={2} spacing="sm">
|
<SimpleGrid cols={2} spacing="sm">
|
||||||
<StatBox label="Latitude" value={toNum(selected.lastLat)?.toFixed(5) ?? "—"} />
|
<StatBox
|
||||||
<StatBox label="Longitude" value={toNum(selected.lastLng)?.toFixed(5) ?? "—"} />
|
label="Latitude"
|
||||||
<StatBox label="Speed" value={`${toNum(selected.lastSpeed) ?? 0} km/h`} />
|
value={toNum(selected.lastLat)?.toFixed(5) ?? "—"}
|
||||||
<StatBox label="Course" value={`${selected.lastCourse ?? 0}°`} />
|
/>
|
||||||
<StatBox label="Voltage" value={selected.voltageLevel != null ? `${selected.voltageLevel}/6` : "—"} />
|
<StatBox
|
||||||
<StatBox label="GSM" value={selected.gsmLevel != null ? `${selected.gsmLevel}/4` : "—"} />
|
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>
|
</SimpleGrid>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Text size="xs" c="dimmed">IMEI</Text>
|
<Text size="xs" c="dimmed">
|
||||||
<Text fw={500} size="sm">{selected.imei}</Text>
|
IMEI
|
||||||
|
</Text>
|
||||||
|
<Text fw={500} size="sm">
|
||||||
|
{selected.imei}
|
||||||
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Text size="xs" c="dimmed">Last fix</Text>
|
<Text size="xs" c="dimmed">
|
||||||
<Text fw={500} size="sm">{fmtTime(selected.lastFixAt)}</Text>
|
Last fix
|
||||||
|
</Text>
|
||||||
|
<Text fw={500} size="sm">
|
||||||
|
{fmtTime(selected.lastFixAt)}
|
||||||
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
{selected.vehicleId && (
|
{selected.vehicleId && (
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
@@ -402,7 +544,9 @@ export function TrackingPage() {
|
|||||||
placeholder="Unassigned"
|
placeholder="Unassigned"
|
||||||
data={vehicleOptions}
|
data={vehicleOptions}
|
||||||
value={selected.vehicleId ?? null}
|
value={selected.vehicleId ?? null}
|
||||||
onChange={(v) => assignMutation.mutate({ id: selected.id, vehicleId: v })}
|
onChange={(v) =>
|
||||||
|
assignMutation.mutate({ id: selected.id, vehicleId: v })
|
||||||
|
}
|
||||||
disabled={!canManage}
|
disabled={!canManage}
|
||||||
searchable
|
searchable
|
||||||
clearable
|
clearable
|
||||||
@@ -420,24 +564,43 @@ export function TrackingPage() {
|
|||||||
{devices.map((d) => (
|
{devices.map((d) => (
|
||||||
<Table.Tr
|
<Table.Tr
|
||||||
key={d.id}
|
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)}
|
onClick={() => setSelectedId(d.id)}
|
||||||
>
|
>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Text size="sm" fw={600}>{deviceLabel(d)}</Text>
|
<Text size="sm" fw={600}>
|
||||||
<Text size="xs" c="dimmed">{toNum(d.lastSpeed) ?? 0} km/h · {fmtTime(d.lastFixAt)}</Text>
|
{deviceLabel(d)}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{toNum(d.lastSpeed) ?? 0} km/h ·{" "}
|
||||||
|
{fmtTime(d.lastFixAt)}
|
||||||
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td align="right">
|
<Table.Td align="right">
|
||||||
<Group gap={6} justify="flex-end" wrap="nowrap">
|
<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 && (
|
{canManage && (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
size="sm"
|
size="sm"
|
||||||
aria-label="Edit tracker"
|
aria-label="Edit tracker"
|
||||||
onClick={(e) => { e.stopPropagation(); openEdit(d); }}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openEdit(d);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Pencil size={15} />
|
<Pencil size={15} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -449,7 +612,9 @@ export function TrackingPage() {
|
|||||||
{devices.length === 0 && (
|
{devices.length === 0 && (
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Td colSpan={2}>
|
<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.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
)}
|
)}
|
||||||
@@ -495,7 +660,9 @@ export function TrackingPage() {
|
|||||||
clearable
|
clearable
|
||||||
/>
|
/>
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Button variant="default" onClick={() => setModalOpen(false)}>Cancel</Button>
|
<Button variant="default" onClick={() => setModalOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
loading={saveMutation.isPending}
|
loading={saveMutation.isPending}
|
||||||
disabled={!form.imei.trim()}
|
disabled={!form.imei.trim()}
|
||||||
|
|||||||
@@ -85,14 +85,15 @@ export function getNextInventoryAction(item: WarehouseInventoryItem): InventoryA
|
|||||||
if (isImport) return inspected ? 'ready-for-pickup' : null;
|
if (isImport) return inspected ? 'ready-for-pickup' : null;
|
||||||
return 'store';
|
return 'store';
|
||||||
case 'STORED':
|
case 'STORED':
|
||||||
// Reserve is retired: a stored export item goes straight to loading prep
|
// Reserve is retired: a stored export item goes straight to loading prep.
|
||||||
// once inspection passes. An import item parked back into storage returns
|
// An import item parked back into storage returns to pickup — otherwise
|
||||||
// to pickup — otherwise Store would strand it with no action.
|
// Store would strand it with no action.
|
||||||
if (isImport) return inspected ? 'ready-for-pickup' : null;
|
if (isImport) return inspected ? 'ready-for-pickup' : null;
|
||||||
return inspected ? 'ready-for-loading' : null;
|
return 'ready-for-loading';
|
||||||
case 'RESERVED':
|
case 'RESERVED':
|
||||||
// Export loading is gated on a passed inspection.
|
// Export loading is not gated on inspection — inspection is tracked, but a
|
||||||
return inspected ? 'ready-for-loading' : null;
|
// train is never held waiting for it.
|
||||||
|
return 'ready-for-loading';
|
||||||
case 'READY_FOR_PICKUP':
|
case 'READY_FOR_PICKUP':
|
||||||
// Issue the DO / release order first, then hand over the goods.
|
// Issue the DO / release order first, then hand over the goods.
|
||||||
return item.releaseDate ? 'deliver' : 'release';
|
return item.releaseDate ? 'deliver' : 'release';
|
||||||
|
|||||||
@@ -45,17 +45,16 @@ interface PlacePrediction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Maps JavaScript API keys are public client-side keys (lock them down by
|
// Maps JavaScript API keys are public client-side keys (lock them down by
|
||||||
// HTTP-referrer in the Google Cloud console). The env var lets deployments
|
// HTTP-referrer in the Google Cloud console), so this one env var is the whole
|
||||||
// override the default key without a code change.
|
// configuration. Requires BOTH "Maps JavaScript API" (tiles) and "Places API"
|
||||||
|
// (the address search) enabled on the key, or the map draws and the search box
|
||||||
|
// silently returns nothing.
|
||||||
//
|
//
|
||||||
// NOTE: the fallback key below is EXPIRED (confirmed via live request —
|
// There is deliberately no fallback key. A hardcoded default used to live here
|
||||||
// "Google Maps JavaScript API error: ExpiredKeyMapError"), which renders
|
// and expired, which degraded a missing env var into a blank map with a search
|
||||||
// this picker's map blank while the search box spins forever. Set
|
// box that spun forever — indistinguishable from a broken picker. Absent config
|
||||||
// VITE_GOOGLE_MAPS_API_KEY (see .env.example) to a live key to fix it; don't
|
// now says so on screen instead.
|
||||||
// rely on this default.
|
const GOOGLE_MAPS_API_KEY = import.meta.env.VITE_GOOGLE_MAPS_API_KEY?.trim();
|
||||||
const GOOGLE_MAPS_API_KEY =
|
|
||||||
import.meta.env.VITE_GOOGLE_MAPS_API_KEY ||
|
|
||||||
"AIzaSyBg4tN31-fgvH_2Ix_TPo6VSfOA2uA5CCI";
|
|
||||||
|
|
||||||
// Centre of the EDR corridor (Addis Ababa) — a sensible default view.
|
// Centre of the EDR corridor (Addis Ababa) — a sensible default view.
|
||||||
const DEFAULT_CENTER = { lat: 9.03, lng: 38.74 };
|
const DEFAULT_CENTER = { lat: 9.03, lng: 38.74 };
|
||||||
@@ -197,10 +196,7 @@ async function resolvePrediction(
|
|||||||
},
|
},
|
||||||
(place, status) => {
|
(place, status) => {
|
||||||
const loc = place?.geometry?.location;
|
const loc = place?.geometry?.location;
|
||||||
if (
|
if (status !== google.maps.places.PlacesServiceStatus.OK || !loc) {
|
||||||
status !== google.maps.places.PlacesServiceStatus.OK ||
|
|
||||||
!loc
|
|
||||||
) {
|
|
||||||
resolve(null);
|
resolve(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -315,6 +311,7 @@ export function LocationPicker(props: LocationPickerProps) {
|
|||||||
lat: toFiniteNumber(props.value.lat),
|
lat: toFiniteNumber(props.value.lat),
|
||||||
lng: toFiniteNumber(props.value.lng),
|
lng: toFiniteNumber(props.value.lng),
|
||||||
};
|
};
|
||||||
|
if (!GOOGLE_MAPS_API_KEY) return <MapUnavailable label={props.label} />;
|
||||||
return (
|
return (
|
||||||
<APIProvider apiKey={GOOGLE_MAPS_API_KEY}>
|
<APIProvider apiKey={GOOGLE_MAPS_API_KEY}>
|
||||||
{props.variant === "modal" ? (
|
{props.variant === "modal" ? (
|
||||||
@@ -326,6 +323,42 @@ export function LocationPicker(props: LocationPickerProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stands in for the picker when the Maps key is absent. Says which variable is
|
||||||
|
* missing rather than rendering an empty map that reads as a broken feature —
|
||||||
|
* the failure this replaced took a live API request to diagnose.
|
||||||
|
*/
|
||||||
|
function MapUnavailable({ label }: { label?: string }) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{label && (
|
||||||
|
<Text fz={13} fw={600} c="#10202F" mb={6}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: "12px 14px",
|
||||||
|
border: "1px solid #F0D2A8",
|
||||||
|
background: "#FFF9F0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MapPin size={16} color="#B45309" style={{ flexShrink: 0 }} />
|
||||||
|
<Text fz={12.5} 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.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Compact trigger + modal wrapper around the inline picker. */
|
/** Compact trigger + modal wrapper around the inline picker. */
|
||||||
function LocationPickerModal({
|
function LocationPickerModal({
|
||||||
value,
|
value,
|
||||||
@@ -374,7 +407,12 @@ function LocationPickerModal({
|
|||||||
>
|
>
|
||||||
<MapPin size={16} />
|
<MapPin size={16} />
|
||||||
</Box>
|
</Box>
|
||||||
<Text fz={13.5} c={hasPin ? "#10202F" : "#94A3B8"} lineClamp={1} style={{ flex: 1 }}>
|
<Text
|
||||||
|
fz={13.5}
|
||||||
|
c={hasPin ? "#10202F" : "#94A3B8"}
|
||||||
|
lineClamp={1}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
{hasPin ? value.address || "Pinned location" : placeholder}
|
{hasPin ? value.address || "Pinned location" : placeholder}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fz={12.5} fw={600} c="#0A6F4D" style={{ flexShrink: 0 }}>
|
<Text fz={12.5} fw={600} c="#0A6F4D" style={{ flexShrink: 0 }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user