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

View File

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

View File

@@ -30,15 +30,17 @@ import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { vehiclesService } from "@/services/vehicles.service";
import { gpsTrackingService, type GpsDevice } from "@/services/gps-tracking.service";
import {
gpsTrackingService,
type GpsDevice,
} from "@/services/gps-tracking.service";
import { freightBrand } from "@/theme/freight-brand";
// Same default key + env override the portal's LocationPicker uses.
// NOTE: fallback key is EXPIRED (ExpiredKeyMapError) — set
// VITE_GOOGLE_MAPS_API_KEY (see .env.example) to a live key.
const GOOGLE_MAPS_API_KEY =
import.meta.env.VITE_GOOGLE_MAPS_API_KEY ||
"AIzaSyBg4tN31-fgvH_2Ix_TPo6VSfOA2uA5CCI";
// Maps JavaScript API keys are public client-side keys — lock them down by
// HTTP-referrer in the Google Cloud console. No fallback: a hardcoded default
// used to live here and expired, turning a missing env var into a blank map
// that read as broken GPS rather than absent configuration.
const GOOGLE_MAPS_API_KEY = import.meta.env.VITE_GOOGLE_MAPS_API_KEY?.trim();
const DEFAULT_CENTER = { lat: 9.03, lng: 38.74 }; // Addis Ababa
const toNum = (v: number | string | null | undefined): number | null =>
@@ -57,8 +59,12 @@ const fmtTime = (iso?: string | null) => {
const StatBox = ({ label, value }: { label: string; value: string }) => (
<Box p="sm" style={{ backgroundColor: "#f8f9fa", borderRadius: 8 }}>
<Text size="xs" c="dimmed">{label}</Text>
<Text fw={600} size="sm">{value}</Text>
<Text size="xs" c="dimmed">
{label}
</Text>
<Text fw={600} size="sm">
{value}
</Text>
</Box>
);
@@ -97,11 +103,20 @@ function useAddress(lat: number, lng: number): string | null {
if (typeof google === "undefined" || !google.maps?.Geocoder) return;
setAddr(null);
let cancelled = false;
new google.maps.Geocoder().geocode({ location: { lat, lng } }, (res, status) => {
if (cancelled) return;
setAddr(status === "OK" && res?.[0] ? res[0].formatted_address : "Unknown location");
});
return () => { cancelled = true; };
new google.maps.Geocoder().geocode(
{ location: { lat, lng } },
(res, status) => {
if (cancelled) return;
setAddr(
status === "OK" && res?.[0]
? res[0].formatted_address
: "Unknown location",
);
},
);
return () => {
cancelled = true;
};
}, [lat, lng]);
return addr;
}
@@ -120,16 +135,24 @@ function HoverInfo({
}) {
const address = useAddress(lat, lng);
return (
<InfoWindow position={{ lat, lng }} pixelOffset={[0, -46]} onCloseClick={onClose}>
<InfoWindow
position={{ lat, lng }}
pixelOffset={[0, -46]}
onCloseClick={onClose}
>
<div style={{ minWidth: 190, fontSize: 13 }}>
<div style={{ fontWeight: 600, marginBottom: 2 }}>{deviceLabel(device)}</div>
<div style={{ fontWeight: 600, marginBottom: 2 }}>
{deviceLabel(device)}
</div>
<div style={{ fontFamily: "monospace" }}>
{lat.toFixed(5)}, {lng.toFixed(5)}
</div>
<div style={{ color: "#555" }}>
{toNum(device.lastSpeed) ?? 0} km/h · {device.lastCourse ?? 0}°
</div>
<div style={{ color: "#777", marginTop: 4 }}>{address ?? "Locating…"}</div>
<div style={{ color: "#777", marginTop: 4 }}>
{address ?? "Locating…"}
</div>
</div>
</InfoWindow>
);
@@ -184,7 +207,8 @@ export function TrackingPage() {
const { data: vehiclesData } = useQuery({
queryKey: ["vehicles", "all"],
queryFn: async () => (await vehiclesService.getAll({ limit: 1000 })).data ?? [],
queryFn: async () =>
(await vehiclesService.getAll({ limit: 1000 })).data ?? [],
});
const vehicleOptions = useMemo(
() =>
@@ -199,7 +223,10 @@ export function TrackingPage() {
() =>
devices
.map((d) => ({ d, lat: toNum(d.lastLat), lng: toNum(d.lastLng) }))
.filter((x): x is { d: GpsDevice; lat: number; lng: number } => x.lat != null && x.lng != null),
.filter(
(x): x is { d: GpsDevice; lat: number; lng: number } =>
x.lat != null && x.lng != null,
),
[devices],
);
@@ -209,19 +236,31 @@ export function TrackingPage() {
// Route history for the selected device's vehicle (chronological trail).
const { data: history = [] } = useQuery({
queryKey: ["gps", "history", selected?.vehicleId],
queryFn: async () => (await gpsTrackingService.history(selected!.vehicleId!, 300)).data ?? [],
queryFn: async () =>
(await gpsTrackingService.history(selected!.vehicleId!, 300)).data ?? [],
enabled: Boolean(selected?.vehicleId),
});
const trail = useMemo(
() => [...history].reverse().map((h) => ({ lat: Number(h.lat), lng: Number(h.lng) })),
() =>
[...history]
.reverse()
.map((h) => ({ lat: Number(h.lat), lng: Number(h.lng) })),
[history],
);
// Teardrop pin colored by state with a white truck glyph inside.
const markerIcon = (d: GpsDevice, selectedFlag: boolean): google.maps.Icon | undefined => {
const markerIcon = (
d: GpsDevice,
selectedFlag: boolean,
): google.maps.Icon | undefined => {
// Maps API loads async — Size/Point classes may not exist yet at first render.
if (typeof google === "undefined" || !google.maps?.Size || !mapsReady) return undefined;
const color = selectedFlag ? freightBrand.primary : d.online ? "#2f80ed" : "#95a5a6";
if (typeof google === "undefined" || !google.maps?.Size || !mapsReady)
return undefined;
const color = selectedFlag
? freightBrand.primary
: d.online
? "#2f80ed"
: "#95a5a6";
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="40" height="48" viewBox="0 0 40 48">
<path d="M20 2C10 2 2 10 2 20c0 12 18 26 18 26s18-14 18-26C38 10 30 2 20 2Z" fill="${color}" stroke="#ffffff" stroke-width="1.5"/>
<g transform="translate(8,7)" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -260,8 +299,13 @@ export function TrackingPage() {
},
onError: (err: unknown) => {
const description =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? "Failed";
toast({ title: editDevice ? "Update failed" : "Registration failed", description, variant: "destructive" });
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed";
toast({
title: editDevice ? "Update failed" : "Registration failed",
description,
variant: "destructive",
});
},
});
@@ -287,15 +331,25 @@ export function TrackingPage() {
return (
<Container size="xl" py="xl" px="lg">
<Breadcrumbs items={[{ label: "Fleet" }, { label: "Vehicle Tracking" }]} />
<Breadcrumbs
items={[{ label: "Fleet" }, { label: "Vehicle Tracking" }]}
/>
<Group justify="space-between" mb="xl">
<div>
<Text fw={700} size="xl">Real-Time Vehicle Tracking</Text>
<Text c="dimmed" size="sm">Live GPS positions from GT06 trackers</Text>
<Text fw={700} size="xl">
Real-Time Vehicle Tracking
</Text>
<Text c="dimmed" size="sm">
Live GPS positions from GT06 trackers
</Text>
</div>
{canManage && (
<Button leftSection={<Plus size={16} />} color="edr-green" onClick={openRegister}>
<Button
leftSection={<Plus size={16} />}
color="edr-green"
onClick={openRegister}
>
Register tracker
</Button>
)}
@@ -314,36 +368,82 @@ export function TrackingPage() {
</Group>
</Card.Section>
<Card.Section p="md">
<Box style={{ height: 500, width: "100%", borderRadius: 8, overflow: "hidden" }}>
<APIProvider apiKey={GOOGLE_MAPS_API_KEY}>
<GoogleMap
defaultCenter={DEFAULT_CENTER}
defaultZoom={7}
gestureHandling="greedy"
disableDefaultUI={false}
style={{ width: "100%", height: "100%" }}
<Box
style={{
height: 500,
width: "100%",
borderRadius: 8,
overflow: "hidden",
}}
>
{!GOOGLE_MAPS_API_KEY ? (
// Name the missing variable rather than showing an empty map: the
// device list beside this still works, so a blank panel reads as
// "no GPS fixes" instead of "no map key".
<Box
style={{
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 24,
textAlign: "center",
border: "1px solid #F0D2A8",
borderRadius: 8,
background: "#FFF9F0",
}}
>
<ReadyProbe onReady={() => setMapsReady(true)} />
{positioned.map(({ d, lat, lng }) => (
<Marker
key={d.id}
position={{ lat, lng }}
title={`${deviceLabel(d)}\n${lat.toFixed(5)}, ${lng.toFixed(5)}`}
icon={markerIcon(d, d.id === selectedId)}
onClick={() => setSelectedId(d.id)}
onMouseOver={() => setHoverId(d.id)}
<Text fz="sm" c="#8A5A16">
Map unavailable <code>VITE_GOOGLE_MAPS_API_KEY</code> is
not set. Add a Google Maps key with the Maps JavaScript
API and Places API enabled to this app&apos;s{" "}
<code>.env</code>, then restart the dev server. Device
positions below are unaffected.
</Text>
</Box>
) : (
<APIProvider apiKey={GOOGLE_MAPS_API_KEY}>
<GoogleMap
defaultCenter={DEFAULT_CENTER}
defaultZoom={7}
gestureHandling="greedy"
disableDefaultUI={false}
style={{ width: "100%", height: "100%" }}
>
<ReadyProbe onReady={() => setMapsReady(true)} />
{positioned.map(({ d, lat, lng }) => (
<Marker
key={d.id}
position={{ lat, lng }}
title={`${deviceLabel(d)}\n${lat.toFixed(5)}, ${lng.toFixed(5)}`}
icon={markerIcon(d, d.id === selectedId)}
onClick={() => setSelectedId(d.id)}
onMouseOver={() => setHoverId(d.id)}
/>
))}
{(() => {
const h = positioned.find((p) => p.d.id === hoverId);
return h ? (
<HoverInfo
device={h.d}
lat={h.lat}
lng={h.lng}
onClose={() => setHoverId(null)}
/>
) : null;
})()}
<FitBounds
points={positioned.map((p) => ({
lat: p.lat,
lng: p.lng,
}))}
/>
))}
{(() => {
const h = positioned.find((p) => p.d.id === hoverId);
return h ? (
<HoverInfo device={h.d} lat={h.lat} lng={h.lng} onClose={() => setHoverId(null)} />
) : null;
})()}
<FitBounds points={positioned.map((p) => ({ lat: p.lat, lng: p.lng }))} />
{selected?.vehicleId && trail.length > 1 && <RouteTrail path={trail} />}
</GoogleMap>
</APIProvider>
{selected?.vehicleId && trail.length > 1 && (
<RouteTrail path={trail} />
)}
</GoogleMap>
</APIProvider>
)}
</Box>
{positioned.length === 0 && (
<Text size="sm" c="dimmed" ta="center" mt="sm">
@@ -363,11 +463,19 @@ export function TrackingPage() {
<Group justify="space-between">
<Text fw={500}>{deviceLabel(selected)}</Text>
<Group gap="xs">
<Badge color={selected.online ? "edr-green" : "gray"} leftSection={<Activity size={12} />}>
<Badge
color={selected.online ? "edr-green" : "gray"}
leftSection={<Activity size={12} />}
>
{selected.online ? "Live" : "Offline"}
</Badge>
{canManage && (
<ActionIcon variant="subtle" color="red" aria-label="Remove tracker" onClick={() => deleteMutation.mutate(selected.id)}>
<ActionIcon
variant="subtle"
color="red"
aria-label="Remove tracker"
onClick={() => deleteMutation.mutate(selected.id)}
>
<Trash2 size={16} />
</ActionIcon>
)}
@@ -375,21 +483,55 @@ export function TrackingPage() {
</Group>
<SimpleGrid cols={2} spacing="sm">
<StatBox label="Latitude" value={toNum(selected.lastLat)?.toFixed(5) ?? "—"} />
<StatBox label="Longitude" value={toNum(selected.lastLng)?.toFixed(5) ?? "—"} />
<StatBox label="Speed" value={`${toNum(selected.lastSpeed) ?? 0} km/h`} />
<StatBox label="Course" value={`${selected.lastCourse ?? 0}°`} />
<StatBox label="Voltage" value={selected.voltageLevel != null ? `${selected.voltageLevel}/6` : "—"} />
<StatBox label="GSM" value={selected.gsmLevel != null ? `${selected.gsmLevel}/4` : "—"} />
<StatBox
label="Latitude"
value={toNum(selected.lastLat)?.toFixed(5) ?? "—"}
/>
<StatBox
label="Longitude"
value={toNum(selected.lastLng)?.toFixed(5) ?? "—"}
/>
<StatBox
label="Speed"
value={`${toNum(selected.lastSpeed) ?? 0} km/h`}
/>
<StatBox
label="Course"
value={`${selected.lastCourse ?? 0}°`}
/>
<StatBox
label="Voltage"
value={
selected.voltageLevel != null
? `${selected.voltageLevel}/6`
: "—"
}
/>
<StatBox
label="GSM"
value={
selected.gsmLevel != null
? `${selected.gsmLevel}/4`
: "—"
}
/>
</SimpleGrid>
<div>
<Text size="xs" c="dimmed">IMEI</Text>
<Text fw={500} size="sm">{selected.imei}</Text>
<Text size="xs" c="dimmed">
IMEI
</Text>
<Text fw={500} size="sm">
{selected.imei}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">Last fix</Text>
<Text fw={500} size="sm">{fmtTime(selected.lastFixAt)}</Text>
<Text size="xs" c="dimmed">
Last fix
</Text>
<Text fw={500} size="sm">
{fmtTime(selected.lastFixAt)}
</Text>
</div>
{selected.vehicleId && (
<Text size="xs" c="dimmed">
@@ -402,7 +544,9 @@ export function TrackingPage() {
placeholder="Unassigned"
data={vehicleOptions}
value={selected.vehicleId ?? null}
onChange={(v) => assignMutation.mutate({ id: selected.id, vehicleId: v })}
onChange={(v) =>
assignMutation.mutate({ id: selected.id, vehicleId: v })
}
disabled={!canManage}
searchable
clearable
@@ -420,24 +564,43 @@ export function TrackingPage() {
{devices.map((d) => (
<Table.Tr
key={d.id}
style={{ cursor: "pointer", backgroundColor: d.id === selectedId ? freightBrand.mutedBg : "transparent" }}
style={{
cursor: "pointer",
backgroundColor:
d.id === selectedId
? freightBrand.mutedBg
: "transparent",
}}
onClick={() => setSelectedId(d.id)}
>
<Table.Td>
<Stack gap={0}>
<Text size="sm" fw={600}>{deviceLabel(d)}</Text>
<Text size="xs" c="dimmed">{toNum(d.lastSpeed) ?? 0} km/h · {fmtTime(d.lastFixAt)}</Text>
<Text size="sm" fw={600}>
{deviceLabel(d)}
</Text>
<Text size="xs" c="dimmed">
{toNum(d.lastSpeed) ?? 0} km/h ·{" "}
{fmtTime(d.lastFixAt)}
</Text>
</Stack>
</Table.Td>
<Table.Td align="right">
<Group gap={6} justify="flex-end" wrap="nowrap">
<Badge color={d.online ? "edr-green" : "gray"} size="sm">{d.online ? "Live" : "Offline"}</Badge>
<Badge
color={d.online ? "edr-green" : "gray"}
size="sm"
>
{d.online ? "Live" : "Offline"}
</Badge>
{canManage && (
<ActionIcon
variant="subtle"
size="sm"
aria-label="Edit tracker"
onClick={(e) => { e.stopPropagation(); openEdit(d); }}
onClick={(e) => {
e.stopPropagation();
openEdit(d);
}}
>
<Pencil size={15} />
</ActionIcon>
@@ -449,7 +612,9 @@ export function TrackingPage() {
{devices.length === 0 && (
<Table.Tr>
<Table.Td colSpan={2}>
<Text size="sm" c="dimmed" ta="center" py="md">No trackers registered yet.</Text>
<Text size="sm" c="dimmed" ta="center" py="md">
No trackers registered yet.
</Text>
</Table.Td>
</Table.Tr>
)}
@@ -495,7 +660,9 @@ export function TrackingPage() {
clearable
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setModalOpen(false)}>Cancel</Button>
<Button variant="default" onClick={() => setModalOpen(false)}>
Cancel
</Button>
<Button
loading={saveMutation.isPending}
disabled={!form.imei.trim()}

View File

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

View File

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