diff --git a/apps/edr-freight-api/py/README.md b/apps/edr-freight-api/py/README.md new file mode 100644 index 000000000..561b35fbc --- /dev/null +++ b/apps/edr-freight-api/py/README.md @@ -0,0 +1,67 @@ +# Contract seed driver + +Creates freight contracts across every flow variant by driving the freight API +over HTTP end-to-end — from DRAFT through **both signatures** (customer sign + +staff counter-sign). It stops right after the staff counter-sign; no clearance +or booking steps are run. + +## What it builds + +20 real flows (movement × kind × customs × freight), each created twice → **40 +contracts** on `all`. + +| Movement | Kind | Customs | Freight | Count | +| --- | --- | --- | --- | --- | +| intercity (DOMESTIC) | one-time / general | without only¹ | bulk / container | 4 | +| import (IMPORT) | one-time / general | with / without | bulk / container | 8 | +| export (EXPORT) | one-time / general | with / without | bulk / container | 8 | + +¹ intercity + customs is not a real combo — DOMESTIC has no clearance gate, so +the customs flag is ignored. Those four are skipped, leaving 20 (16 working + 4 +`with-customs + bulk`). + +The four `with-customs + bulk` flows are still built here: the known break is +downstream in clearance (customs output docs are container-only), which this +script does not reach, so all 20 reach a signed state. + +## Terminal status after both signatures (by dimension) + +- DOMESTIC one-time → `FULLY_EXECUTED` +- any GENERAL, and DOMESTIC general → `CONTRACT_ACTIVE` +- IMPORT/EXPORT one-time (customs or self-clearance) → `AWAITING_CLEARANCE_DOCUMENTS` + (fully signed; clearance not driven) + +## Setup + +```bash +cd apps/edr-freight-api/py +python -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env # then fill it in +``` + +Fill `.env`: customer + admin IAM credentials (admin should be a **super_admin**), +`OTP_PHONE`, and the Postgres connection (used only to read the sign-OTP). + +## Run + +```bash +python create_contracts.py # all 20 flows +python create_contracts.py intercity # only DOMESTIC flows (4) +python create_contracts.py import # only IMPORT flows (8) +python create_contracts.py import export # IMPORT + EXPORT (16) +``` + +Filters are by movement: `intercity`, `import`, `export` (pass one or many); +no arg or `all` runs everything. + +## How auth + OTP work + +- **Login**: `POST /api/auth/login` with `{ email, password }` returns a JWT + (`token`), sent as `Authorization: Bearer `. MFA accounts are not + supported — the script errors out clearly if MFA is required. +- **Actors**: the customer token does create/submit/customer-sign; the admin + token does staff-accept/approve/generate/counter-sign. +- **Sign OTP**: customer sign needs a fresh 6-digit OTP. The script calls + `POST /api/otp/send { phone }`, reads the plaintext code from + `.otp_verifications` in Postgres, then signs within the 5-minute TTL. diff --git a/apps/edr-freight-api/py/__pycache__/create_contracts.cpython-312.pyc b/apps/edr-freight-api/py/__pycache__/create_contracts.cpython-312.pyc new file mode 100644 index 000000000..6c13fefb3 Binary files /dev/null and b/apps/edr-freight-api/py/__pycache__/create_contracts.cpython-312.pyc differ diff --git a/apps/edr-freight-api/py/create_contracts.py b/apps/edr-freight-api/py/create_contracts.py new file mode 100644 index 000000000..086ecf993 --- /dev/null +++ b/apps/edr-freight-api/py/create_contracts.py @@ -0,0 +1,479 @@ +#!/usr/bin/env python3 +""" +Seed freight contracts across every flow variant, driven end-to-end over HTTP. + +For each flow the script logs in, creates a DRAFT contract, and pushes it through +the lifecycle up to and INCLUDING both signatures (customer sign + staff +counter-sign). It STOPS after the staff counter-sign — no clearance, no booking. + +Flow dimensions (3 x 2 x 2 x 2 = 24 combos, but only the 20 real ones are built): + movement : intercity(DOMESTIC) | import(IMPORT) | export(EXPORT) + kind : one-time(ONE_TIME) | general(GENERAL) + customs : without | with (customsClearingEnabled) + freight : bulk(BULK) | container(CONTAINER) + +intercity + customs is dropped (DOMESTIC ignores customs → no real combo), which +removes 4 dead combos and leaves 20 flows (16 working + 4 customs+bulk whose +break is downstream in clearance). Each is created twice → 40 contracts on `all`. + +CLI (filter by movement, pass one or many): + python create_contracts.py # all 20 flows + python create_contracts.py all # all 20 flows + python create_contracts.py intercity # only DOMESTIC flows + python create_contracts.py import # only IMPORT flows + python create_contracts.py import export # IMPORT + EXPORT flows + +Config comes from .env (see .env.example). Requires: requests, psycopg, +python-dotenv (see requirements.txt). +""" +from __future__ import annotations + +import base64 +import os +import sys +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import psycopg +import requests +from dotenv import load_dotenv + +HERE = Path(__file__).resolve().parent +load_dotenv(HERE / ".env") + +# --------------------------------------------------------------------------- # +# Config +# --------------------------------------------------------------------------- # +API_URL = os.getenv("FREIGHT_API_URL", "http://localhost:3001/api").rstrip("/") + +CUSTOMER_EMAIL = os.getenv("CUSTOMER_EMAIL", "") +CUSTOMER_PASSWORD = os.getenv("CUSTOMER_PASSWORD", "") +ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", "") +ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "") + +# Phone the sign-OTP is sent to and read back from Postgres. Independent of the +# logged-in user — the sign endpoint keys the OTP purely on this number. +OTP_PHONE = os.getenv("OTP_PHONE", "") + +# DB connection used ONLY to read the plaintext sign-OTP from freight.otp_verifications. +DB_HOST = os.getenv("DB_HOST", "localhost") +DB_PORT = os.getenv("DB_PORT", "5432") +DB_NAME = os.getenv("DB_NAME", "edr_dev") +DB_USER = os.getenv("DB_USER", "postgres") +DB_PASSWORD = os.getenv("DB_PASSWORD", "") +DB_SCHEMA = os.getenv("DB_SCHEMA", "freight") + +WAAFI = HERE / "waafi.jpeg" + +VALIDITY_DAYS = int(os.getenv("VALIDITY_DAYS", "365")) +CONTRACTS_PER_FLOW = int(os.getenv("CONTRACTS_PER_FLOW", "2")) +REQUEST_TIMEOUT = int(os.getenv("REQUEST_TIMEOUT", "60")) + + +# --------------------------------------------------------------------------- # +# Flow matrix — the 20 real flows +# --------------------------------------------------------------------------- # +@dataclass(frozen=True) +class Flow: + movement: str # intercity | import | export + trade_direction: str # DOMESTIC | IMPORT | EXPORT + kind: str # ONE_TIME | GENERAL + customs: bool # customsClearingEnabled + freight: str # BULK | CONTAINER + + @property + def label(self) -> str: + return ( + f"{self.movement}+{'general' if self.kind == 'GENERAL' else 'one-time'}" + f"+{'with' if self.customs else 'no'}-customs" + f"+{self.freight.lower()}" + ) + + +def build_flow_matrix() -> list[Flow]: + movements = [ + ("intercity", "DOMESTIC"), + ("import", "IMPORT"), + ("export", "EXPORT"), + ] + kinds = ["ONE_TIME", "GENERAL"] + freights = ["BULK", "CONTAINER"] + + flows: list[Flow] = [] + for movement, direction in movements: + # DOMESTIC ignores customs (no clearance gate) → customs=True is not a + # real combo. Only build without-customs for intercity. + customs_options = [False] if direction == "DOMESTIC" else [False, True] + for kind in kinds: + for customs in customs_options: + for freight in freights: + flows.append(Flow(movement, direction, kind, customs, freight)) + return flows + + +# --------------------------------------------------------------------------- # +# HTTP client +# --------------------------------------------------------------------------- # +class ApiError(RuntimeError): + def __init__(self, method: str, path: str, resp: requests.Response): + body = resp.text + try: + body = resp.json() + except Exception: + pass + super().__init__(f"{method} {path} -> {resp.status_code}: {body}") + self.status_code = resp.status_code + + +class Client: + """Thin wrapper that carries a bearer token.""" + + def __init__(self, name: str, token: str | None = None): + self.name = name + self.token = token + + def _headers(self, extra: dict[str, str] | None = None) -> dict[str, str]: + h: dict[str, str] = {} + if self.token: + h["Authorization"] = f"Bearer {self.token}" + if extra: + h.update(extra) + return h + + def get(self, path: str, params: dict | None = None) -> Any: + r = requests.get( + f"{API_URL}{path}", + headers=self._headers(), + params=params, + timeout=REQUEST_TIMEOUT, + ) + if not r.ok: + raise ApiError("GET", path, r) + return r.json() if r.content else None + + def post_json(self, path: str, body: dict | None = None) -> Any: + r = requests.post( + f"{API_URL}{path}", + headers=self._headers({"Content-Type": "application/json"}), + json=body or {}, + timeout=REQUEST_TIMEOUT, + ) + if not r.ok: + raise ApiError("POST", path, r) + return r.json() if r.content else None + + def post_multipart( + self, path: str, data: dict[str, str], files: list[tuple] | None = None + ) -> Any: + r = requests.post( + f"{API_URL}{path}", + headers=self._headers(), # requests sets multipart Content-Type + data=data, + files=files or [], + timeout=REQUEST_TIMEOUT, + ) + if not r.ok: + raise ApiError("POST", path, r) + return r.json() if r.content else None + + +def login(email: str, password: str, who: str) -> Client: + r = requests.post( + f"{API_URL}/auth/login", + json={"email": email, "password": password}, + timeout=REQUEST_TIMEOUT, + ) + if not r.ok: + raise ApiError("POST", "/auth/login", r) + payload = r.json() + if payload.get("mfaRequired"): + raise RuntimeError( + f"{who} login requires MFA — this script cannot complete an MFA login. " + "Disable MFA for the seed account or supply a non-MFA account." + ) + token = payload.get("token") + if not token: + raise RuntimeError(f"{who} login returned no token: {payload}") + return Client(who, token) + + +# --------------------------------------------------------------------------- # +# OTP — send + read from Postgres +# --------------------------------------------------------------------------- # +def send_otp(customer: Client, phone: str) -> None: + # POST /api/otp/send is @Public — no token needed, but sending one is harmless. + customer.post_json("/otp/send", {"phone": phone}) + + +def read_otp_from_db(phone: str) -> str: + """Read the freshest plaintext OTP for `phone` from freight.otp_verifications.""" + dsn = ( + f"host={DB_HOST} port={DB_PORT} dbname={DB_NAME} " + f"user={DB_USER} password={DB_PASSWORD}" + ) + with psycopg.connect(dsn) as conn: + with conn.cursor() as cur: + cur.execute( + f'SELECT otp FROM "{DB_SCHEMA}".otp_verifications ' + "WHERE phone = %s ORDER BY updated_at DESC LIMIT 1", + (phone,), + ) + row = cur.fetchone() + if not row: + raise RuntimeError(f"No OTP row found for phone {phone} in {DB_SCHEMA}.otp_verifications") + return str(row[0]) + + +# --------------------------------------------------------------------------- # +# Reference-data lookups (yards / service types / cargo types) +# --------------------------------------------------------------------------- # +@dataclass +class RefData: + yards: list[dict] = field(default_factory=list) + service_types: list[dict] = field(default_factory=list) + cargo_types: list[dict] = field(default_factory=list) + + +def _as_items(resp: Any) -> list[dict]: + if isinstance(resp, list): + return resp + if isinstance(resp, dict): + return resp.get("items") or resp.get("data") or [] + return [] + + +def load_ref_data(client: Client) -> RefData: + ref = RefData( + yards=_as_items(client.get("/yards")), + service_types=_as_items(client.get("/service-types")), + cargo_types=_as_items(client.get("/cargo-types")), + ) + if len(ref.yards) < 2: + raise RuntimeError(f"Need >=2 yards, got {len(ref.yards)}. Seed yards first.") + if not ref.service_types: + raise RuntimeError("No service types found. Seed service types first.") + if not ref.cargo_types: + raise RuntimeError("No cargo types found. Seed cargo types first.") + return ref + + +def pick_service_type(ref: RefData, wants_customs: bool) -> str: + """Prefer a service type whose includesCustoms matches the flow's customs need.""" + for st in ref.service_types: + if bool(st.get("includesCustoms")) == wants_customs: + return st["id"] + # Fall back to any — customsClearingEnabled on the contract still drives the flow. + return ref.service_types[0]["id"] + + +# --------------------------------------------------------------------------- # +# Contract payload builder +# --------------------------------------------------------------------------- # +def build_create_payload(flow: Flow, ref: RefData, idx: int) -> dict[str, str]: + """Return multipart form fields. Booleans as 'true'/'false' strings; nested + arrays as JSON strings (implicit conversion is off in the API).""" + import json + + origin = ref.yards[0]["id"] + destination = ref.yards[1]["id"] + service_type_id = pick_service_type(ref, flow.customs) + + # Cargo scope: CONTAINER -> >=1 size row; BULK -> exactly one cargo-type row. + if flow.freight == "CONTAINER": + cargo_scope = [{"containerSize": "20ft"}] + if flow.kind == "GENERAL": + cargo_scope[0]["quantityCap"] = 10 + else: # BULK + cargo_scope = [{"cargoTypeId": ref.cargo_types[0]["id"]}] + if flow.kind == "GENERAL": + cargo_scope[0]["quantityCap"] = 1000 + + # Routes: ONE_TIME -> exactly 1; GENERAL -> 1..N (one is fine). + routes = [{"originYardId": origin, "destinationYardId": destination, "sortOrder": 0}] + + fields: dict[str, str] = { + "contractKind": flow.kind, + "tradeDirection": flow.trade_direction, + "freightType": flow.freight, + "serviceTypeId": service_type_id, + "paymentCurrency": "ETB", + "customsClearingEnabled": "true" if flow.customs else "false", + "contractType": "SPOT", + "cargoScope": json.dumps(cargo_scope), + "routes": json.dumps(routes), + } + if flow.customs: + fields["customsClearingAgent"] = "Seed Agent" + return fields + + +def signature_b64() -> str: + return base64.b64encode(WAAFI.read_bytes()).decode() + + +# --------------------------------------------------------------------------- # +# Lifecycle driver — create → submit → accept → approve → generate → sign x2 +# --------------------------------------------------------------------------- # +def waafi_file_tuple(field_name: str) -> tuple: + return (field_name, (WAAFI.name, WAAFI.read_bytes(), "image/jpeg")) + + +def drive_flow( + flow: Flow, idx: int, customer: Client, admin: Client, ref: RefData +) -> dict[str, Any]: + result: dict[str, Any] = {"flow": flow.label, "n": idx, "status": None} + + # S1 — create (customer, multipart, waafi attached as intake doc) + fields = build_create_payload(flow, ref, idx) + contract = customer.post_multipart( + "/contracts", data=fields, files=[waafi_file_tuple("intake_document")] + ) + cid = contract["id"] + result["contractId"] = cid + result["reference"] = contract.get("reference") + + # S2 — submit (customer). May go to PRICE_CHANGED_PENDING_CONFIRM → confirm. + contract = customer.post_json(f"/contracts/{cid}/submit") + if (contract or {}).get("status") == "PRICE_CHANGED_PENDING_CONFIRM": + contract = customer.post_json(f"/contracts/{cid}/confirm-submit") + + # S3 — staff accept (admin) → PENDING_APPROVAL + approval chain + admin.post_json(f"/contracts/{cid}/staff/accept", {"validityDays": VALIDITY_DAYS}) + + # S4 — approve every pending step IN ORDER with its exact requiredRole (admin) + approve_all_steps(admin, cid) + + # S5 — generate contract document (admin) → CONTRACT_READY + admin.post_json(f"/contracts/{cid}/contract/generate") + + # S6 — customer sign (needs OTP) → SIGNED_CUSTOMER + send_otp(customer, OTP_PHONE) + time.sleep(1.0) # let the OTP row land + otp = read_otp_from_db(OTP_PHONE) + customer.post_json( + f"/contracts/{cid}/contract/sign", + { + "role": "CUSTOMER", + "signatureImageBase64": signature_b64(), + "signerDisplayName": "Seed Customer", + "consentText": "I agree.", + "otp": otp, + "otpPhone": OTP_PHONE, + }, + ) + + # S7 — staff counter-sign (admin) → FULLY_EXECUTED / CONTRACT_ACTIVE / + # AWAITING_CLEARANCE_DOCUMENTS depending on dimension. STOP HERE. + signed = admin.post_json( + f"/contracts/{cid}/contract/sign", + { + "role": "STAFF", + "signatureImageBase64": signature_b64(), + "signerDisplayName": "Seed Staff", + "consentText": "Countersigned.", + }, + ) + result["status"] = (signed or {}).get("status") + return result + + +def approve_all_steps(admin: Client, cid: str) -> None: + """Read the contract, approve each PENDING approval step in order. Superadmin + can approve any role, but the endpoint still checks step.requiredRole == body, + so we echo the step's own requiredRole back.""" + guard = 0 + while True: + guard += 1 + if guard > 12: + raise RuntimeError(f"Approval loop exceeded 12 iterations for {cid}") + contract = admin.get(f"/contracts/{cid}") + steps = contract.get("approvalSteps") or [] + pending = [s for s in steps if s.get("status") == "PENDING"] + if not pending: + return + # findNextPendingApprovalStep orders by sequence; sort the same way. + pending.sort(key=lambda s: s.get("sequence", s.get("sortOrder", 0))) + step = pending[0] + admin.post_json( + f"/contracts/{cid}/approval-steps/{step['id']}/approve", + {"requiredRole": step["requiredRole"]}, + ) + + +# --------------------------------------------------------------------------- # +# Main +# --------------------------------------------------------------------------- # +VALID_FILTERS = {"all", "intercity", "import", "export"} + + +def parse_filters(argv: list[str]) -> set[str]: + args = [a.lower() for a in argv[1:]] + if not args or "all" in args: + return {"intercity", "import", "export"} + unknown = set(args) - VALID_FILTERS + if unknown: + raise SystemExit( + f"Unknown filter(s): {', '.join(sorted(unknown))}. " + f"Valid: {', '.join(sorted(VALID_FILTERS))}" + ) + return set(args) + + +def require_config() -> None: + missing = [ + name + for name, val in [ + ("CUSTOMER_EMAIL", CUSTOMER_EMAIL), + ("CUSTOMER_PASSWORD", CUSTOMER_PASSWORD), + ("ADMIN_EMAIL", ADMIN_EMAIL), + ("ADMIN_PASSWORD", ADMIN_PASSWORD), + ("OTP_PHONE", OTP_PHONE), + ] + if not val + ] + if missing: + raise SystemExit(f"Missing required .env keys: {', '.join(missing)}") + if not WAAFI.exists(): + raise SystemExit(f"Missing signature/upload image: {WAAFI}") + + +def main() -> None: + require_config() + wanted = parse_filters(sys.argv) + + flows = [f for f in build_flow_matrix() if f.movement in wanted] + total = len(flows) * CONTRACTS_PER_FLOW + print(f"API : {API_URL}") + print(f"Filters : {', '.join(sorted(wanted))}") + print(f"Flows : {len(flows)} x {CONTRACTS_PER_FLOW} = {total} contracts\n") + + print("Logging in...") + customer = login(CUSTOMER_EMAIL, CUSTOMER_PASSWORD, "customer") + admin = login(ADMIN_EMAIL, ADMIN_PASSWORD, "admin") + + print("Loading reference data...") + ref = load_ref_data(admin) + + results: list[dict] = [] + for flow in flows: + for n in range(1, CONTRACTS_PER_FLOW + 1): + tag = f"[{flow.label} #{n}]" + try: + res = drive_flow(flow, n, customer, admin, ref) + print(f" OK {tag} {res['reference']} -> {res['status']}") + results.append(res) + except Exception as exc: # noqa: BLE001 — report and continue + print(f" FAIL {tag} {exc}") + results.append({"flow": flow.label, "n": n, "error": str(exc)}) + + ok = [r for r in results if not r.get("error")] + bad = [r for r in results if r.get("error")] + print(f"\nDone. {len(ok)} created, {len(bad)} failed, {total} attempted.") + if bad: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/apps/edr-freight-api/py/requirements.txt b/apps/edr-freight-api/py/requirements.txt new file mode 100644 index 000000000..7dcde7140 --- /dev/null +++ b/apps/edr-freight-api/py/requirements.txt @@ -0,0 +1,3 @@ +requests>=2.31 +psycopg[binary]>=3.1 +python-dotenv>=1.0 diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts index a7bd13c28..ac21b2dce 100644 --- a/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts @@ -42,8 +42,13 @@ describe('clearance.util — clearanceOutputSettingCode', () => { expect(clearanceOutputSettingCode('IMPORT', 'CONTAINER', false)).toBeNull(); }); - it('returns null for bulk (no container output set) and domestic', () => { - expect(clearanceOutputSettingCode('IMPORT', 'BULK', true)).toBeNull(); + it('resolves bulk output sets (mirrors container) and returns null for domestic', () => { + expect(clearanceOutputSettingCode('IMPORT', 'BULK', true)).toBe( + 'clearance_output_import_bulk', + ); + expect(clearanceOutputSettingCode('EXPORT', 'BULK', true)).toBe( + 'clearance_output_export_bulk', + ); expect(clearanceOutputSettingCode('DOMESTIC', 'CONTAINER', true)).toBeNull(); }); }); diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts index 2e2865d8b..6d7b86c8f 100644 --- a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts @@ -33,7 +33,7 @@ export function clearanceSettingCode( return `clearance_${op}_${freight}_${customs}`; } -/** The GL-output (customs output) setting code; only container customs sets exist. */ +/** The GL-output (customs output) setting code, keyed on op + freight. */ export function clearanceOutputSettingCode( tradeDirection: string, freightType: string, @@ -42,9 +42,8 @@ export function clearanceOutputSettingCode( if (!includesCustoms) return null; const op = operationFor(tradeDirection); if (!op) return null; - // Only container customs output sets are seeded for this phase. - if (freightFor(freightType) !== 'container') return null; - return `clearance_output_${op}_container`; + const freight = freightFor(freightType); + return `clearance_output_${op}_${freight}`; } /** Convenience: resolve both codes for a loaded booking (with its serviceType). */ diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts index 9b108ce75..bc1b4ad49 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts @@ -45,7 +45,7 @@ export function contractClearanceSettingCode( return `contract_clearance_${op}_${freight}`; } -/** The GL-output (customs output) setting code; only container customs sets exist. */ +/** The GL-output (customs output) setting code, keyed on op + freight. */ export function contractClearanceOutputSettingCode( tradeDirection: string, freightType: string, @@ -54,8 +54,8 @@ export function contractClearanceOutputSettingCode( if (!includesCustoms) return null; const op = operationFor(tradeDirection); if (!op) return null; - if (freightFor(freightType) !== 'container') return null; - return `contract_clearance_output_${op}_container`; + const freight = freightFor(freightType); + return `contract_clearance_output_${op}_${freight}`; } /** Convenience: resolve both codes for a loaded contract. */ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts index 25ee84b8c..bd6cd854f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts @@ -5,6 +5,7 @@ import { BATCH_WINDOW_START_HOURS, listConfigBookingWindows, groupBookingsIntoBoardWindows, + computeImportWindowTimes, type BoardWindowConfig, } from './batch-window.util'; @@ -54,6 +55,72 @@ describe('batch-window.util', () => { }); }); +describe('computeImportWindowTimes — first-window open respects office hours', () => { + // Departs Mon 06 Jul 08:00 EAT (05:00 UTC). Lead 3 days → anchor 03 Jul 08:00 + // EAT (05:00 UTC). Bounded desk 08:00–17:00, 15h window. + const departure = new Date('2026-07-06T05:00:00.000Z'); + const bounded = { + importWindowLeadDays: 3, + windowOpenHour: 8, + windowCloseHour: 17, + windowDurationHours: 15, + }; + + it('opens at the morning anchor when now is before the lead window', () => { + // Now = 02 Jul 06:00 EAT (before the 03 Jul anchor). + const now = new Date('2026-07-02T03:00:00.000Z'); + const { windowOpensAt } = computeImportWindowTimes(departure, bounded, now); + // Anchor: 03 Jul 08:00 EAT = 05:00 UTC. + expect(windowOpensAt.toISOString()).toBe('2026-07-03T05:00:00.000Z'); + }); + + it('opens NOW when inside the lead window and inside office hours (past the anchor)', () => { + // Now = 05 Jul 12:00 EAT (09:00 UTC): inside lead days, inside 08:00–17:00, + // anchor already passed → open immediately. + const now = new Date('2026-07-05T09:00:00.000Z'); + const { windowOpensAt } = computeImportWindowTimes(departure, bounded, now); + expect(windowOpensAt.toISOString()).toBe('2026-07-05T09:00:00.000Z'); + }); + + it('waits for next morning when now is after the desk closes', () => { + // Departs 06 Jul 14:00 EAT (11:00 UTC) so next-morning open sits before departure. + // Now = 05 Jul 18:00 EAT (15:00 UTC): after 17:00 close → open 06 Jul 08:00 EAT. + const lateDeparture = new Date('2026-07-06T11:00:00.000Z'); + const now = new Date('2026-07-05T15:00:00.000Z'); + const { windowOpensAt } = computeImportWindowTimes(lateDeparture, bounded, now); + // 06 Jul 08:00 EAT = 05:00 UTC. + expect(windowOpensAt.toISOString()).toBe('2026-07-06T05:00:00.000Z'); + }); + + it('opens this morning when now is before the desk opens on a lead day', () => { + // Now = 05 Jul 06:00 EAT (03:00 UTC): inside lead days but before 08:00 → 08:00 today. + const now = new Date('2026-07-05T03:00:00.000Z'); + const { windowOpensAt } = computeImportWindowTimes(departure, bounded, now); + expect(windowOpensAt.toISOString()).toBe('2026-07-05T05:00:00.000Z'); + }); + + it('24-hour desk opens NOW at any hour, day or night, once inside the lead window', () => { + // Round-the-clock desk (open === close). Now = 05 Jul 03:00 EAT (00:00 UTC), + // deep night, past the anchor → open immediately. + const roundClock = { ...bounded, windowOpenHour: 8, windowCloseHour: 8 }; + const now = new Date('2026-07-05T00:00:00.000Z'); + const { windowOpensAt } = computeImportWindowTimes(departure, roundClock, now); + expect(windowOpensAt.toISOString()).toBe('2026-07-05T00:00:00.000Z'); + }); + + it('caps the close at departure', () => { + // Opens now (05 Jul 12:00 EAT); a 24h duration would close 06 Jul 12:00 EAT, + // past the 06 Jul 08:00 departure → clamped to departure. + const now = new Date('2026-07-05T09:00:00.000Z'); + const { windowClosesAt } = computeImportWindowTimes( + departure, + { ...bounded, windowDurationHours: 24 }, + now, + ); + expect(windowClosesAt.toISOString()).toBe(departure.toISOString()); + }); +}); + describe('batch-window board windows (config-driven booking cycles)', () => { // Default rules: open 08:00 EAT, desk shuts 17:00, 3 days before departure, // 3h long, reopen 90m later. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts index 442c21f95..ec8b6826d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts @@ -169,31 +169,44 @@ export function isRoundTheClock(hours: OfficeHours): boolean { * single EAT day and never wraps past midnight (enforced when global rules are * saved). openHour === closeHour is the 24-hour desk, handled first. */ +/** + * The EAT instant a booking cycle would open if it became ready at `readyAt`, + * honouring the daily office window but WITHOUT any departure bound: + * + * • round-the-clock desk → opens at `readyAt` (no day break) + * • ready before openHour → opens at openHour that EAT morning + * • ready inside office hours → opens at `readyAt` + * • ready at/after closeHour → opens at openHour the next morning + * + * `nextCycleOpensAt` layers the "before departure" gate on top of this; the first + * import window uses it directly and lets its own departure cap apply. + */ +export function officeHoursOpen(readyAt: Date, hours: OfficeHours): Date { + if (isRoundTheClock(hours)) { + return readyAt; + } + const { hour, minute } = eatParts(readyAt); + const readyMinutes = hour * 60 + minute; + const openMinutes = hours.windowOpenHour * 60; + const closeMinutes = hours.windowCloseHour * 60; + if (readyMinutes < openMinutes) { + // Ready before the desk opens on its own EAT calendar day → open this morning. + return eatDayToUtc(eatDay(readyAt), hours.windowOpenHour); + } + if (readyMinutes < closeMinutes) { + // Inside office hours → open as soon as ready. + return readyAt; + } + // Desk shut for the day → open tomorrow morning. + return eatDayToUtc(shiftEatDay(eatDay(readyAt), 1), hours.windowOpenHour); +} + export function nextCycleOpensAt( earliestNextOpen: Date, hours: OfficeHours, departure: Date, ): Date | null { - let opensAt: Date; - if (isRoundTheClock(hours)) { - opensAt = earliestNextOpen; - } else { - const { hour, minute } = eatParts(earliestNextOpen); - const readyMinutes = hour * 60 + minute; - const openMinutes = hours.windowOpenHour * 60; - const closeMinutes = hours.windowCloseHour * 60; - if (readyMinutes < openMinutes) { - // Ready before the desk opens on its own EAT calendar day → open this morning. - opensAt = eatDayToUtc(eatDay(earliestNextOpen), hours.windowOpenHour); - } else if (readyMinutes < closeMinutes) { - // Inside office hours → open as soon as ready. - opensAt = earliestNextOpen; - } else { - // Desk shut for the day → open tomorrow morning. - const tomorrow = shiftEatDay(eatDay(earliestNextOpen), 1); - opensAt = eatDayToUtc(tomorrow, hours.windowOpenHour); - } - } + const opensAt = officeHoursOpen(earliestNextOpen, hours); return opensAt.getTime() < departure.getTime() ? opensAt : null; } @@ -203,27 +216,53 @@ export interface InitialWindowTimes { } /** - * Import booking-day window: opens at `windowOpenHour` EAT on departure-day minus - * `importWindowLeadDays`, for `windowDurationHours`. A schedule created after its - * computed window has fully passed gets a same-day window starting now instead, - * capped at departure. + * Import booking-day window. The natural anchor is `windowOpenHour` EAT on + * departure-day minus `importWindowLeadDays`. When `now` is at/before that anchor + * (we're still before the lead window) the window opens at the anchor — the normal + * morning wait. + * + * Once `now` is PAST the anchor we're already inside the lead window, so the desk's + * office hours decide the open the same way a reopen cycle does (via + * `nextCycleOpensAt`): + * + * • 24-hour desk (open === close) → opens at `now`, any hour, day or night + * • `now` inside [openHour, closeHour) → opens at `now` (desk is open right now) + * • `now` before openHour that EAT day → opens at openHour that morning + * • `now` at/after closeHour → desk shut; opens openHour next morning + * + * `windowDurationHours` extends from that open, capped at departure. */ export function computeImportWindowTimes( departure: Date, cfg: { importWindowLeadDays: number; windowOpenHour: number; + windowCloseHour: number; windowDurationHours: number; }, now: Date, ): InitialWindowTimes { const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays); - let opensAt = eatDayToUtc(windowDay, cfg.windowOpenHour); - let closesAt = new Date(opensAt.getTime() + cfg.windowDurationHours * 3_600_000); - if (closesAt.getTime() <= now.getTime()) { - opensAt = now; - closesAt = new Date(now.getTime() + cfg.windowDurationHours * 3_600_000); + const anchor = eatDayToUtc(windowDay, cfg.windowOpenHour); + + let opensAt: Date; + if (now.getTime() <= anchor.getTime()) { + // Before the lead window → normal morning wait at the anchor. + opensAt = anchor; + } else { + // Inside the lead window → the office-hours rule decides the open, exactly as a + // reopen cycle does: open now if the desk is open now (or round-the-clock), + // else at the next open hour. We use the same primitive as reopen cycles but + // WITHOUT its `< departure` null-gate — when the next open lands on/after + // departure the shared cap below clamps the (zero-length) window to departure, + // which is truthful, rather than masking it as "open now". + opensAt = officeHoursOpen(now, { + windowOpenHour: cfg.windowOpenHour, + windowCloseHour: cfg.windowCloseHour, + }); } + + let closesAt = new Date(opensAt.getTime() + cfg.windowDurationHours * 3_600_000); if (closesAt.getTime() > departure.getTime()) { closesAt = departure; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-date.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-date.dto.ts new file mode 100644 index 000000000..4792f7d3b --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-date.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsISO8601 } from 'class-validator'; + +/** + * Reschedule a train's departure date (staff action on the ops board). Only + * allowed before the booking window opens; the new date must still leave room + * for the booking lead window before departure. + */ +export class UpdateScheduleDateDto { + @ApiProperty({ + example: '2026-07-20T05:00:00.000Z', + description: 'New scheduled departure date/time (ISO 8601)', + }) + @IsISO8601() + scheduleDate!: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 38ebd7be5..770906320 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -43,6 +43,7 @@ import { AvailableDaysQueryDto } from "./dto/available-days-query.dto"; import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-query.dto"; import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto"; import { UpdateScheduleWindowRuleDto } from "./dto/update-schedule-window-rule.dto"; +import { UpdateScheduleDateDto } from "./dto/update-schedule-date.dto"; import { TrainSchedulingService } from "./train-scheduling.service"; import { BookingBatchService } from "./booking-batch.service"; import { BookingWindowService } from "./booking-window.service"; @@ -534,6 +535,20 @@ export class TrainSchedulingController { return this.trainSchedulingService.getContainerTrainScheduleById(id); } + @Patch("schedules/:id/schedule-date") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Reschedule a train's departure date — only before the booking window opens, and only if the new date still leaves room for the booking lead window", + }) + async updateScheduleDate( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateScheduleDateDto, + ) { + await this.trainSchedulingService.updateScheduleDate(id, dto); + return this.trainSchedulingService.getContainerTrainScheduleById(id); + } + @Post("schedules/:id/doc-review-complete") @TrainSchedulingManage() @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 877d58786..6f5fb5449 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -65,6 +65,7 @@ import { } from './dto/import-djibouti-operation.dto'; import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto'; import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.dto'; +import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto'; import { type BookingWindowConfig } from './booking-window.config'; import { buildCappedWagonPlan, @@ -413,6 +414,93 @@ export class TrainSchedulingService { return fresh ?? schedule; } + /** + * Reschedule ONE train's departure date (staff action on the ops board). Only + * allowed while the booking window has not opened yet — an OPEN/past schedule + * stays frozen so customers keep the times they were shown. The new date must + * still leave room for the booking lead window before departure (same floor as + * schedule creation); INTERCITY/DOMESTIC uses the import lead. The window + * open/close times are re-derived from the schedule's existing rule snapshot. + */ + async updateScheduleDate( + id: string, + dto: UpdateScheduleDateDto, + ): Promise { + const schedule = await this.trainSchedulesRepository.findById(id); + if (!schedule) { + throw new NotFoundException(`Train schedule ${id} not found`); + } + if (schedule.windowPhase !== 'PRE_WINDOW') { + throw new BadRequestException( + 'The departure date can only be changed before the booking window opens ' + + `(this schedule is "${schedule.windowPhase ?? 'not window-managed'}").`, + ); + } + + const now = new Date(); + const departure = new Date(dto.scheduleDate); + if (Number.isNaN(departure.getTime())) { + throw new BadRequestException('Invalid departure date.'); + } + + // Staff cannot schedule inside the lead window — there must be room for a + // booking window before departure. IMPORT/DOMESTIC lead is in whole EAT days; + // EXPORT lead is in hours. Mirrors the create-schedule check. + const windowCfg = await this.getWindowConfig(); + const earliest = earliestSchedulableDeparture( + schedule.direction, + windowCfg, + now, + ); + if (departure.getTime() < earliest.getTime()) { + const detail = + schedule.direction === 'EXPORT' + ? `at least ${windowCfg.exportBookingLeadHours} hour(s) ahead` + : `at least ${windowCfg.importWindowLeadDays} day(s) ahead`; + throw new BadRequestException( + `Departure ${departure.toISOString()} is inside the booking lead window; ` + + `${schedule.direction === 'EXPORT' ? 'export' : 'import'} trains must be scheduled ${detail} ` + + `(earliest ${earliest.toISOString()}).`, + ); + } + + // Re-derive the window from the schedule's own rule snapshot (falling back to + // the live config where a legacy row has no snapshot) against the new date. + const merged: BookingWindowConfig = { + importWindowLeadDays: + schedule.ruleImportWindowLeadDays ?? windowCfg.importWindowLeadDays, + exportBookingLeadHours: + schedule.ruleExportBookingLeadHours ?? windowCfg.exportBookingLeadHours, + windowOpenHour: schedule.ruleWindowOpenHour ?? windowCfg.windowOpenHour, + windowCloseHour: schedule.ruleWindowCloseHour ?? windowCfg.windowCloseHour, + windowDurationHours: + schedule.ruleWindowDurationHours != null + ? Number(schedule.ruleWindowDurationHours) + : windowCfg.windowDurationHours, + docReviewMinutes: windowCfg.docReviewMinutes, + paymentWindowMinutes: windowCfg.paymentWindowMinutes, + reopenDelayMinutes: windowCfg.reopenDelayMinutes, + }; + + const times = + schedule.direction === 'EXPORT' + ? computeExportWindowTimes(departure, merged) + : computeImportWindowTimes(departure, merged, now); + + await this.dataSource.getRepository(TrainSchedule).update(id, { + scheduledDepartureDate: departure, + windowOpensAt: times.windowOpensAt, + windowClosesAt: times.windowClosesAt, + }); + this.logger.log( + `Departure date changed for schedule ${id} → ${departure.toISOString()} ` + + `(window reopens ${times.windowOpensAt.toISOString()})`, + ); + + const fresh = await this.trainSchedulesRepository.findById(id); + return fresh ?? schedule; + } + /** * Re-derive windowOpensAt/windowClosesAt for schedules whose booking window has * not opened yet (windowPhase === 'PRE_WINDOW', still Draft/Scheduled, departure diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index 3466e6653..e5e3bb139 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -350,6 +350,20 @@ const CLEARANCE_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [ entity: CLEARANCE_ENTITY, fields: EXPORT_CONTAINER_OUTPUT_FIELDS, }, + // Bulk output sets mirror the container output docs so customs+bulk bookings + // can finalize (previously bulk had no output set and got stuck at finalize). + { + code: "clearance_output_import_bulk", + label: "Customs output documents (import bulk)", + entity: CLEARANCE_ENTITY, + fields: IMPORT_CONTAINER_OUTPUT_FIELDS, + }, + { + code: "clearance_output_export_bulk", + label: "Customs output documents (export bulk)", + entity: CLEARANCE_ENTITY, + fields: EXPORT_CONTAINER_OUTPUT_FIELDS, + }, ]; // ── Contract pre-booking clearance settings (Path B) ──────────────────────── @@ -398,6 +412,20 @@ const CONTRACT_CLEARANCE_SETTINGS: OnboardingDocumentSetting[] = [ entity: CONTRACT_CLEARANCE_ENTITY, fields: EXPORT_CONTAINER_OUTPUT_FIELDS, }, + // Bulk output sets mirror the container output docs so customs+bulk contracts + // can finalize (previously bulk had no output set and got stuck at finalize). + { + code: "contract_clearance_output_import_bulk", + label: "Contract customs output documents (import bulk)", + entity: CONTRACT_CLEARANCE_ENTITY, + fields: IMPORT_CONTAINER_OUTPUT_FIELDS, + }, + { + code: "contract_clearance_output_export_bulk", + label: "Contract customs output documents (export bulk)", + entity: CONTRACT_CLEARANCE_ENTITY, + fields: EXPORT_CONTAINER_OUTPUT_FIELDS, + }, ]; // ── Path A self-clearance settings (no EDR customs service) ────────────────── diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/EditScheduleDateModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/EditScheduleDateModal.tsx new file mode 100644 index 000000000..6ea7dfc5b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/EditScheduleDateModal.tsx @@ -0,0 +1,141 @@ +import { useEffect, useState } from "react"; +import { + Alert, + Box, + Button, + Group, + Modal, + Stack, + Text, + TextInput, + ThemeIcon, +} from "@mantine/core"; +import { isAxiosError } from "axios"; +import { CalendarClock, Info } from "lucide-react"; +import { useMutation } from "@tanstack/react-query"; + +import { api } from "@/services/api"; +import { useToast } from "@/hooks/use-toast"; + +function parseError(error: unknown, fallback: string): string { + if (isAxiosError(error)) { + const message = error.response?.data?.message; + if (Array.isArray(message)) return message.join(", "); + if (typeof message === "string") return message; + } + return fallback; +} + +/** ISO → the `YYYY-MM-DDTHH:mm` value a datetime-local input expects (local time). */ +function toLocalInputValue(iso: string | null | undefined): string { + if (!iso) return ""; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return ""; + const pad = (n: number) => String(n).padStart(2, "0"); + return ( + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` + + `T${pad(date.getHours())}:${pad(date.getMinutes())}` + ); +} + +export interface EditScheduleDateModalProps { + scheduleId: string | null; + currentDate: string | null; + routeName?: string | null; + opened: boolean; + onClose: () => void; + /** Called after a successful save (e.g. to refetch a list). */ + onSaved?: () => void; +} + +/** + * Reschedule a train's departure date. Only shown for schedules whose booking + * window has not opened yet; the API rejects a date inside the booking lead + * window (import/intercity lead in days, export in hours). + */ +export default function EditScheduleDateModal({ + scheduleId, + currentDate, + routeName, + opened, + onClose, + onSaved, +}: EditScheduleDateModalProps) { + const { toast } = useToast(); + const save = useMutation( + api.trainScheduling.updateScheduleDate.mutationOptions(), + ); + + const [value, setValue] = useState(""); + + useEffect(() => { + if (opened) setValue(toLocalInputValue(currentDate)); + }, [opened, currentDate]); + + const handleSave = async () => { + if (!scheduleId || !value) { + toast({ title: "Pick a departure date", variant: "destructive" }); + return; + } + try { + await save.mutateAsync({ + id: scheduleId, + scheduleDate: new Date(value).toISOString(), + }); + toast({ title: "Departure date updated" }); + onSaved?.(); + onClose(); + } catch (err) { + toast({ + title: "Update failed", + description: parseError(err, "Could not update departure date"), + variant: "destructive", + }); + } + }; + + return ( + + + + + + + Edit departure date + + + {routeName ?? "This schedule only"} + + + + } + > + + }> + The date can only be changed before the booking window opens, and must + still leave room for the booking lead window before departure. + + setValue(e.currentTarget.value)} + /> + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index e1199b4ed..03d1d2d7d 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -283,6 +283,8 @@ export const URL_CONSTANTS = { `/train-scheduling/schedules/${id}/booking-window`, WINDOW_RULE: (id: string) => `/train-scheduling/schedules/${id}/window-rule`, + SCHEDULE_DATE: (id: string) => + `/train-scheduling/schedules/${id}/schedule-date`, CONTRACT_BOOKING_WINDOWS: (contractId: string) => `/train-scheduling/contracts/${contractId}/booking-windows`, MARK_BOOKING_PAID: (bookingId: string) => diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index 1c7110a5e..efdb3d4d9 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -128,6 +128,7 @@ export default function TrainScheduleV2DetailPage() { queryFn: () => trainSchedulingService.getImportDjiboutiOperation(scheduleId as string), enabled: Boolean(scheduleId && gatepassApplies), }); + const gatepassSecured = gatepassQuery.data?.gatepassStatus === "SECURED"; const secureGatepass = useMutation({ mutationFn: () => trainSchedulingService.grantImportDjiboutiGatepass(scheduleId as string, { @@ -911,6 +912,31 @@ export default function TrainScheduleV2DetailPage() { Reschedule train ) : null} + {gatepassApplies ? ( + gatepassSecured ? ( + + ) : ( + + ) + ) : null} @@ -995,83 +1021,6 @@ export default function TrainScheduleV2DetailPage() { ) : null} - {gatepassApplies ? ( - - - - - - - - - - - Djibouti Port gate pass - - - {gatepassQuery.data?.gatepassStatus ?? "NOT_SECURED"} - - - - {schedule.direction === "IMPORT" - ? "Secure before dispatch from Djibouti." - : "Secure after dispatch before Djibouti Port entry / unloading."} - - - - {gatepassQuery.isLoading ? : null} - - - - setGatepassSecuredAt(event.currentTarget.value)} - /> - setGatepassReference(event.currentTarget.value)} - /> - setGatepassFileUrl(event.currentTarget.value)} - /> - -