mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
81
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user