update import gl flow

This commit is contained in:
Marshal
2026-07-02 13:27:59 +00:00
parent fe40d9f4be
commit fec2a5d320
12 changed files with 452 additions and 252 deletions

View File

@@ -121,7 +121,8 @@ export function GlClearanceUploadModal({
<PhasedFileDropzone
label={isDo ? "Delivery Order file" : "Release Order file"}
description="PDF or image."
description={isDo ? "Any file type." : "PDF or image."}
accept={isDo ? "*/*" : undefined}
value={file}
onChange={setFile}
replaceMode={replaceMode}

View File

@@ -59,6 +59,7 @@ type ClearanceViewLike = Pick<
| "preClearanceFinalized"
| "exportClearanceFinalized"
| "allApproved"
| "t1"
> & { operationReady?: boolean };
type MilestoneRow = NonNullable<ClearanceViewLike["milestones"]>[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={<Ship size={14} />}
>
{showDj &&
canDj &&
!useUploadModals &&
(activeStep >= 6 ||
isMilestoneDone(clearance.milestones, "DO_COLLECTED")) ? (
{showDj && canDj && !useUploadModals ? (
<DeliveryOrderStep
entityId={entityId}
isBooking={isBooking}
@@ -427,11 +446,7 @@ export function PhasedClearanceActionPanel({
) : null}
<StepStatus
done={isMilestoneDone(clearance.milestones, "DO_COLLECTED")}
pendingLabel={
!clearance.preClearanceFinalized
? "Blocked until GL Ethiopia finalizes pre-clearance."
: "Waiting for GL Djibouti to upload the Delivery Order."
}
pendingLabel="Waiting for GL Djibouti to upload the Delivery Order (can be uploaded at any time)."
doneLabel="Delivery Order collected."
/>
{useUploadModals && showDj && canDj && onUploadDoRequest ? (
@@ -439,7 +454,6 @@ export function PhasedClearanceActionPanel({
color="edr-green"
leftSection={<Upload size={16} />}
onClick={onUploadDoRequest}
disabled={!clearance.preClearanceFinalized && !findWorkflowFile(workflowFiles, "delivery_order")}
>
{findWorkflowFile(workflowFiles, "delivery_order")
? "Replace DO"
@@ -472,17 +486,37 @@ export function PhasedClearanceActionPanel({
) : (
<StepStatus
done={Boolean(
bookingCreated || clearance.bookingReady || clearance.operationReady,
effectiveBookingCreated ||
clearance.bookingReady ||
clearance.operationReady,
)}
pendingLabel="Complete the Delivery Order step first."
doneLabel={
bookingCreated
effectiveBookingCreated
? "Shipment booking created."
: "Ready — create the shipment booking."
}
/>
)}
</Stepper.Step>
<Stepper.Step
label="T1 transport documents"
description="GL Djibouti uploads after wagon allocation; GL Ethiopia closes on arrival"
icon={
clearance.t1?.closed ? <CheckCircle2 size={14} /> : <Truck size={14} />
}
>
<ImportT1Section
t1={clearance.t1 ?? null}
workflowFiles={workflowFiles}
canDjAct={showDj && canDj}
canEtAct={showEt && canEt}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
</Stepper.Step>
</Stepper>
</Paper>
</Stack>
@@ -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<File[]>([]);
const [uploading, setUploading] = useState(false);
const [closing, setClosing] = useState(false);
const uploaded = t1FilesFromWorkflow(workflowFiles);
const replaceMode = uploaded.length > 0;
if (!t1) {
return (
<StepStatus
done={false}
pendingLabel="Available once the shipment booking is created."
doneLabel=""
/>
);
}
const departed = Boolean(t1.trainDepartedAt);
const arrived = Boolean(t1.trainArrivedAt);
const canUpload = canDjAct && t1.wagonAllocated && !departed && !t1.closed;
return (
<Stack gap="sm">
{uploaded.length > 0 ? (
<Stack gap={8}>
<Text size="xs" fw={700} c="dimmed" tt="uppercase">
T1 document{uploaded.length > 1 ? "s" : ""}
</Text>
{uploaded.map((row) => (
<PhasedUploadedFileRow
key={row.code}
label={row.label}
file={row.file}
onView={onViewFile}
onDownload={onDownloadFile}
compact
/>
))}
</Stack>
) : null}
{t1.closed ? (
<StepStatus
done
pendingLabel=""
doneLabel="T1 accepted and closed by GL Ethiopia — documents are final."
/>
) : !t1.wagonAllocated ? (
<StepStatus
done={false}
pendingLabel="Waiting for operations to allocate wagons."
doneLabel=""
/>
) : departed ? (
<Alert color="orange" variant="light" icon={<AlertTriangle size={16} />}>
The train has departed T1 documents are locked and can no longer be changed.
</Alert>
) : uploaded.length === 0 && !canUpload ? (
<StepStatus
done={false}
pendingLabel="Waiting for GL Djibouti to upload T1 transport documents."
doneLabel=""
/>
) : null}
{canUpload ? (
<>
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-gray-0)">
<PhasedMultiFileDropzone
label="T1 transport documents"
description={
replaceMode
? "Replace T1 files — upload one or more documents (any file type)."
: "Upload one or more T1 transport documents (any file type)."
}
accept="*/*"
value={files}
onChange={setFiles}
replaceMode={replaceMode}
disabled={uploading}
/>
</Paper>
<Button
color="edr-green"
loading={uploading}
disabled={files.length === 0}
leftSection={<Upload size={16} />}
fullWidth
onClick={async () => {
setUploading(true);
try {
const payload = Object.fromEntries(
files.map((file, index) => [`t1_transport_document_${index}`, file]),
) as Record<string, File>;
await contractsService.uploadT1Documents(t1.bookingId, payload);
setFiles([]);
toast.success(replaceMode ? "T1 documents updated" : "T1 documents uploaded");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setUploading(false);
}
}}
>
{replaceMode ? "Replace T1 documents" : "Upload T1 documents"}
</Button>
</>
) : null}
{canEtAct && !t1.closed ? (
arrived ? (
<Stack gap="xs">
<Text size="sm" c="dimmed">
The train has arrived review the T1 documents and close (accept) them.
</Text>
<Button
color="edr-green"
loading={closing}
disabled={uploaded.length === 0}
leftSection={<PackageCheck size={16} />}
onClick={async () => {
setClosing(true);
try {
await contractsService.closeT1(t1.bookingId);
toast.success("T1 closed");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setClosing(false);
}
}}
>
Accept &amp; close T1
</Button>
</Stack>
) : departed ? (
<Alert color="blue" variant="light" icon={<Clock size={16} />}>
Train en route T1 can be closed once it arrives in Ethiopia.
</Alert>
) : null
) : null}
</Stack>
);
}
function StepStatus({
done,
pendingLabel,

View File

@@ -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`,
},

View File

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

View File

@@ -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<string, File | null>,
) => {
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<Freight.ClearanceT1State> => {
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<PaginatedContracts> => {
const response = await client.get<PaginatedContracts>(

View File

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

View File

@@ -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<unknown> = Promise.resolve();
function scheduleRequest<T>(run: () => Promise<T>): Promise<T> {
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<string, GeocodeResult[]>();
const reverseCache = new Map<string, string>();
/** 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<GeocodeResult[]> {
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<GeocodeResult[]> {
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<string> {
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 <LocationPickerModal {...props} />;
return <LocationPickerInline {...props} />;
return (
<APIProvider apiKey={GOOGLE_MAPS_API_KEY}>
{props.variant === "modal" ? (
<LocationPickerModal {...props} />
) : (
<LocationPickerInline {...props} />
)}
</APIProvider>
);
}
/** 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<GeocodeResult[]>([]);
const [searching, setSearching] = useState(false);
const [resolving, setResolving] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const reverseAbortRef = useRef<AbortController | null>(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 (
<Box>
@@ -494,26 +447,23 @@ function LocationPickerInline({
border: "1px solid #E6ECF2",
}}
>
<MapContainer
center={center}
zoom={hasPin ? PINNED_ZOOM : DEFAULT_ZOOM}
<GoogleMap
defaultCenter={center}
defaultZoom={hasPin ? PINNED_ZOOM : DEFAULT_ZOOM}
style={{ height: "100%", width: "100%" }}
scrollWheelZoom
gestureHandling="greedy"
clickableIcons={false}
streetViewControl={false}
mapTypeControl={false}
onClick={handleMapClick}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<InvalidateSizeOnMount />
<ClickToPin onPick={handlePin} />
<MapRecenter lat={value.lat} lng={value.lng} />
{hasPin && (
<Marker
position={[value.lat as number, value.lng as number]}
icon={markerIcon}
position={{ lat: value.lat as number, lng: value.lng as number }}
/>
)}
</MapContainer>
</GoogleMap>
</Box>
<Text fz={11.5} c="#6B7C8E" mt={6} style={{ display: "flex", gap: 5 }}>

View File

@@ -16,6 +16,7 @@ interface Window {
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_GOOGLE_MAPS_API_KEY?: string;
}
interface ImportMeta {