From fec2a5d3208e1250418a3900243f17229e434cb0 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 2 Jul 2026 13:27:59 +0000 Subject: [PATCH] update import gl flow --- .../contracts/GlClearanceUploadModal.tsx | 3 +- .../contracts/PhasedClearanceActionPanel.tsx | 232 ++++++++++++- .../backoffice/src/constants/URLS.ts | 4 + .../pages/contracts/GlClearanceDetailPage.tsx | 3 +- .../src/services/contracts.service.ts | 21 ++ apps/edr-freight-web/portal/package.json | 5 +- .../new-booking-form/LocationPicker.tsx | 308 ++++++++---------- apps/edr-freight-web/portal/src/vite-env.d.ts | 1 + .../src/freight/clearance-files.catalog.ts | 28 ++ packages/types/src/freight/contracts.ts | 16 + packages/types/src/freight/index.ts | 2 + pnpm-lock.yaml | 81 ++--- 12 files changed, 452 insertions(+), 252 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx index b33729bf1..ba54e3b8b 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx @@ -121,7 +121,8 @@ export function GlClearanceUploadModal({ & { operationReady?: boolean }; type MilestoneRow = NonNullable[number]; @@ -79,7 +80,10 @@ function isBookingMilestoneDone( return m?.status === "COMPLETED" || m?.status === "SKIPPED"; } -function computeImportActiveStep(clearance: ClearanceViewLike): number { +function computeImportActiveStep( + clearance: ClearanceViewLike, + bookingCreated: boolean, +): number { if (!isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")) return 0; if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 1; if ( @@ -98,7 +102,23 @@ function computeImportActiveStep(clearance: ClearanceViewLike): number { if (!isMilestoneDone(clearance.milestones, "TRANSIT_PERMIT_UPLOADED")) return 4; if (!clearance.preClearanceFinalized) return 5; if (!isMilestoneDone(clearance.milestones, "DO_COLLECTED")) return 6; - return 7; + if (!bookingCreated) return 7; + if (!clearance.t1?.closed) return 8; + return 9; +} + +function t1FilesFromWorkflow( + workflowFiles: Freight.ClearanceWorkflowFile[], +): Array<{ code: string; label: string; file: { id: string; name: string } }> { + return workflowFiles + .filter( + (f) => f.code.toLowerCase().startsWith("t1_transport_document") && f.file, + ) + .map((f) => ({ + code: f.code, + label: f.label, + file: f.file!, + })); } function declarationFilesFromWorkflow( @@ -174,9 +194,12 @@ export function PhasedClearanceActionPanel({ const showEt = roleMode === "ET" || roleMode === "ALL"; const showDj = roleMode === "DJ" || roleMode === "ALL"; const isImport = tradeDirection === "IMPORT"; + // The server only builds the t1 block once a booking is linked — use it as the + // booking-created signal on pages that don't pass bookingCreated (GL DJ detail). + const effectiveBookingCreated = bookingCreated || Boolean(clearance.t1); const activeStep = useMemo( - () => (isImport ? computeImportActiveStep(clearance) : 0), - [clearance, isImport], + () => (isImport ? computeImportActiveStep(clearance, effectiveBookingCreated) : 0), + [clearance, isImport, effectiveBookingCreated], ); if (isImport) { @@ -401,11 +424,7 @@ export function PhasedClearanceActionPanel({ description="GL Djibouti uploads DO" icon={} > - {showDj && - canDj && - !useUploadModals && - (activeStep >= 6 || - isMilestoneDone(clearance.milestones, "DO_COLLECTED")) ? ( + {showDj && canDj && !useUploadModals ? ( {useUploadModals && showDj && canDj && onUploadDoRequest ? ( @@ -439,7 +454,6 @@ export function PhasedClearanceActionPanel({ color="edr-green" leftSection={} onClick={onUploadDoRequest} - disabled={!clearance.preClearanceFinalized && !findWorkflowFile(workflowFiles, "delivery_order")} > {findWorkflowFile(workflowFiles, "delivery_order") ? "Replace DO" @@ -472,17 +486,37 @@ export function PhasedClearanceActionPanel({ ) : ( )} + + : + } + > + + @@ -578,6 +612,170 @@ export function PhasedClearanceActionPanel({ ); } +function ImportT1Section({ + t1, + workflowFiles = [], + canDjAct, + canEtAct, + onChanged, + onViewFile, + onDownloadFile, +}: { + t1: Freight.ClearanceT1State | null; + workflowFiles?: Freight.ClearanceWorkflowFile[]; + canDjAct: boolean; + canEtAct: boolean; + onChanged?: () => void; + onViewFile?: (file: { name: string; url: string }) => void; + onDownloadFile?: (file: { id: string; name: string }) => void; +}) { + const [files, setFiles] = useState([]); + const [uploading, setUploading] = useState(false); + const [closing, setClosing] = useState(false); + + const uploaded = t1FilesFromWorkflow(workflowFiles); + const replaceMode = uploaded.length > 0; + + if (!t1) { + return ( + + ); + } + + const departed = Boolean(t1.trainDepartedAt); + const arrived = Boolean(t1.trainArrivedAt); + const canUpload = canDjAct && t1.wagonAllocated && !departed && !t1.closed; + + return ( + + {uploaded.length > 0 ? ( + + + T1 document{uploaded.length > 1 ? "s" : ""} + + {uploaded.map((row) => ( + + ))} + + ) : null} + + {t1.closed ? ( + + ) : !t1.wagonAllocated ? ( + + ) : departed ? ( + }> + The train has departed — T1 documents are locked and can no longer be changed. + + ) : uploaded.length === 0 && !canUpload ? ( + + ) : null} + + {canUpload ? ( + <> + + + + + + ) : null} + + {canEtAct && !t1.closed ? ( + arrived ? ( + + + The train has arrived — review the T1 documents and close (accept) them. + + + + ) : departed ? ( + }> + Train en route — T1 can be closed once it arrives in Ethiopia. + + ) : null + ) : null} + + ); +} + function StepStatus({ done, pendingLabel, diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 753dcb2a5..eb48a89b1 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -211,6 +211,10 @@ export const URL_CONSTANTS = { `/contracts/bookings/${bookingId}/documents`, BOOKING_TRANSPORT_DOCUMENT: (bookingId: string) => `/contracts/bookings/${bookingId}/transport-document`, + BOOKING_T1_DOCUMENTS: (bookingId: string) => + `/contracts/bookings/${bookingId}/t1-documents`, + BOOKING_T1_CLOSE: (bookingId: string) => + `/contracts/bookings/${bookingId}/t1-close`, BOOKING_INCIDENTS: (bookingId: string) => `/contracts/bookings/${bookingId}/incidents`, }, diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx index ecf95fe89..6756ecc3e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx @@ -112,7 +112,8 @@ export default function GlClearanceDetailPage() { const isImport = data.tradeDirection === "IMPORT"; const hasDo = Boolean(findWorkflowFile(workflowFiles, "delivery_order")); const hasRo = Boolean(findWorkflowFile(workflowFiles, "release_order")); - const canUploadDo = isImport && Boolean(data.clearance.preClearanceFinalized || hasDo); + // DO upload is un-gated — Djibouti GL may attach it at any point, any file type. + const canUploadDo = isImport; const vesselDepartureDate = "vesselDepartureDate" in data.clearance ? (data.clearance.vesselDepartureDate ?? null) diff --git a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts index 465cc302d..fe923d016 100644 --- a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts @@ -333,6 +333,27 @@ export const contractsService = { return unwrap(response.data); }, + /** GL Djibouti uploads T1 transit documents (multi-file, post wagon allocation). */ + uploadT1Documents: async ( + bookingId: string, + files: Record, + ) => { + const form = new FormData(); + for (const [key, file] of Object.entries(files)) { + if (file) form.append(key, file); + } + const response = await client.post(C.BOOKING_T1_DOCUMENTS(bookingId), form, { + headers: { "Content-Type": "multipart/form-data" }, + }); + return unwrap(response.data); + }, + + /** GL Ethiopia closes (accepts) the T1 document set after the train arrives. */ + closeT1: async (bookingId: string): Promise => { + const response = await client.post(C.BOOKING_T1_CLOSE(bookingId)); + return unwrap(response.data) as Freight.ClearanceT1State; + }, + // ── Path A self-clearance (Operations review) ── getOpsClearanceQueue: async (): Promise => { const response = await client.get( diff --git a/apps/edr-freight-web/portal/package.json b/apps/edr-freight-web/portal/package.json index c445d67e9..a9bcf4ecc 100644 --- a/apps/edr-freight-web/portal/package.json +++ b/apps/edr-freight-web/portal/package.json @@ -20,18 +20,17 @@ "@mantine/hooks": "^9.3.0", "@tanstack/react-query": "^5.59.0", "@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz", + "@vis.gl/react-google-maps": "^1.8.3", "axios": "^1.7.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^3.6.0", - "leaflet": "^1.9.4", "lucide-react": "^1.14.0", "radix-ui": "^1.4.3", "react": "19.2.6", "react-dom": "19.2.6", "react-hook-form": "^7.76.0", "react-hot-toast": "^2.6.0", - "react-leaflet": "^5.0.0", "react-phone-number-input": "^3.4.17", "react-router-dom": "^6.27.0", "recharts": "^3.8.1", @@ -44,7 +43,7 @@ "@edr/tsconfig": "workspace:*", "@hookform/devtools": "^4.4.0", "@tailwindcss/vite": "^4.3.0", - "@types/leaflet": "^1.9.21", + "@types/google.maps": "^3.65.2", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.2", diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx index 4aad2be68..176cd0aa9 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx @@ -1,5 +1,3 @@ -import "leaflet/dist/leaflet.css"; - import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Box, @@ -13,8 +11,14 @@ import { useCombobox, } from "@mantine/core"; import { Check, MapPin, Search } from "lucide-react"; -import L from "leaflet"; -import { MapContainer, Marker, TileLayer, useMap, useMapEvents } from "react-leaflet"; +import { + APIProvider, + Map as GoogleMap, + type MapMouseEvent, + Marker, + useMap, + useMapsLibrary, +} from "@vis.gl/react-google-maps"; import { fieldStyles } from "./shared"; @@ -25,129 +29,93 @@ export interface LocationValue { lng: number | null; } -/** A single Nominatim search result, normalised to what the UI needs. */ +/** A single geocoding result, normalised to what the UI needs. */ interface GeocodeResult { displayName: string; lat: number; lng: number; } -// Leaflet's default marker icon URLs break under bundlers; point them at the -// CDN-hosted assets once so every map instance renders a visible pin. -const markerIcon = L.icon({ - iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png", - iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png", - shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png", - iconSize: [25, 41], - iconAnchor: [12, 41], - popupAnchor: [1, -34], - shadowSize: [41, 41], -}); +// 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. +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. -const DEFAULT_CENTER: [number, number] = [9.03, 38.74]; +const DEFAULT_CENTER = { lat: 9.03, lng: 38.74 }; const DEFAULT_ZOOM = 6; const PINNED_ZOOM = 14; -const NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"; -const NOMINATIM_REVERSE_URL = "https://nominatim.openstreetmap.org/reverse"; // Search only fires once the user pauses typing for this long. Slightly longer // than a keystroke burst so we make one request per pause, not per character. const SEARCH_DEBOUNCE_MS = 550; const MIN_QUERY_LEN = 2; // Bias geocoding toward the EDR corridor countries so local addresses surface -// first (Nominatim still returns global matches if nothing local fits). -const SEARCH_COUNTRYCODES = "et,dj"; -// Nominatim's fair-use policy allows at most 1 request/second. We keep a hard -// floor a touch above 1s so a flurry of map clicks / searches can never trip -// the 429 ("Too Many Requests") wall. -const MIN_REQUEST_INTERVAL_MS = 1100; +// first (we retry globally if nothing local matches). +const SEARCH_COUNTRIES = ["ET", "DJ"]; +const MAX_RESULTS = 8; // Reverse-geocode precision: coordinates are rounded to ~11m before caching so // near-identical pin drags resolve from cache instead of re-hitting the API. const REVERSE_COORD_PRECISION = 4; -// ── Module-level rate-limited request queue ───────────────────────────────── -// Every Nominatim call (forward + reverse, across ALL picker instances on the -// page) funnels through one promise chain that spaces requests ≥1.1s apart. -let lastRequestAt = 0; -let queueTail: Promise = Promise.resolve(); - -function scheduleRequest(run: () => Promise): Promise { - const result = queueTail.then(async () => { - const now = Date.now(); - const wait = Math.max(0, lastRequestAt + MIN_REQUEST_INTERVAL_MS - now); - if (wait > 0) await new Promise((r) => setTimeout(r, wait)); - lastRequestAt = Date.now(); - return run(); - }); - // Keep the chain alive even if this request rejects, so one failure doesn't - // stall every queued request behind it. - queueTail = result.catch(() => undefined); - return result; -} - // Simple in-memory caches keyed by the normalized query / rounded coordinate. const searchCache = new Map(); const reverseCache = new Map(); -/** One Nominatim forward-geocode request. `countryCodes` biases to a region. */ -async function nominatimSearch( - query: string, - signal: AbortSignal, - countryCodes?: string, +/** + * One Geocoder request, normalised. The promise-based `geocode` rejects on + * ZERO_RESULTS (and any other non-OK status), so failures collapse to "no + * matches" rather than surfacing as an error state. + */ +async function geocode( + geocoder: google.maps.Geocoder, + request: google.maps.GeocoderRequest, ): Promise { - const params = new URLSearchParams({ - q: query, - format: "jsonv2", - addressdetails: "0", - limit: "8", - }); - if (countryCodes) params.set("countrycodes", countryCodes); - const res = await fetch(`${NOMINATIM_URL}?${params}`, { - signal, - headers: { Accept: "application/json", "Accept-Language": "en" }, - }); - if (!res.ok) return []; - const data = (await res.json()) as Array<{ - display_name: string; - lat: string; - lon: string; - }>; - return data.map((d) => ({ - displayName: d.display_name, - lat: Number(d.lat), - lng: Number(d.lon), - })); + try { + const { results } = await geocoder.geocode(request); + return results.slice(0, MAX_RESULTS).map((r) => ({ + displayName: r.formatted_address, + lat: r.geometry.location.lat(), + lng: r.geometry.location.lng(), + })); + } catch { + return []; + } } /** - * Forward-geocode a free-text query. Served from cache when possible; otherwise - * queued (rate-limited) and tried EDR-corridor-first, then global, so local - * addresses rank highest without the field ever looking "broken". + * Forward-geocode a free-text query. Served from cache when possible; + * otherwise tried EDR-corridor-first, then global, so local addresses rank + * highest without the field ever looking "broken". */ async function searchPlaces( + geocoder: google.maps.Geocoder, query: string, - signal: AbortSignal, ): Promise { const key = query.trim().toLowerCase(); const cached = searchCache.get(key); if (cached) return cached; - const found = await scheduleRequest(async () => { - if (signal.aborted) return []; - const local = await nominatimSearch(query, signal, SEARCH_COUNTRYCODES); - if (local.length > 0) return local; - return nominatimSearch(query, signal); - }); + // The Geocoder only accepts one country restriction per request, so the + // corridor pass fans out to one request per country and merges in order. + const perCountry = await Promise.all( + SEARCH_COUNTRIES.map((country) => + geocode(geocoder, { address: query, componentRestrictions: { country } }), + ), + ); + const local = perCountry.flat().slice(0, MAX_RESULTS); + const found = local.length > 0 ? local : await geocode(geocoder, { address: query }); if (found.length > 0) searchCache.set(key, found); return found; } -/** Reverse-geocode a dropped pin to its nearest address (cached + queued). */ +/** Reverse-geocode a dropped pin to its nearest address (cached). */ async function reverseGeocode( + geocoder: google.maps.Geocoder, lat: number, lng: number, - signal?: AbortSignal, ): Promise { const key = `${lat.toFixed(REVERSE_COORD_PRECISION)},${lng.toFixed( REVERSE_COORD_PRECISION, @@ -155,63 +123,33 @@ async function reverseGeocode( const cached = reverseCache.get(key); if (cached != null) return cached; - const params = new URLSearchParams({ - lat: String(lat), - lon: String(lng), - format: "json", - }); - try { - const address = await scheduleRequest(async () => { - if (signal?.aborted) return ""; - const res = await fetch(`${NOMINATIM_REVERSE_URL}?${params}`, { - signal, - headers: { Accept: "application/json", "Accept-Language": "en" }, - }); - if (!res.ok) return ""; - const data = (await res.json()) as { display_name?: string }; - return data.display_name ?? ""; - }); - reverseCache.set(key, address); - return address; - } catch { - return ""; - } + const [best] = await geocode(geocoder, { location: { lat, lng } }); + const address = best?.displayName ?? ""; + reverseCache.set(key, address); + return address; } -/** - * Leaflet computes its tile layout from the container size at mount. When the - * map is revealed inside a just-toggled section it can mount before layout - * settles and render grey tiles — invalidating the size on the next frame - * forces a correct redraw. - */ -function InvalidateSizeOnMount() { - const map = useMap(); - useEffect(() => { - const id = setTimeout(() => map.invalidateSize(), 0); - return () => clearTimeout(id); - }, [map]); - return null; +/** Lazily constructs a Geocoder once the geocoding library has loaded. */ +function useGeocoder(): google.maps.Geocoder | null { + const geocodingLib = useMapsLibrary("geocoding"); + return useMemo( + () => (geocodingLib ? new geocodingLib.Geocoder() : null), + [geocodingLib], + ); } /** Recenters the map imperatively when the pinned coordinate changes. */ function MapRecenter({ lat, lng }: { lat: number | null; lng: number | null }) { const map = useMap(); useEffect(() => { - if (lat != null && lng != null) { - map.setView([lat, lng], PINNED_ZOOM, { animate: true }); + if (map && lat != null && lng != null) { + map.panTo({ lat, lng }); + map.setZoom(PINNED_ZOOM); } }, [lat, lng, map]); return null; } -/** Captures map clicks and forwards the dropped coordinate. */ -function ClickToPin({ onPick }: { onPick: (lat: number, lng: number) => void }) { - useMapEvents({ - click: (e) => onPick(e.latlng.lat, e.latlng.lng), - }); - return null; -} - export interface LocationPickerProps { value: LocationValue; onChange: (value: LocationValue) => void; @@ -227,14 +165,21 @@ export interface LocationPickerProps { } /** - * Address + map location picker backed by free OpenStreetMap services: - * - type to search (Nominatim forward geocoding), - * - or click anywhere on the map to drop a pin (Nominatim reverse geocoding). + * Address + map location picker backed by Google Maps: + * - type to search (Geocoding API forward geocoding, debounced), + * - or click anywhere on the map to drop a pin (reverse geocoding). * Reports the resolved address and coordinates up via `onChange`. */ export function LocationPicker(props: LocationPickerProps) { - if (props.variant === "modal") return ; - return ; + return ( + + {props.variant === "modal" ? ( + + ) : ( + + )} + + ); } /** Compact trigger + modal wrapper around the inline picker. */ @@ -343,12 +288,13 @@ function LocationPickerInline({ withinPortal = true, }: LocationPickerProps & { mapHeight?: number; withinPortal?: boolean }) { const combobox = useCombobox(); + const geocoder = useGeocoder(); const [query, setQuery] = useState(""); const [results, setResults] = useState([]); const [searching, setSearching] = useState(false); const [resolving, setResolving] = useState(false); - const abortRef = useRef(null); - const reverseAbortRef = useRef(null); + const searchStaleRef = useRef<{ stale: boolean } | null>(null); + const reverseStaleRef = useRef<{ stale: boolean } | null>(null); const hasPin = value.lat != null && value.lng != null; @@ -366,32 +312,31 @@ function LocationPickerInline({ } setSearching(true); combobox.openDropdown(); - abortRef.current?.abort(); - const controller = new AbortController(); - abortRef.current = controller; + if (!geocoder) return; // re-runs once the geocoding library loads + // The Geocoder has no abort support, so a token marks superseded requests + // and their responses are dropped instead of overwriting newer results. + const token = { stale: false }; + searchStaleRef.current = token; const handle = setTimeout(async () => { - try { - const found = await searchPlaces(q, controller.signal); - if (controller.signal.aborted) return; - setResults(found); - combobox.openDropdown(); - } catch (err) { - // Ignore aborts (a newer keystroke superseded this request). - if ((err as Error)?.name !== "AbortError") setResults([]); - } finally { - if (!controller.signal.aborted) setSearching(false); - } + const found = await searchPlaces(geocoder, q); + if (token.stale) return; + setResults(found); + setSearching(false); + combobox.openDropdown(); }, SEARCH_DEBOUNCE_MS); - // Cancel both the pending debounce AND any in-flight request when the query - // changes, so a stale response can't overwrite newer results. return () => { clearTimeout(handle); - controller.abort(); + token.stale = true; }; - }, [query, combobox]); + }, [query, geocoder, combobox]); - // Abort any in-flight reverse lookup when the picker unmounts. - useEffect(() => () => reverseAbortRef.current?.abort(), []); + // Drop any in-flight reverse lookup when the picker unmounts. + useEffect( + () => () => { + if (reverseStaleRef.current) reverseStaleRef.current.stale = true; + }, + [], + ); const selectResult = useCallback( (r: GeocodeResult) => { @@ -407,13 +352,14 @@ function LocationPickerInline({ async (lat: number, lng: number) => { // Show the pin immediately; fill the address once reverse geocoding lands. onChange({ address: value.address, lat, lng }); - // Cancel any in-flight reverse lookup — only the latest dropped pin counts. - reverseAbortRef.current?.abort(); - const controller = new AbortController(); - reverseAbortRef.current = controller; + if (!geocoder) return; + // Mark any in-flight reverse lookup stale — only the latest pin counts. + if (reverseStaleRef.current) reverseStaleRef.current.stale = true; + const token = { stale: false }; + reverseStaleRef.current = token; setResolving(true); - const address = await reverseGeocode(lat, lng, controller.signal); - if (controller.signal.aborted) return; // a newer pin superseded this one + const address = await reverseGeocode(geocoder, lat, lng); + if (token.stale) return; // a newer pin superseded this one setResolving(false); onChange({ address: address || `${lat.toFixed(5)}, ${lng.toFixed(5)}`, @@ -421,14 +367,21 @@ function LocationPickerInline({ lng, }); }, - [onChange, value.address], + [onChange, value.address, geocoder], + ); + + const handleMapClick = useCallback( + (e: MapMouseEvent) => { + const latLng = e.detail.latLng; + if (latLng) void handlePin(latLng.lat, latLng.lng); + }, + [handlePin], ); const inputValue = query || value.address; - const center = useMemo<[number, number]>( - () => (hasPin ? [value.lat as number, value.lng as number] : DEFAULT_CENTER), - [hasPin, value.lat, value.lng], - ); + const center = hasPin + ? { lat: value.lat as number, lng: value.lng as number } + : DEFAULT_CENTER; return ( @@ -494,26 +447,23 @@ function LocationPickerInline({ border: "1px solid #E6ECF2", }} > - - - - {hasPin && ( )} - + diff --git a/apps/edr-freight-web/portal/src/vite-env.d.ts b/apps/edr-freight-web/portal/src/vite-env.d.ts index d755e510b..b24245689 100644 --- a/apps/edr-freight-web/portal/src/vite-env.d.ts +++ b/apps/edr-freight-web/portal/src/vite-env.d.ts @@ -16,6 +16,7 @@ interface Window { interface ImportMetaEnv { readonly VITE_API_URL: string; + readonly VITE_GOOGLE_MAPS_API_KEY?: string; } interface ImportMeta { diff --git a/packages/types/src/freight/clearance-files.catalog.ts b/packages/types/src/freight/clearance-files.catalog.ts index fe6e9ebbf..307965dd8 100644 --- a/packages/types/src/freight/clearance-files.catalog.ts +++ b/packages/types/src/freight/clearance-files.catalog.ts @@ -33,6 +33,13 @@ export const CLEARANCE_WORKFLOW_FILE_CATALOG: ClearanceWorkflowFileCatalogEntry[ }, { code: "delivery_order", label: "Delivery Order", uploadedBy: "gl_dj", category: "djibouti", tradeDirection: "IMPORT" }, { code: "release_order", label: "Release Order", uploadedBy: "gl_dj", category: "djibouti", tradeDirection: "EXPORT" }, + { + code: "t1_transport_document", + label: "T1 Transport Document", + uploadedBy: "gl_dj", + category: "djibouti", + tradeDirection: "IMPORT", + }, ]; /** Legacy single-type declaration codes (still shown when already uploaded). */ @@ -101,6 +108,27 @@ export function transitPermitFileLabel(code: string, index?: number): string { return code; } +/** Legacy single T1 code (GL post-booking uploader). */ +export const LEGACY_T1_TRANSPORT_CODE = "t1_transport_document"; + +/** Multi-file T1 transport uploads use `t1_transport_document_0`, `_1`, … */ +export const T1_TRANSPORT_FILE_PREFIX = "t1_transport_document_"; + +export function isT1TransportFileCode(code: string | null | undefined): boolean { + if (!code) return false; + const lower = code.toLowerCase(); + return lower === LEGACY_T1_TRANSPORT_CODE || lower.startsWith(T1_TRANSPORT_FILE_PREFIX); +} + +export function t1TransportFileLabel(code: string, index?: number): string { + const lower = code.toLowerCase(); + if (lower === LEGACY_T1_TRANSPORT_CODE) return "T1 Transport Document"; + if (lower.startsWith(T1_TRANSPORT_FILE_PREFIX)) { + return index != null ? `T1 transport document ${index + 1}` : "T1 Transport Document"; + } + return code; +} + export const LEGACY_EXPORT_TRANSPORT_CODE = "export_transport_document"; export function isExportTransportFileCode(code: string | null | undefined): boolean { diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index f4c0b3380..c9d698f86 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -242,6 +242,20 @@ export interface ContractClearanceDocument { reviewedByStaffId?: string | null; } +/** + * Post-allocation T1 transit document state for the booking linked to an import + * customs flow. GL Djibouti uploads after wagon allocation; uploads lock once the + * train departs; GL Ethiopia closes (accepts) T1 when the train arrives. + */ +export interface ClearanceT1State { + bookingId: string; + wagonAllocated: boolean; + trainDepartedAt: string | null; + trainArrivedAt: string | null; + closed: boolean; + closedAt?: string | null; +} + export interface ContractClearanceView { contractId: string; /** Overall contract status (e.g. CLEARANCE_UNDER_REVIEW). */ @@ -283,6 +297,8 @@ export interface ContractClearanceView { } | null; /** Phased customs uploads (IM4, DO, transit permit, etc.) with friendly labels. */ workflowFiles?: import("./clearance-files.catalog").ClearanceWorkflowFile[]; + /** Import post-allocation T1 transit document state (null until a booking is linked). */ + t1?: ClearanceT1State | null; } export type ClearanceActorRole = "CUSTOMER" | "GL_ET" | "GL_DJ" | "OPERATIONS"; diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 2def8fa75..b05c9d8d1 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -548,6 +548,8 @@ export interface ClearanceView { } | null; /** Phased customs uploads (IM4, DO, transit permit, etc.) with friendly labels. */ workflowFiles?: import("./clearance-files.catalog").ClearanceWorkflowFile[]; + /** Import post-allocation T1 transit document state (null until wagon allocation). */ + t1?: import("./contracts").ClearanceT1State | null; } /** Company an invoice is billed to (minimal projection). */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8051dab4a..abd86df8f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -344,6 +344,9 @@ importers: '@tria-plc/iamui': specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7) + '@vis.gl/react-google-maps': + specifier: ^1.8.3 + version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) axios: specifier: ^1.7.7 version: 1.17.0 @@ -356,9 +359,6 @@ importers: date-fns: specifier: ^3.6.0 version: 3.6.0 - leaflet: - specifier: ^1.9.4 - version: 1.9.4 lucide-react: specifier: ^1.14.0 version: 1.17.0(react@19.2.6) @@ -377,9 +377,6 @@ importers: react-hot-toast: specifier: ^2.6.0 version: 2.6.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-leaflet: - specifier: ^5.0.0 - version: 5.0.0(leaflet@1.9.4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react-phone-number-input: specifier: ^3.4.17 version: 3.4.17(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -411,9 +408,9 @@ importers: '@tailwindcss/vite': specifier: ^4.3.0 version: 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0)) - '@types/leaflet': - specifier: ^1.9.21 - version: 1.9.21 + '@types/google.maps': + specifier: ^3.65.2 + version: 3.65.2 '@types/react': specifier: ^18.3.11 version: 18.3.31 @@ -1701,6 +1698,9 @@ packages: reflect-metadata: ^0.2.2 rxjs: ^7.x + '@googlemaps/js-api-loader@2.1.1': + resolution: {integrity: sha512-yUpAwksbHrlZIWD49JmveNSfBG4oAK0AwMknfSaPMnP5N7UT8oFRVCqwjGb1XQovi//7KLbPQKZpbofiLGzpDw==} + '@hello-pangea/dnd@18.0.1': resolution: {integrity: sha512-xojVWG8s/TGrKT1fC8K2tIWeejJYTAeJuj36zM//yEm/ZrnZUSFGS15BpO+jGZT1ybWvyXmeDJwPYb4dhWlbZQ==} peerDependencies: @@ -3515,13 +3515,6 @@ packages: '@radix-ui/rect@1.1.2': resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==} - '@react-leaflet/core@3.0.0': - resolution: {integrity: sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==} - peerDependencies: - leaflet: ^1.9.0 - react: ^19.0.0 - react-dom: ^19.0.0 - '@react-pdf-viewer/attachment@3.12.0': resolution: {integrity: sha512-mhwrYJSIpCvHdERpLUotqhMgSjhtF+BTY1Yb9Fnzpcq3gLZP+Twp5Rynq21tCrVdDizPaVY7SKu400GkgdMfZw==} peerDependencies: @@ -4265,8 +4258,8 @@ packages: '@types/express@5.0.6': resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} - '@types/geojson@7946.0.16': - resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/google.maps@3.65.2': + resolution: {integrity: sha512-e52bmOhGCQSNabFpL48iQlwJybq6rfns8NUVJ20MR7CdPlHQ2RmSCnPbJfrUYJfogrE4OiHQTZ4LXpop+eer1w==} '@types/graceful-fs@4.1.9': resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} @@ -4303,9 +4296,6 @@ packages: '@types/jsonwebtoken@9.0.5': resolution: {integrity: sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==} - '@types/leaflet@1.9.21': - resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==} - '@types/lodash@4.17.24': resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} @@ -4609,6 +4599,12 @@ packages: cpu: [x64] os: [win32] + '@vis.gl/react-google-maps@1.8.3': + resolution: {integrity: sha512-DW7nEuvOJ299DmdBnvGiUARrgS/+sTEO1iJgG9J8YaErZqLoq7S4TJ22f3EjJvR4dti4L4gft43JEK77nnKXDw==} + peerDependencies: + react: '>=16.8.0 || ^19.0 || ^19.0.0-rc' + react-dom: '>=16.8.0 || ^19.0 || ^19.0.0-rc' + '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -7978,9 +7974,6 @@ packages: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} engines: {node: '>= 0.6.3'} - leaflet@1.9.4: - resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==} - leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -9368,13 +9361,6 @@ packages: react-is@19.2.7: resolution: {integrity: sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==} - react-leaflet@5.0.0: - resolution: {integrity: sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==} - peerDependencies: - leaflet: ^1.9.0 - react: ^19.0.0 - react-dom: ^19.0.0 - react-number-format@5.4.5: resolution: {integrity: sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw==} peerDependencies: @@ -12134,6 +12120,10 @@ snapshots: reflect-metadata: 0.2.2 rxjs: 7.8.2 + '@googlemaps/js-api-loader@2.1.1': + dependencies: + '@types/google.maps': 3.65.2 + '@hello-pangea/dnd@18.0.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@babel/runtime': 7.29.7 @@ -14803,12 +14793,6 @@ snapshots: '@radix-ui/rect@1.1.2': {} - '@react-leaflet/core@3.0.0(leaflet@1.9.4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - leaflet: 1.9.4 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - '@react-pdf-viewer/attachment@3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@react-pdf-viewer/core': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -15809,7 +15793,7 @@ snapshots: '@types/express-serve-static-core': 5.1.1 '@types/serve-static': 2.2.0 - '@types/geojson@7946.0.16': {} + '@types/google.maps@3.65.2': {} '@types/graceful-fs@4.1.9': dependencies: @@ -15847,10 +15831,6 @@ snapshots: dependencies: '@types/node': 20.19.42 - '@types/leaflet@1.9.21': - dependencies: - '@types/geojson': 7946.0.16 - '@types/lodash@4.17.24': {} '@types/luxon@3.7.1': {} @@ -16142,6 +16122,14 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true + '@vis.gl/react-google-maps@1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@googlemaps/js-api-loader': 2.1.1 + '@types/google.maps': 3.65.2 + fast-deep-equal: 3.1.3 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0))': dependencies: '@babel/core': 7.29.7 @@ -20147,8 +20135,6 @@ snapshots: dependencies: readable-stream: 2.3.8 - leaflet@1.9.4: {} - leven@3.1.0: {} levn@0.4.1: @@ -21629,13 +21615,6 @@ snapshots: react-is@19.2.7: {} - react-leaflet@5.0.0(leaflet@1.9.4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): - dependencies: - '@react-leaflet/core': 3.0.0(leaflet@1.9.4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - leaflet: 1.9.4 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - react-number-format@5.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1