mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 15:18:11 +00:00
update import gl flow
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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 & 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,
|
||||
|
||||
@@ -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`,
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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='© <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 }}>
|
||||
|
||||
@@ -16,6 +16,7 @@ interface Window {
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string;
|
||||
readonly VITE_GOOGLE_MAPS_API_KEY?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
Reference in New Issue
Block a user