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 {

View File

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

View File

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

View File

@@ -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). */

81
pnpm-lock.yaml generated
View File

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