From 3e2299960a1b2dd8562cca62f9a41217169f34c4 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 5 Jul 2026 18:06:24 +0000 Subject: [PATCH] add script to seed freight contracts across flow variants --- apps/edr-freight-api/py/README.md | 67 +++ .../create_contracts.cpython-312.pyc | Bin 0 -> 22013 bytes apps/edr-freight-api/py/create_contracts.py | 479 ++++++++++++++++++ apps/edr-freight-api/py/requirements.txt | 3 + .../modules/bookings/clearance.util.spec.ts | 9 +- .../src/modules/bookings/clearance.util.ts | 7 +- .../contracts/contract-clearance.util.ts | 6 +- .../batch-window.util.spec.ts | 67 +++ .../train-scheduling/batch-window.util.ts | 97 ++-- .../dto/update-schedule-date.dto.ts | 16 + .../train-scheduling.controller.ts | 15 + .../train-scheduling.service.ts | 88 ++++ .../src/seed/file-upload-settings.seeder.ts | 28 + .../trainScheduling/EditScheduleDateModal.tsx | 141 ++++++ .../backoffice/src/constants/URLS.ts | 2 + .../TrainScheduleV2DetailPage.tsx | 103 +--- .../TrainScheduleV2ListPage.tsx | 21 + .../backoffice/src/services/api.ts | 12 + .../src/services/trainScheduling.service.ts | 11 + 19 files changed, 1057 insertions(+), 115 deletions(-) create mode 100644 apps/edr-freight-api/py/README.md create mode 100644 apps/edr-freight-api/py/__pycache__/create_contracts.cpython-312.pyc create mode 100644 apps/edr-freight-api/py/create_contracts.py create mode 100644 apps/edr-freight-api/py/requirements.txt create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-date.dto.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/EditScheduleDateModal.tsx 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 0000000000000000000000000000000000000000..6c13fefb3cdd27a2f5eb6c8bbe635a03384324e7 GIT binary patch literal 22013 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 bxOAH51m}44wz2fCVEuM8_=r%(kn{fodcV|H literal 0 HcmV?d00001 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)} - /> - -