mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +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';
|
||||
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<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) {
|
||||
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);
|
||||
|
||||
@@ -1697,7 +1697,6 @@ export class WarehouseInventoryService {
|
||||
/** EXPORT inventory rows at a given status (route-derived direction), with booking detail. */
|
||||
private async exportInventoryByStatus(
|
||||
status: WarehouseInventoryStatus,
|
||||
requireInspectionPassed = false,
|
||||
): Promise<ReadyToLoadRow[]> {
|
||||
const rows: Array<
|
||||
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
|
||||
WHERE inv.deleted_at IS NULL
|
||||
AND inv.status = $1
|
||||
${requireInspectionPassed ? `AND inv.inspection_status = 'PASSED'` : ''}
|
||||
ORDER BY inv.created_at DESC`,
|
||||
[status],
|
||||
);
|
||||
@@ -1739,9 +1737,13 @@ export class WarehouseInventoryService {
|
||||
.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[]> {
|
||||
return this.exportInventoryByStatus('READY_FOR_LOADING', true);
|
||||
return this.exportInventoryByStatus('READY_FOR_LOADING');
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
throw new BadRequestException('Inventory must have booking, warehouse, yard and zone before loading prep');
|
||||
}
|
||||
if (item.inspectionStatus !== 'PASSED') {
|
||||
throw new BadRequestException('Inventory must pass inspection before it can be marked ready for loading');
|
||||
}
|
||||
// Inspection is tracked, not enforced — uninspected cargo may still be
|
||||
// marked ready and loaded so a train is never held for paperwork.
|
||||
return this.transition(id, 'READY_FOR_LOADING', {
|
||||
timestampField: 'readyForLoadingAt',
|
||||
activityType: 'READY_FOR_LOADING',
|
||||
|
||||
@@ -30,15 +30,17 @@ import { useToast } from "@/hooks/use-toast";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { vehiclesService } from "@/services/vehicles.service";
|
||||
import { gpsTrackingService, type GpsDevice } from "@/services/gps-tracking.service";
|
||||
import {
|
||||
gpsTrackingService,
|
||||
type GpsDevice,
|
||||
} from "@/services/gps-tracking.service";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
// Same default key + env override the portal's LocationPicker uses.
|
||||
// NOTE: fallback key is EXPIRED (ExpiredKeyMapError) — set
|
||||
// VITE_GOOGLE_MAPS_API_KEY (see .env.example) to a live key.
|
||||
const GOOGLE_MAPS_API_KEY =
|
||||
import.meta.env.VITE_GOOGLE_MAPS_API_KEY ||
|
||||
"AIzaSyBg4tN31-fgvH_2Ix_TPo6VSfOA2uA5CCI";
|
||||
// Maps JavaScript API keys are public client-side keys — lock them down by
|
||||
// HTTP-referrer in the Google Cloud console. No fallback: a hardcoded default
|
||||
// used to live here and expired, turning a missing env var into a blank map
|
||||
// that read as broken GPS rather than absent configuration.
|
||||
const GOOGLE_MAPS_API_KEY = import.meta.env.VITE_GOOGLE_MAPS_API_KEY?.trim();
|
||||
const DEFAULT_CENTER = { lat: 9.03, lng: 38.74 }; // Addis Ababa
|
||||
|
||||
const toNum = (v: number | string | null | undefined): number | null =>
|
||||
@@ -57,8 +59,12 @@ const fmtTime = (iso?: string | null) => {
|
||||
|
||||
const StatBox = ({ label, value }: { label: string; value: string }) => (
|
||||
<Box p="sm" style={{ backgroundColor: "#f8f9fa", borderRadius: 8 }}>
|
||||
<Text size="xs" c="dimmed">{label}</Text>
|
||||
<Text fw={600} size="sm">{value}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text fw={600} size="sm">
|
||||
{value}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -97,11 +103,20 @@ function useAddress(lat: number, lng: number): string | null {
|
||||
if (typeof google === "undefined" || !google.maps?.Geocoder) return;
|
||||
setAddr(null);
|
||||
let cancelled = false;
|
||||
new google.maps.Geocoder().geocode({ location: { lat, lng } }, (res, status) => {
|
||||
if (cancelled) return;
|
||||
setAddr(status === "OK" && res?.[0] ? res[0].formatted_address : "Unknown location");
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
new google.maps.Geocoder().geocode(
|
||||
{ location: { lat, lng } },
|
||||
(res, status) => {
|
||||
if (cancelled) return;
|
||||
setAddr(
|
||||
status === "OK" && res?.[0]
|
||||
? res[0].formatted_address
|
||||
: "Unknown location",
|
||||
);
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [lat, lng]);
|
||||
return addr;
|
||||
}
|
||||
@@ -120,16 +135,24 @@ function HoverInfo({
|
||||
}) {
|
||||
const address = useAddress(lat, lng);
|
||||
return (
|
||||
<InfoWindow position={{ lat, lng }} pixelOffset={[0, -46]} onCloseClick={onClose}>
|
||||
<InfoWindow
|
||||
position={{ lat, lng }}
|
||||
pixelOffset={[0, -46]}
|
||||
onCloseClick={onClose}
|
||||
>
|
||||
<div style={{ minWidth: 190, fontSize: 13 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 2 }}>{deviceLabel(device)}</div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 2 }}>
|
||||
{deviceLabel(device)}
|
||||
</div>
|
||||
<div style={{ fontFamily: "monospace" }}>
|
||||
{lat.toFixed(5)}, {lng.toFixed(5)}
|
||||
</div>
|
||||
<div style={{ color: "#555" }}>
|
||||
{toNum(device.lastSpeed) ?? 0} km/h · {device.lastCourse ?? 0}°
|
||||
</div>
|
||||
<div style={{ color: "#777", marginTop: 4 }}>{address ?? "Locating…"}</div>
|
||||
<div style={{ color: "#777", marginTop: 4 }}>
|
||||
{address ?? "Locating…"}
|
||||
</div>
|
||||
</div>
|
||||
</InfoWindow>
|
||||
);
|
||||
@@ -184,7 +207,8 @@ export function TrackingPage() {
|
||||
|
||||
const { data: vehiclesData } = useQuery({
|
||||
queryKey: ["vehicles", "all"],
|
||||
queryFn: async () => (await vehiclesService.getAll({ limit: 1000 })).data ?? [],
|
||||
queryFn: async () =>
|
||||
(await vehiclesService.getAll({ limit: 1000 })).data ?? [],
|
||||
});
|
||||
const vehicleOptions = useMemo(
|
||||
() =>
|
||||
@@ -199,7 +223,10 @@ export function TrackingPage() {
|
||||
() =>
|
||||
devices
|
||||
.map((d) => ({ d, lat: toNum(d.lastLat), lng: toNum(d.lastLng) }))
|
||||
.filter((x): x is { d: GpsDevice; lat: number; lng: number } => x.lat != null && x.lng != null),
|
||||
.filter(
|
||||
(x): x is { d: GpsDevice; lat: number; lng: number } =>
|
||||
x.lat != null && x.lng != null,
|
||||
),
|
||||
[devices],
|
||||
);
|
||||
|
||||
@@ -209,19 +236,31 @@ export function TrackingPage() {
|
||||
// Route history for the selected device's vehicle (chronological trail).
|
||||
const { data: history = [] } = useQuery({
|
||||
queryKey: ["gps", "history", selected?.vehicleId],
|
||||
queryFn: async () => (await gpsTrackingService.history(selected!.vehicleId!, 300)).data ?? [],
|
||||
queryFn: async () =>
|
||||
(await gpsTrackingService.history(selected!.vehicleId!, 300)).data ?? [],
|
||||
enabled: Boolean(selected?.vehicleId),
|
||||
});
|
||||
const trail = useMemo(
|
||||
() => [...history].reverse().map((h) => ({ lat: Number(h.lat), lng: Number(h.lng) })),
|
||||
() =>
|
||||
[...history]
|
||||
.reverse()
|
||||
.map((h) => ({ lat: Number(h.lat), lng: Number(h.lng) })),
|
||||
[history],
|
||||
);
|
||||
|
||||
// Teardrop pin colored by state with a white truck glyph inside.
|
||||
const markerIcon = (d: GpsDevice, selectedFlag: boolean): google.maps.Icon | undefined => {
|
||||
const markerIcon = (
|
||||
d: GpsDevice,
|
||||
selectedFlag: boolean,
|
||||
): google.maps.Icon | undefined => {
|
||||
// Maps API loads async — Size/Point classes may not exist yet at first render.
|
||||
if (typeof google === "undefined" || !google.maps?.Size || !mapsReady) return undefined;
|
||||
const color = selectedFlag ? freightBrand.primary : d.online ? "#2f80ed" : "#95a5a6";
|
||||
if (typeof google === "undefined" || !google.maps?.Size || !mapsReady)
|
||||
return undefined;
|
||||
const color = selectedFlag
|
||||
? freightBrand.primary
|
||||
: d.online
|
||||
? "#2f80ed"
|
||||
: "#95a5a6";
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="40" height="48" viewBox="0 0 40 48">
|
||||
<path d="M20 2C10 2 2 10 2 20c0 12 18 26 18 26s18-14 18-26C38 10 30 2 20 2Z" fill="${color}" stroke="#ffffff" stroke-width="1.5"/>
|
||||
<g transform="translate(8,7)" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -260,8 +299,13 @@ export function TrackingPage() {
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const description =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? "Failed";
|
||||
toast({ title: editDevice ? "Update failed" : "Registration failed", description, variant: "destructive" });
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed";
|
||||
toast({
|
||||
title: editDevice ? "Update failed" : "Registration failed",
|
||||
description,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -287,15 +331,25 @@ export function TrackingPage() {
|
||||
|
||||
return (
|
||||
<Container size="xl" py="xl" px="lg">
|
||||
<Breadcrumbs items={[{ label: "Fleet" }, { label: "Vehicle Tracking" }]} />
|
||||
<Breadcrumbs
|
||||
items={[{ label: "Fleet" }, { label: "Vehicle Tracking" }]}
|
||||
/>
|
||||
|
||||
<Group justify="space-between" mb="xl">
|
||||
<div>
|
||||
<Text fw={700} size="xl">Real-Time Vehicle Tracking</Text>
|
||||
<Text c="dimmed" size="sm">Live GPS positions from GT06 trackers</Text>
|
||||
<Text fw={700} size="xl">
|
||||
Real-Time Vehicle Tracking
|
||||
</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
Live GPS positions from GT06 trackers
|
||||
</Text>
|
||||
</div>
|
||||
{canManage && (
|
||||
<Button leftSection={<Plus size={16} />} color="edr-green" onClick={openRegister}>
|
||||
<Button
|
||||
leftSection={<Plus size={16} />}
|
||||
color="edr-green"
|
||||
onClick={openRegister}
|
||||
>
|
||||
Register tracker
|
||||
</Button>
|
||||
)}
|
||||
@@ -314,36 +368,82 @@ export function TrackingPage() {
|
||||
</Group>
|
||||
</Card.Section>
|
||||
<Card.Section p="md">
|
||||
<Box style={{ height: 500, width: "100%", borderRadius: 8, overflow: "hidden" }}>
|
||||
<APIProvider apiKey={GOOGLE_MAPS_API_KEY}>
|
||||
<GoogleMap
|
||||
defaultCenter={DEFAULT_CENTER}
|
||||
defaultZoom={7}
|
||||
gestureHandling="greedy"
|
||||
disableDefaultUI={false}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
<Box
|
||||
style={{
|
||||
height: 500,
|
||||
width: "100%",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{!GOOGLE_MAPS_API_KEY ? (
|
||||
// Name the missing variable rather than showing an empty map: the
|
||||
// device list beside this still works, so a blank panel reads as
|
||||
// "no GPS fixes" instead of "no map key".
|
||||
<Box
|
||||
style={{
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 24,
|
||||
textAlign: "center",
|
||||
border: "1px solid #F0D2A8",
|
||||
borderRadius: 8,
|
||||
background: "#FFF9F0",
|
||||
}}
|
||||
>
|
||||
<ReadyProbe onReady={() => setMapsReady(true)} />
|
||||
{positioned.map(({ d, lat, lng }) => (
|
||||
<Marker
|
||||
key={d.id}
|
||||
position={{ lat, lng }}
|
||||
title={`${deviceLabel(d)}\n${lat.toFixed(5)}, ${lng.toFixed(5)}`}
|
||||
icon={markerIcon(d, d.id === selectedId)}
|
||||
onClick={() => setSelectedId(d.id)}
|
||||
onMouseOver={() => setHoverId(d.id)}
|
||||
<Text fz="sm" c="#8A5A16">
|
||||
Map unavailable — <code>VITE_GOOGLE_MAPS_API_KEY</code> is
|
||||
not set. Add a Google Maps key with the Maps JavaScript
|
||||
API and Places API enabled to this app's{" "}
|
||||
<code>.env</code>, then restart the dev server. Device
|
||||
positions below are unaffected.
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<APIProvider apiKey={GOOGLE_MAPS_API_KEY}>
|
||||
<GoogleMap
|
||||
defaultCenter={DEFAULT_CENTER}
|
||||
defaultZoom={7}
|
||||
gestureHandling="greedy"
|
||||
disableDefaultUI={false}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
>
|
||||
<ReadyProbe onReady={() => setMapsReady(true)} />
|
||||
{positioned.map(({ d, lat, lng }) => (
|
||||
<Marker
|
||||
key={d.id}
|
||||
position={{ lat, lng }}
|
||||
title={`${deviceLabel(d)}\n${lat.toFixed(5)}, ${lng.toFixed(5)}`}
|
||||
icon={markerIcon(d, d.id === selectedId)}
|
||||
onClick={() => setSelectedId(d.id)}
|
||||
onMouseOver={() => setHoverId(d.id)}
|
||||
/>
|
||||
))}
|
||||
{(() => {
|
||||
const h = positioned.find((p) => p.d.id === hoverId);
|
||||
return h ? (
|
||||
<HoverInfo
|
||||
device={h.d}
|
||||
lat={h.lat}
|
||||
lng={h.lng}
|
||||
onClose={() => setHoverId(null)}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
<FitBounds
|
||||
points={positioned.map((p) => ({
|
||||
lat: p.lat,
|
||||
lng: p.lng,
|
||||
}))}
|
||||
/>
|
||||
))}
|
||||
{(() => {
|
||||
const h = positioned.find((p) => p.d.id === hoverId);
|
||||
return h ? (
|
||||
<HoverInfo device={h.d} lat={h.lat} lng={h.lng} onClose={() => setHoverId(null)} />
|
||||
) : null;
|
||||
})()}
|
||||
<FitBounds points={positioned.map((p) => ({ lat: p.lat, lng: p.lng }))} />
|
||||
{selected?.vehicleId && trail.length > 1 && <RouteTrail path={trail} />}
|
||||
</GoogleMap>
|
||||
</APIProvider>
|
||||
{selected?.vehicleId && trail.length > 1 && (
|
||||
<RouteTrail path={trail} />
|
||||
)}
|
||||
</GoogleMap>
|
||||
</APIProvider>
|
||||
)}
|
||||
</Box>
|
||||
{positioned.length === 0 && (
|
||||
<Text size="sm" c="dimmed" ta="center" mt="sm">
|
||||
@@ -363,11 +463,19 @@ export function TrackingPage() {
|
||||
<Group justify="space-between">
|
||||
<Text fw={500}>{deviceLabel(selected)}</Text>
|
||||
<Group gap="xs">
|
||||
<Badge color={selected.online ? "edr-green" : "gray"} leftSection={<Activity size={12} />}>
|
||||
<Badge
|
||||
color={selected.online ? "edr-green" : "gray"}
|
||||
leftSection={<Activity size={12} />}
|
||||
>
|
||||
{selected.online ? "Live" : "Offline"}
|
||||
</Badge>
|
||||
{canManage && (
|
||||
<ActionIcon variant="subtle" color="red" aria-label="Remove tracker" onClick={() => deleteMutation.mutate(selected.id)}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label="Remove tracker"
|
||||
onClick={() => deleteMutation.mutate(selected.id)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
@@ -375,21 +483,55 @@ export function TrackingPage() {
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<StatBox label="Latitude" value={toNum(selected.lastLat)?.toFixed(5) ?? "—"} />
|
||||
<StatBox label="Longitude" value={toNum(selected.lastLng)?.toFixed(5) ?? "—"} />
|
||||
<StatBox label="Speed" value={`${toNum(selected.lastSpeed) ?? 0} km/h`} />
|
||||
<StatBox label="Course" value={`${selected.lastCourse ?? 0}°`} />
|
||||
<StatBox label="Voltage" value={selected.voltageLevel != null ? `${selected.voltageLevel}/6` : "—"} />
|
||||
<StatBox label="GSM" value={selected.gsmLevel != null ? `${selected.gsmLevel}/4` : "—"} />
|
||||
<StatBox
|
||||
label="Latitude"
|
||||
value={toNum(selected.lastLat)?.toFixed(5) ?? "—"}
|
||||
/>
|
||||
<StatBox
|
||||
label="Longitude"
|
||||
value={toNum(selected.lastLng)?.toFixed(5) ?? "—"}
|
||||
/>
|
||||
<StatBox
|
||||
label="Speed"
|
||||
value={`${toNum(selected.lastSpeed) ?? 0} km/h`}
|
||||
/>
|
||||
<StatBox
|
||||
label="Course"
|
||||
value={`${selected.lastCourse ?? 0}°`}
|
||||
/>
|
||||
<StatBox
|
||||
label="Voltage"
|
||||
value={
|
||||
selected.voltageLevel != null
|
||||
? `${selected.voltageLevel}/6`
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<StatBox
|
||||
label="GSM"
|
||||
value={
|
||||
selected.gsmLevel != null
|
||||
? `${selected.gsmLevel}/4`
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">IMEI</Text>
|
||||
<Text fw={500} size="sm">{selected.imei}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
IMEI
|
||||
</Text>
|
||||
<Text fw={500} size="sm">
|
||||
{selected.imei}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">Last fix</Text>
|
||||
<Text fw={500} size="sm">{fmtTime(selected.lastFixAt)}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Last fix
|
||||
</Text>
|
||||
<Text fw={500} size="sm">
|
||||
{fmtTime(selected.lastFixAt)}
|
||||
</Text>
|
||||
</div>
|
||||
{selected.vehicleId && (
|
||||
<Text size="xs" c="dimmed">
|
||||
@@ -402,7 +544,9 @@ export function TrackingPage() {
|
||||
placeholder="Unassigned"
|
||||
data={vehicleOptions}
|
||||
value={selected.vehicleId ?? null}
|
||||
onChange={(v) => assignMutation.mutate({ id: selected.id, vehicleId: v })}
|
||||
onChange={(v) =>
|
||||
assignMutation.mutate({ id: selected.id, vehicleId: v })
|
||||
}
|
||||
disabled={!canManage}
|
||||
searchable
|
||||
clearable
|
||||
@@ -420,24 +564,43 @@ export function TrackingPage() {
|
||||
{devices.map((d) => (
|
||||
<Table.Tr
|
||||
key={d.id}
|
||||
style={{ cursor: "pointer", backgroundColor: d.id === selectedId ? freightBrand.mutedBg : "transparent" }}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
backgroundColor:
|
||||
d.id === selectedId
|
||||
? freightBrand.mutedBg
|
||||
: "transparent",
|
||||
}}
|
||||
onClick={() => setSelectedId(d.id)}
|
||||
>
|
||||
<Table.Td>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw={600}>{deviceLabel(d)}</Text>
|
||||
<Text size="xs" c="dimmed">{toNum(d.lastSpeed) ?? 0} km/h · {fmtTime(d.lastFixAt)}</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{deviceLabel(d)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{toNum(d.lastSpeed) ?? 0} km/h ·{" "}
|
||||
{fmtTime(d.lastFixAt)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td align="right">
|
||||
<Group gap={6} justify="flex-end" wrap="nowrap">
|
||||
<Badge color={d.online ? "edr-green" : "gray"} size="sm">{d.online ? "Live" : "Offline"}</Badge>
|
||||
<Badge
|
||||
color={d.online ? "edr-green" : "gray"}
|
||||
size="sm"
|
||||
>
|
||||
{d.online ? "Live" : "Offline"}
|
||||
</Badge>
|
||||
{canManage && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
aria-label="Edit tracker"
|
||||
onClick={(e) => { e.stopPropagation(); openEdit(d); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openEdit(d);
|
||||
}}
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</ActionIcon>
|
||||
@@ -449,7 +612,9 @@ export function TrackingPage() {
|
||||
{devices.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={2}>
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">No trackers registered yet.</Text>
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
No trackers registered yet.
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
@@ -495,7 +660,9 @@ export function TrackingPage() {
|
||||
clearable
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setModalOpen(false)}>Cancel</Button>
|
||||
<Button variant="default" onClick={() => setModalOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
loading={saveMutation.isPending}
|
||||
disabled={!form.imei.trim()}
|
||||
|
||||
@@ -85,14 +85,15 @@ export function getNextInventoryAction(item: WarehouseInventoryItem): InventoryA
|
||||
if (isImport) return inspected ? 'ready-for-pickup' : null;
|
||||
return 'store';
|
||||
case 'STORED':
|
||||
// Reserve is retired: a stored export item goes straight to loading prep
|
||||
// once inspection passes. An import item parked back into storage returns
|
||||
// to pickup — otherwise Store would strand it with no action.
|
||||
// Reserve is retired: a stored export item goes straight to loading prep.
|
||||
// An import item parked back into storage returns to pickup — otherwise
|
||||
// Store would strand it with no action.
|
||||
if (isImport) return inspected ? 'ready-for-pickup' : null;
|
||||
return inspected ? 'ready-for-loading' : null;
|
||||
return 'ready-for-loading';
|
||||
case 'RESERVED':
|
||||
// Export loading is gated on a passed inspection.
|
||||
return inspected ? 'ready-for-loading' : null;
|
||||
// Export loading is not gated on inspection — inspection is tracked, but a
|
||||
// train is never held waiting for it.
|
||||
return 'ready-for-loading';
|
||||
case 'READY_FOR_PICKUP':
|
||||
// Issue the DO / release order first, then hand over the goods.
|
||||
return item.releaseDate ? 'deliver' : 'release';
|
||||
|
||||
@@ -45,17 +45,16 @@ interface PlacePrediction {
|
||||
}
|
||||
|
||||
// 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
|
||||
// override the default key without a code change.
|
||||
// HTTP-referrer in the Google Cloud console), so this one env var is the whole
|
||||
// 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 —
|
||||
// "Google Maps JavaScript API error: ExpiredKeyMapError"), which renders
|
||||
// this picker's map blank while the search box spins forever. Set
|
||||
// VITE_GOOGLE_MAPS_API_KEY (see .env.example) to a live key to fix it; don't
|
||||
// rely on this default.
|
||||
const GOOGLE_MAPS_API_KEY =
|
||||
import.meta.env.VITE_GOOGLE_MAPS_API_KEY ||
|
||||
"AIzaSyBg4tN31-fgvH_2Ix_TPo6VSfOA2uA5CCI";
|
||||
// There is deliberately no fallback key. A hardcoded default used to live here
|
||||
// and expired, which degraded a missing env var into a blank map with a search
|
||||
// box that spun forever — indistinguishable from a broken picker. Absent config
|
||||
// now says so on screen instead.
|
||||
const GOOGLE_MAPS_API_KEY = import.meta.env.VITE_GOOGLE_MAPS_API_KEY?.trim();
|
||||
|
||||
// Centre of the EDR corridor (Addis Ababa) — a sensible default view.
|
||||
const DEFAULT_CENTER = { lat: 9.03, lng: 38.74 };
|
||||
@@ -197,10 +196,7 @@ async function resolvePrediction(
|
||||
},
|
||||
(place, status) => {
|
||||
const loc = place?.geometry?.location;
|
||||
if (
|
||||
status !== google.maps.places.PlacesServiceStatus.OK ||
|
||||
!loc
|
||||
) {
|
||||
if (status !== google.maps.places.PlacesServiceStatus.OK || !loc) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
@@ -315,6 +311,7 @@ export function LocationPicker(props: LocationPickerProps) {
|
||||
lat: toFiniteNumber(props.value.lat),
|
||||
lng: toFiniteNumber(props.value.lng),
|
||||
};
|
||||
if (!GOOGLE_MAPS_API_KEY) return <MapUnavailable label={props.label} />;
|
||||
return (
|
||||
<APIProvider apiKey={GOOGLE_MAPS_API_KEY}>
|
||||
{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. */
|
||||
function LocationPickerModal({
|
||||
value,
|
||||
@@ -374,7 +407,12 @@ function LocationPickerModal({
|
||||
>
|
||||
<MapPin size={16} />
|
||||
</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}
|
||||
</Text>
|
||||
<Text fz={12.5} fw={600} c="#0A6F4D" style={{ flexShrink: 0 }}>
|
||||
|
||||
Reference in New Issue
Block a user