Merge pull request #1185 from Tria-plc/eims-integration

Eims integration
This commit is contained in:
Hagernesh Tadesse
2026-08-08 16:48:30 +03:00
committed by GitHub
5 changed files with 315 additions and 166 deletions

View File

@@ -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);

View File

@@ -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',

View File

@@ -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&apos;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()}

View File

@@ -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';

View File

@@ -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&apos;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 }}>