Merge pull request #476 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-07-06 07:16:07 +03:00
committed by GitHub
40 changed files with 3865 additions and 303 deletions

View File

@@ -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 <token>`. 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
`<schema>.otp_verifications` in Postgres, then signs within the 5-minute TTL.

View File

@@ -0,0 +1,504 @@
#!/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. Resolved at runtime
# from the customer's own IAM profile (GET /api/me → phoneNumber). OTP_PHONE is an
# optional override / fallback used only when the customer has no phone on file.
# The sign endpoint keys the OTP purely on this number, so it just has to be the
# same value for "send" and "read".
OTP_PHONE = os.getenv("OTP_PHONE", "")
OTP_PHONE_FALLBACK = os.getenv("OTP_PHONE_FALLBACK", "251900000000")
# 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 resolve_otp_phone(customer: Client) -> str:
"""Phone the sign-OTP is sent to. Prefer the customer's own IAM profile phone
(GET /api/me → phoneNumber); fall back to OTP_PHONE, then OTP_PHONE_FALLBACK.
The value only has to be consistent between send + DB read."""
phone = ""
try:
me = customer.get("/me") or {}
phone = (me.get("phoneNumber") or "").strip()
except Exception:
pass
phone = phone or OTP_PHONE or OTP_PHONE_FALLBACK
if not phone:
raise RuntimeError(
"Could not resolve an OTP phone (customer has none, and neither "
"OTP_PHONE nor OTP_PHONE_FALLBACK is set)."
)
return phone
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, otp_phone: str
) -> 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),
]
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")
otp_phone = resolve_otp_phone(customer)
print(f"OTP phone: {otp_phone}")
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, otp_phone)
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()

View File

@@ -0,0 +1,3 @@
requests>=2.31
psycopg[binary]>=3.1
python-dotenv>=1.0

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,52 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Add the daily booking-desk close hour.
*
* The import booking window used to reopen only within the same EAT calendar day
* as its close; a cycle whose reopen crossed midnight died at CLOSED_FOR_DAY with
* capacity still free. The window now runs a daily office range [openHour,
* closeHour): a not-yet-full train pauses at closeHour and resumes the next
* morning at openHour, every day until it fills or departs. openHour === closeHour
* means a 24-hour desk.
*
* `window_close_hour` on the global-rules singleton is the live config; the
* matching `rule_window_close_hour` snapshot on each schedule freezes it at
* creation so the batch board keeps drawing the window the customer was shown.
* Both default/backfill to 17:00 (5 PM), the previous implicit office close.
*/
export class AddWindowCloseHour1950000000000 implements MigrationInterface {
name = "AddWindowCloseHour1950000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_scheduling_global_rules
ADD COLUMN IF NOT EXISTS window_close_hour integer NOT NULL DEFAULT 17;
`);
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS rule_window_close_hour integer;
`);
// Backfill the snapshot from the global-rules singleton so pre-existing
// schedules keep projecting reopen cycles.
await queryRunner.query(`
UPDATE freight.train_schedules ts
SET rule_window_close_hour = COALESCE(ts.rule_window_close_hour, r.window_close_hour)
FROM freight.train_scheduling_global_rules r
WHERE ts.rule_window_close_hour IS NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
DROP COLUMN IF EXISTS rule_window_close_hour;
`);
await queryRunner.query(`
ALTER TABLE freight.train_scheduling_global_rules
DROP COLUMN IF EXISTS window_close_hour;
`);
}
}

View File

@@ -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();
});
});

View File

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

View File

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

View File

@@ -244,9 +244,10 @@ export class GlOperationsService {
}
/**
* T1 transit-document lifecycle state for an import shipment booking. Wagon
* allocation opens the upload window; train departure locks it; train arrival
* lets GL Ethiopia close (accept) the T1 set.
* T1 transit-document lifecycle state for an import shipment booking. The
* gate pass (secured on the train schedule after wagon allocation) opens the
* upload window; train departure locks it; train arrival lets GL Ethiopia
* close (accept) the T1 set.
*/
async t1State(bookingId: string): Promise<Freight.ClearanceT1State> {
const train = await this.trainState(bookingId);
@@ -269,7 +270,8 @@ export class GlOperationsService {
}
/**
* GL Djibouti uploads T1 transport documents (multi-file) after wagon allocation.
* GL Djibouti uploads T1 transport documents (multi-file) once the gate pass
* is secured on the train schedule (which itself follows wagon allocation).
* Replaces the previous batch; locked once the train departs or T1 is closed.
*/
async uploadT1Documents(
@@ -287,6 +289,12 @@ export class GlOperationsService {
'Wagons must be allocated before T1 transport documents can be uploaded.',
);
}
const gatepass = await this.gatepassForBooking(bookingId);
if (!gatepass.granted) {
throw new BadRequestException(
'Secure the Djibouti gate pass on the train schedule before uploading T1 transport documents.',
);
}
if (state.closed) {
throw new BadRequestException('T1 has been closed by GL Ethiopia — documents are final.');
}
@@ -303,7 +311,8 @@ export class GlOperationsService {
/**
* Close (accept) the T1/transport document set.
* Import: GL Ethiopia closes once the train has arrived (T1 files required).
* Export: GL Djibouti closes after the gate pass (transport document required).
* Export: GL Djibouti closes once the train arrives at Djibouti (transport
* document required).
*/
async closeT1(
bookingId: string,
@@ -337,10 +346,9 @@ export class GlOperationsService {
'The transport document must be uploaded before T1 can be closed.',
);
}
const gatepass = await this.gatepassForBooking(bookingId);
if (!gatepass.granted) {
if (!state.trainArrivedAt) {
throw new BadRequestException(
'Secure the Djibouti gate pass on the train schedule before closing T1.',
'The train has not arrived at Djibouti yet — T1 can be closed only after arrival.',
);
}
// Export bookings seeded before T1_CLOSED joined the catalog lack the row.

View File

@@ -120,9 +120,17 @@ export class TrainSchedule extends BaseEntity {
@Column({ name: 'rule_window_open_hour', type: 'int', nullable: true })
ruleWindowOpenHour?: number | null;
/** EAT hour the daily booking desk shuts (equals open hour for a 24h desk). */
@Column({ name: 'rule_window_close_hour', type: 'int', nullable: true })
ruleWindowCloseHour?: number | null;
@Column({ name: 'rule_window_duration_hours', type: 'numeric', precision: 6, scale: 4, nullable: true })
ruleWindowDurationHours?: number | null;
/**
* Frozen reopen gap = doc-review + payment minutes at creation. The board
* projects each next cycle at close + this delay, then snaps it into office hours.
*/
@Column({ name: 'rule_reopen_delay_minutes', type: 'int', nullable: true })
ruleReopenDelayMinutes?: number | null;

View File

@@ -5,6 +5,7 @@ import {
BATCH_WINDOW_START_HOURS,
listConfigBookingWindows,
groupBookingsIntoBoardWindows,
computeImportWindowTimes,
type BoardWindowConfig,
} from './batch-window.util';
@@ -54,11 +55,145 @@ 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:0017: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:0017: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('overnight desk (open > close, wraps past midnight)', () => {
// Desk open 08:00, closes 05:00 next morning — open across midnight.
const overnight = { ...bounded, windowOpenHour: 8, windowCloseHour: 5 };
it('opens NOW at 00:00 (deep night is INSIDE the overnight window)', () => {
// Now = 05 Jul 00:00 EAT (04 Jul 21:00 UTC): after midnight, before 05:00 →
// inside the overnight desk → open immediately. This is the reported bug.
const now = new Date('2026-07-04T21:00:00.000Z');
const { windowOpensAt } = computeImportWindowTimes(departure, overnight, now);
expect(windowOpensAt.toISOString()).toBe('2026-07-04T21:00:00.000Z');
});
it('opens NOW at 22:00 (evening is INSIDE the overnight window)', () => {
// Now = 05 Jul 22:00 EAT (19:00 UTC): after 08:00 open → inside → open now.
const now = new Date('2026-07-05T19:00:00.000Z');
const { windowOpensAt } = computeImportWindowTimes(departure, overnight, now);
expect(windowOpensAt.toISOString()).toBe('2026-07-05T19:00:00.000Z');
});
it('waits to 08:00 when now is in the daytime gap [05:00, 08:00)', () => {
// Now = 05 Jul 06:00 EAT (03:00 UTC): desk shut (gap) → open 08:00 today.
const now = new Date('2026-07-05T03:00:00.000Z');
const { windowOpensAt } = computeImportWindowTimes(departure, overnight, now);
// 05 Jul 08:00 EAT = 05:00 UTC.
expect(windowOpensAt.toISOString()).toBe('2026-07-05T05:00:00.000Z');
});
});
});
describe('computeImportWindowTimes — overnight desk (open > close, wraps midnight)', () => {
// Overnight desk 08:00 → 07:00 next morning: open across [08:00, 24:00) and
// [00:00, 07:00). Only the daytime gap [07:00, 08:00) is shut.
const overnight = {
importWindowLeadDays: 3,
windowOpenHour: 8,
windowCloseHour: 7,
windowDurationHours: 6,
};
it('opens NOW in the evening side of the window (after open hour)', () => {
// Departs 06 Jul 10:00 EAT (07:00 UTC). Now = 05 Jul 20:00 EAT (17:00 UTC):
// ≥ 08:00 → desk open → open immediately.
const departure = new Date('2026-07-06T07:00:00.000Z');
const now = new Date('2026-07-05T17:00:00.000Z');
const { windowOpensAt } = computeImportWindowTimes(departure, overnight, now);
expect(windowOpensAt.toISOString()).toBe('2026-07-05T17:00:00.000Z');
});
it('opens NOW after midnight (before close hour)', () => {
// Departs 06 Jul 10:00 EAT (07:00 UTC). Now = 06 Jul 02:00 EAT (05 Jul 23:00
// UTC): < 07:00 → still inside the overnight window → open immediately.
const departure = new Date('2026-07-06T07:00:00.000Z');
const now = new Date('2026-07-05T23:00:00.000Z');
const { windowOpensAt } = computeImportWindowTimes(departure, overnight, now);
expect(windowOpensAt.toISOString()).toBe('2026-07-05T23:00:00.000Z');
});
it('waits until open hour in the daytime gap [close, open)', () => {
// Departs 06 Jul 10:00 EAT (07:00 UTC). Now = 05 Jul 07:30 EAT (04:30 UTC):
// in the shut daytime gap → opens 05 Jul 08:00 EAT (05:00 UTC).
const departure = new Date('2026-07-06T07:00:00.000Z');
const now = new Date('2026-07-05T04:30:00.000Z');
const { windowOpensAt } = computeImportWindowTimes(departure, overnight, now);
expect(windowOpensAt.toISOString()).toBe('2026-07-05T05:00:00.000Z');
});
});
describe('batch-window board windows (config-driven booking cycles)', () => {
// Default rules: open 08:00 EAT, 3 days before departure, 3h long, reopen 90m later.
// Default rules: open 08:00 EAT, desk shuts 17:00, 3 days before departure,
// 3h long, reopen 90m later.
const cfg: BoardWindowConfig = {
importWindowLeadDays: 3,
windowOpenHour: 8,
windowCloseHour: 17,
windowDurationHours: 3,
reopenDelayMinutes: 90,
exportBookingLeadHours: 24,
@@ -76,14 +211,43 @@ describe('batch-window board windows (config-driven booking cycles)', () => {
expect(windows[0].end.toISOString()).toBe('2026-06-05T08:00:00.000Z');
});
it('import: reopens reopenDelayMinutes after close, same booking day', () => {
it('import: reopens reopenDelayMinutes after close while inside office hours', () => {
const departure = new Date('2026-06-08T11:00:00.000Z');
const windows = listConfigBookingWindows('IMPORT', departure, cfg);
// cycle 1: 08:0011:00; reopen +90m → cycle 2 opens 12:30 EAT
// cycle 1: 08:0011:00; reopen +90m → cycle 2 opens 12:30 EAT, same day
expect(windows.length).toBeGreaterThanOrEqual(2);
expect(windows[1].start.toISOString()).toBe('2026-06-05T09:30:00.000Z'); // 12:30 EAT
// all cycles stay on the same EAT booking day
expect(windows.every((w) => w.date === '2026-06-05')).toBe(true);
expect(windows[1].date).toBe('2026-06-05');
});
it('import: pauses at close hour and resumes next morning at open hour', () => {
const departure = new Date('2026-06-08T11:00:00.000Z');
const windows = listConfigBookingWindows('IMPORT', departure, cfg);
// Day 05 Jun: 08:00, 12:30, 17:00-clamped… the cycle whose reopen lands
// at/after 17:00 EAT rolls to 06 Jun 08:00 EAT (05:00 UTC).
const day6First = windows.find((w) => w.date === '2026-06-06');
expect(day6First).toBeDefined();
expect(day6First!.start.toISOString()).toBe('2026-06-06T05:00:00.000Z'); // 08:00 EAT
// Cycles span the office days between the window day and departure.
const days = new Set(windows.map((w) => w.date));
expect(days.has('2026-06-05')).toBe(true);
expect(days.has('2026-06-06')).toBe(true);
});
it('import: 24-hour desk (open hour === close hour) never breaks for the day', () => {
const roundClock: BoardWindowConfig = { ...cfg, windowOpenHour: 8, windowCloseHour: 8 };
const departure = new Date('2026-06-08T11:00:00.000Z');
const windows = listConfigBookingWindows('IMPORT', departure, roundClock);
// Reopen chains straight through midnight: an overnight cycle exists.
const crossesNight = windows.some(
(w, i) => i > 0 && windows[i - 1].date !== w.date,
);
expect(crossesNight).toBe(true);
// Cycles run continuously from the window day up to departure — the last one
// reaches departure, proving the runaway cap did not truncate the projection.
expect(windows[windows.length - 1].end.getTime()).toBe(departure.getTime());
// Spans the full lead (window day 05 Jun → departure 08 Jun).
expect(new Set(windows.map((w) => w.date)).size).toBeGreaterThanOrEqual(3);
});
it('export: single FCFS window exportBookingLeadHours before departure', () => {

View File

@@ -137,33 +137,145 @@ export function shiftEatDay(day: string, deltaDays: number): string {
).padStart(2, '0')}`;
}
/**
* The daily office window `[openHour, closeHour)` in EAT: after `closeHour` the
* booking desk is shut and reopens `openHour` the next morning. `openHour ===
* closeHour` means a 24-hour desk that never breaks for the day.
*/
export interface OfficeHours {
windowOpenHour: number;
windowCloseHour: number;
}
/** True when the desk runs round the clock (open hour equals close hour). */
export function isRoundTheClock(hours: OfficeHours): boolean {
return hours.windowOpenHour === hours.windowCloseHour;
}
/**
* Where the NEXT booking cycle opens after a cycle closes at `closedAt`, given a
* not-yet-full train and a daily office window. `earliestNextOpen` is the raw
* ready time (close + doc-review + payment); the desk honours it only while
* inside office hours:
*
* • round-the-clock desk → opens at `earliestNextOpen` (no day break)
* • ready time before closeHour → opens at `earliestNextOpen`, same day
* • ready time at/after closeHour → desk shut; opens next morning at openHour
*
* Returns `null` when the next open would fall on/after `departure` — the train
* leaves before another cycle could run, so the window is done.
*
* The desk may run within one EAT day (`closeHour > openHour`), round the clock
* (`openHour === closeHour`), or overnight across midnight (`openHour >
* closeHour`, e.g. 08:00 → 07:00). `officeHoursOpen` handles all three.
*/
/**
* 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 (hours.windowOpenHour > hours.windowCloseHour) {
// Overnight desk, e.g. open 08:00 → close 07:00 next morning. The desk is
// open across midnight: [openHour, 24:00) on this EAT day and [00:00,
// closeHour) on the next. Only the daytime gap [closeHour, openHour) is shut.
if (readyMinutes >= openMinutes || readyMinutes < closeMinutes) {
// Inside the overnight window (either side of midnight) → open when ready.
return readyAt;
}
// In the daytime gap → the desk opens again at openHour this EAT morning.
return eatDayToUtc(eatDay(readyAt), hours.windowOpenHour);
}
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 {
const opensAt = officeHoursOpen(earliestNextOpen, hours);
return opensAt.getTime() < departure.getTime() ? opensAt : null;
}
export interface InitialWindowTimes {
windowOpensAt: Date;
windowClosesAt: Date;
}
/**
* 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;
}
@@ -269,7 +381,10 @@ export interface BoardWindow extends BatchWindow {
export interface BoardWindowConfig {
importWindowLeadDays: number;
windowOpenHour: number;
/** EAT hour the daily booking desk shuts; equals windowOpenHour for a 24h desk. */
windowCloseHour: number;
windowDurationHours: number;
/** Gap between a cycle's close and its reopen (doc review + payment minutes). */
reopenDelayMinutes: number;
exportBookingLeadHours: number;
}
@@ -328,25 +443,34 @@ export function listConfigBookingWindows(
const windows: BoardWindow[] = [];
const durationMs = cfg.windowDurationHours * 3_600_000;
// Post-close gap before the next cycle opens (doc review + payment), subject
// to office hours below.
const reopenMs = cfg.reopenDelayMinutes * 60_000;
const officeHours: OfficeHours = {
windowOpenHour: cfg.windowOpenHour,
windowCloseHour: cfg.windowCloseHour,
};
const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays);
let opensAt = anchorOpensAt ?? eatDayToUtc(windowDay, cfg.windowOpenHour);
// Reopen stays on the same EAT booking day and before departure; cap at 12 cycles.
for (let cycle = 0; cycle < 12; cycle += 1) {
let opensAt: Date | null = anchorOpensAt ?? eatDayToUtc(windowDay, cfg.windowOpenHour);
// The loop terminates naturally: every cycle advances opensAt by at least
// (duration + reopen) > 0, and nextCycleOpensAt returns null once opensAt would
// reach departure. maxCycles is a derived runaway backstop sized to the real
// span (first open → departure) over the smallest possible advance, so a
// legitimate config is never silently truncated — only a pathological
// zero-length one would hit it.
const spanMs = departure.getTime() - opensAt.getTime();
const minAdvanceMs = Math.max(durationMs + reopenMs, 60_000);
const maxCycles = Math.ceil(spanMs / minAdvanceMs) + 2;
for (let cycle = 0; cycle < maxCycles; cycle += 1) {
if (opensAt.getTime() >= departure.getTime()) break;
let closesAt = new Date(opensAt.getTime() + durationMs);
if (closesAt.getTime() > departure.getTime()) closesAt = departure;
windows.push(boardWindowFromInterval(opensAt, closesAt));
const nextOpensAt = new Date(closesAt.getTime() + reopenMs);
if (
nextOpensAt.getTime() >= departure.getTime() ||
eatDay(nextOpensAt) !== eatDay(opensAt)
) {
break;
}
opensAt = nextOpensAt;
const earliestNextOpen = new Date(closesAt.getTime() + reopenMs);
opensAt = nextCycleOpensAt(earliestNextOpen, officeHours, departure);
if (opensAt == null) break;
}
// Degenerate config (no window before departure) — surface a single window

View File

@@ -82,6 +82,7 @@ describe('BookingBatchService — PAID reconcile', () => {
importWindowLeadDays: 3,
exportBookingLeadHours: 24,
windowOpenHour: 8,
windowCloseHour: 17,
windowDurationHours: 3,
docReviewMinutes: 30,
paymentWindowMinutes: 60,

View File

@@ -736,6 +736,7 @@ export class BookingBatchService implements OnModuleInit {
};
const windowCfg = {
windowOpenHour: num(s.ruleWindowOpenHour, liveCfg.windowOpenHour),
windowCloseHour: num(s.ruleWindowCloseHour, liveCfg.windowCloseHour),
windowDurationHours: num(
s.ruleWindowDurationHours,
liveCfg.windowDurationHours,

View File

@@ -7,8 +7,14 @@ export interface BookingWindowConfig {
importWindowLeadDays: number;
/** Hours before departure an export booking becomes acceptable (FCFS). */
exportBookingLeadHours: number;
/** Local (Africa/Addis_Ababa) hour at which the import window opens. */
/** Local (Africa/Addis_Ababa) hour at which the import window opens each day. */
windowOpenHour: number;
/**
* Local (Africa/Addis_Ababa) hour the booking desk shuts for the day: once a
* cycle's reopen would fall at/after this hour, the window pauses and resumes
* next morning at windowOpenHour. Equal to windowOpenHour ⇒ 24-hour desk.
*/
windowCloseHour: number;
windowDurationHours: number;
/** Max staff document-review time after the window closes. */
docReviewMinutes: number;

View File

@@ -9,9 +9,9 @@ import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { NotificationsService } from '../notifications/notifications.service';
import { BookingBatchService } from './booking-batch.service';
import { TrainSchedulingService } from './train-scheduling.service';
import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service';
import { BATCH_TIMEZONE } from './booking-batch.constants';
import { eatDay } from './batch-window.util';
import { eatDay, nextCycleOpensAt, type OfficeHours } from './batch-window.util';
import { type BookingWindowConfig } from './booking-window.config';
/**
@@ -54,7 +54,7 @@ export class BookingWindowService implements OnModuleInit {
this.ticking = true;
try {
const now = new Date();
const cfg = await this.trainSchedulingService.getWindowConfig();
const liveCfg = await this.trainSchedulingService.getWindowConfig();
const active = (
await this.trainSchedulesRepository.findAll({
@@ -64,12 +64,22 @@ export class BookingWindowService implements OnModuleInit {
],
})
).filter(
(s) => s.windowPhase != null && s.windowPhase !== 'DONE' && s.windowPhase !== 'CLOSED_FOR_DAY',
// CLOSED_FOR_DAY is legacy (the daily desk now reopens via PRE_WINDOW):
// still pick those rows up so advanceImport can revive them next morning.
(s) => s.windowPhase != null && s.windowPhase !== 'DONE',
);
for (const schedule of active) {
try {
await this.advanceSchedule(schedule, cfg, now);
// Each train runs under its OWN frozen rule snapshot, not the live global
// config — a later global-rules edit must not retro-change the window an
// existing train already advertised, and the reopen cycles must match the
// board (which is drawn from the same snapshot).
await this.advanceSchedule(
schedule,
effectiveWindowConfig(schedule, liveCfg),
now,
);
} catch (err) {
this.logger.error(
`Window transition failed for schedule ${schedule.id}: ${(err as Error).message}`,
@@ -100,7 +110,7 @@ export class BookingWindowService implements OnModuleInit {
return schedule;
}
const now = new Date();
const cfg = await this.trainSchedulingService.getWindowConfig();
const liveCfg = await this.trainSchedulingService.getWindowConfig();
// Stamp the whole route-day group so one staff action releases every train
// sharing this booking day's pool.
const group = (
@@ -121,7 +131,7 @@ export class BookingWindowService implements OnModuleInit {
.getRepository(TrainSchedule)
.update(s.id, { docReviewCompletedAt: now });
s.docReviewCompletedAt = now;
await this.advanceSchedule(s, cfg, now);
await this.advanceSchedule(s, effectiveWindowConfig(s, liveCfg), now);
}
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
return fresh ?? schedule;
@@ -185,6 +195,14 @@ export class BookingWindowService implements OnModuleInit {
): Promise<boolean> {
const { windowPhase, windowOpensAt, windowClosesAt } = schedule;
// Legacy rows parked at CLOSED_FOR_DAY predate the daily-desk reopen: revive
// them through the same not-full conclude path so they resume next morning
// (or finalize as DONE if no cycle fits before departure).
if (windowPhase === 'CLOSED_FOR_DAY') {
await this.concludeCycle(schedule, cfg, now);
return true;
}
if (windowPhase === 'PRE_WINDOW' && windowOpensAt && now >= windowOpensAt) {
await this.setPhase(schedule, {
windowPhase: 'OPEN',
@@ -268,34 +286,46 @@ export class BookingWindowService implements OnModuleInit {
return;
}
const closesAt = schedule.windowClosesAt ?? now;
const reopenAt = new Date(closesAt.getTime() + cfg.reopenDelayMinutes * 60_000);
const nextOpensAt = reopenAt > now ? reopenAt : now;
let nextClosesAt = new Date(nextOpensAt.getTime() + cfg.windowDurationHours * 3_600_000);
// Doc review + payment have already run, so the desk is ready to reopen NOW —
// office hours decide whether that is this afternoon or tomorrow morning. Past
// the last cycle before departure, nextCycleOpensAt returns null and we finish.
const officeHours: OfficeHours = {
windowOpenHour: cfg.windowOpenHour,
windowCloseHour: cfg.windowCloseHour,
};
const nextOpensAt = nextCycleOpensAt(
now,
officeHours,
schedule.scheduledDepartureDate,
);
if (nextOpensAt == null) {
await this.setPhase(schedule, { windowPhase: 'DONE' });
this.logger.log(
`Schedule ${schedule.id} not full but no cycle fits before departure — window done`,
);
return;
}
let nextClosesAt = new Date(
nextOpensAt.getTime() + cfg.windowDurationHours * 3_600_000,
);
if (nextClosesAt > schedule.scheduledDepartureDate) {
nextClosesAt = schedule.scheduledDepartureDate;
}
const sameBookingDay = eatDay(nextOpensAt) === eatDay(closesAt);
const beforeDeparture = nextOpensAt < schedule.scheduledDepartureDate;
if (sameBookingDay && beforeDeparture) {
await this.setPhase(schedule, {
windowPhase: 'PRE_WINDOW',
windowOpensAt: nextOpensAt,
windowClosesAt: nextClosesAt,
docReviewCompletedAt: null,
docReviewEndsAt: null,
paymentPhaseEndsAt: null,
});
this.logger.log(
`Schedule ${schedule.id} not full — window reopens at ${nextOpensAt.toISOString()}`,
);
} else {
await this.setPhase(schedule, { windowPhase: 'CLOSED_FOR_DAY' });
this.logger.log(
`Booking day over for schedule ${schedule.id} — remaining capacity is staff-managed`,
);
}
// Stays PRE_WINDOW (not CLOSED_FOR_DAY): the tick reopens it at nextOpensAt,
// whether that is later today or next morning after the office-hours break.
await this.setPhase(schedule, {
windowPhase: 'PRE_WINDOW',
windowOpensAt: nextOpensAt,
windowClosesAt: nextClosesAt,
docReviewCompletedAt: null,
docReviewEndsAt: null,
paymentPhaseEndsAt: null,
});
const sameDay = eatDay(nextOpensAt) === eatDay(now);
this.logger.log(
`Schedule ${schedule.id} not full — window reopens ${sameDay ? 'today' : 'next booking day'} at ${nextOpensAt.toISOString()}`,
);
}
private async tryAutoFinalize(scheduleId: string): Promise<void> {

View File

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

View File

@@ -0,0 +1,62 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsNumber, IsOptional, Max, Min } from 'class-validator';
/**
* Per-schedule booking-window rule override (staff action on the ops board).
* Every field is optional — only the ones sent are changed; the rest keep the
* schedule's existing snapshot. Mirrors the window fields of the global rules DTO.
*/
export class UpdateScheduleWindowRuleDto {
@ApiPropertyOptional({ example: 8, description: 'Local EAT hour the booking desk opens each day' })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(23)
windowOpenHour?: number;
@ApiPropertyOptional({
example: 17,
description:
'Local EAT hour the booking desk shuts each day; a not-yet-full window resumes next morning at windowOpenHour. Equal to windowOpenHour = 24-hour desk',
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(23)
windowCloseHour?: number;
@ApiPropertyOptional({ example: 3, description: 'How long each booking cycle stays open, in hours' })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0.0166)
@Max(12)
windowDurationHours?: number;
@ApiPropertyOptional({ example: 30, description: 'Max staff document-review minutes after the window closes' })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
docReviewMinutes?: number;
@ApiPropertyOptional({ example: 60, description: 'Customer payment window minutes' })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
paymentWindowMinutes?: number;
@ApiPropertyOptional({
example: 3,
description: 'Days before departure the booking window starts (re-derives the window start)',
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
importWindowLeadDays?: number;
}

View File

@@ -52,7 +52,7 @@ export class UpdateTrainSchedulingGlobalRulesDto {
@Min(1)
exportBookingLeadHours?: number;
@ApiPropertyOptional({ example: 8, description: 'Local EAT hour the import window opens' })
@ApiPropertyOptional({ example: 8, description: 'Local EAT hour the import window opens each day' })
@IsOptional()
@Type(() => Number)
@IsInt()
@@ -60,6 +60,18 @@ export class UpdateTrainSchedulingGlobalRulesDto {
@Max(23)
windowOpenHour?: number;
@ApiPropertyOptional({
example: 17,
description:
'Local EAT hour the booking desk shuts each day; a not-yet-full window resumes next morning at windowOpenHour. Equal to windowOpenHour = 24-hour desk',
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(23)
windowCloseHour?: number;
// Stored in hours. The UI enters this in minutes/hours/days and converts to
// hours before sending, so the floor is 1 minute (0.0166h) — not 15 min.
@ApiPropertyOptional({ example: 3 })

View File

@@ -54,6 +54,14 @@ export class TrainSchedulingGlobalRules extends BaseEntity {
@Column({ name: 'window_open_hour', type: 'int', default: 8 })
windowOpenHour!: number;
/**
* Local (Africa/Addis_Ababa) hour the booking desk shuts each day. A not-yet-full
* train whose next cycle would reopen at/after this hour pauses until the next
* morning's windowOpenHour. Set equal to windowOpenHour for a 24-hour desk.
*/
@Column({ name: 'window_close_hour', type: 'int', default: 17 })
windowCloseHour!: number;
// Stored in hours; 4 decimals so sub-minute UI durations (4 min = 0.0667h)
// are exact. See WidenWindowDurationHoursPrecision migration.
@Column({

View File

@@ -42,6 +42,8 @@ import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
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";
@@ -370,6 +372,19 @@ export class TrainSchedulingController {
return this.trainSchedulingService.updateImportLoadingStatus(id, dto);
}
@Patch("schedules/:id/loading-status")
@TrainSchedulingManage()
@ApiOperation({
summary:
"Mark bookings loaded/unloaded on this schedule (any direction, pre-dispatch only)",
})
setBookingLoadingStatus(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateImportLoadingStatusDto,
) {
return this.trainSchedulingService.setBookingLoadingStatus(id, dto);
}
@Post("schedules/:id/pin-wagons")
@TrainSchedulingManage()
@ApiOperation({ summary: "Pin physical wagons to train set slots" })
@@ -438,6 +453,19 @@ export class TrainSchedulingController {
return this.trainSchedulingService.confirmImportLoadedOnTrain(id, dto);
}
@Post("schedules/:id/confirm-loading")
@TrainSchedulingManage()
@ApiOperation({
summary:
"Confirm cargo loaded on the train (any direction; unblocks import-Djibouti dispatch)",
})
confirmScheduleLoading(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: ImportDjiboutiActionDto,
) {
return this.trainSchedulingService.confirmScheduleLoading(id, dto);
}
@Post("schedules/:id/import-djibouti/depart")
@TrainSchedulingManage()
@ApiOperation({ summary: "Depart loaded import train from Djibouti" })
@@ -519,6 +547,34 @@ export class TrainSchedulingController {
return this.trainSchedulingService.getContainerTrainScheduleById(id);
}
@Patch("schedules/:id/window-rule")
@TrainSchedulingManage()
@ApiOperation({
summary:
"Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens",
})
async updateScheduleWindowRule(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateScheduleWindowRuleDto,
) {
await this.trainSchedulingService.updateScheduleWindowRule(id, dto);
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({

View File

@@ -64,6 +64,8 @@ import {
UploadImportDjiboutiDocumentDto,
} 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,
@@ -124,6 +126,66 @@ import {
const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const;
/**
* The booking-window rule fields frozen onto a train schedule at creation (and
* refreshed by restampPendingWindows for not-yet-open schedules). The board draws
* its display cycles from this snapshot, so a later global-rules edit never redraws
* an already-open schedule's windows. The reopen gap is derived here — doc review +
* payment — because that is the real delay between a cycle closing and reopening.
*/
function windowRuleSnapshot(cfg: BookingWindowConfig) {
return {
ruleWindowOpenHour: cfg.windowOpenHour,
ruleWindowCloseHour: cfg.windowCloseHour,
ruleWindowDurationHours: cfg.windowDurationHours,
ruleReopenDelayMinutes: cfg.docReviewMinutes + cfg.paymentWindowMinutes,
ruleImportWindowLeadDays: cfg.importWindowLeadDays,
ruleExportBookingLeadHours: cfg.exportBookingLeadHours,
};
}
/**
* The booking-window config a specific schedule runs under: its frozen rule
* snapshot (open/close hour, duration, lead, reopen gap) overlaid on the live
* config, with the live config filling any snapshot field a legacy row lacks.
*
* The window SHAPE (hours, duration, lead, reopen gap) comes from the snapshot so
* the runtime cycle engine matches exactly what the board drew and the customer
* saw — a later global-rule edit must not retro-change an existing train. The
* doc-review / payment split is an internal process timing (not part of the
* window the customer sees) and is not stored split in the snapshot, so it always
* takes the live values; their sum is only used as a fallback reopen gap when the
* row predates `ruleReopenDelayMinutes`.
*/
export function effectiveWindowConfig(
schedule: {
ruleWindowOpenHour?: number | null;
ruleWindowCloseHour?: number | null;
ruleWindowDurationHours?: number | null;
ruleReopenDelayMinutes?: number | null;
ruleImportWindowLeadDays?: number | null;
ruleExportBookingLeadHours?: number | null;
},
liveCfg: BookingWindowConfig,
): BookingWindowConfig {
return {
importWindowLeadDays:
schedule.ruleImportWindowLeadDays ?? liveCfg.importWindowLeadDays,
exportBookingLeadHours:
schedule.ruleExportBookingLeadHours ?? liveCfg.exportBookingLeadHours,
windowOpenHour: schedule.ruleWindowOpenHour ?? liveCfg.windowOpenHour,
windowCloseHour: schedule.ruleWindowCloseHour ?? liveCfg.windowCloseHour,
windowDurationHours:
schedule.ruleWindowDurationHours != null
? Number(schedule.ruleWindowDurationHours)
: liveCfg.windowDurationHours,
docReviewMinutes: liveCfg.docReviewMinutes,
paymentWindowMinutes: liveCfg.paymentWindowMinutes,
reopenDelayMinutes:
schedule.ruleReopenDelayMinutes ?? liveCfg.reopenDelayMinutes,
};
}
export type BookingWagonAllocationStatus =
| 'NOT_ATTEMPTED'
| 'ASSIGNED'
@@ -269,18 +331,27 @@ export class TrainSchedulingService {
if (dto.importWindowLeadDays != null) row.importWindowLeadDays = dto.importWindowLeadDays;
if (dto.exportBookingLeadHours != null) row.exportBookingLeadHours = dto.exportBookingLeadHours;
if (dto.windowOpenHour != null) row.windowOpenHour = dto.windowOpenHour;
if (dto.windowCloseHour != null) row.windowCloseHour = dto.windowCloseHour;
if (dto.windowDurationHours != null) row.windowDurationHours = dto.windowDurationHours;
if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes;
if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes;
if (dto.reopenDelayMinutes != null) row.reopenDelayMinutes = dto.reopenDelayMinutes;
// The booking desk supports three shapes: a same-day range
// (closeHour > openHour), a 24-hour desk (openHour === closeHour), and an
// overnight range that wraps past midnight (openHour > closeHour, e.g.
// 08:00 → 07:00). officeHoursOpen handles all three, so no ordering guard.
// Fields that change the STAMPED open/close times of a schedule. docReview/
// payment/reopen are read live by the cron each tick, so they need no
// re-stamp; only the four below feed computeImport/ExportWindowTimes.
const windowTimingChanged =
dto.importWindowLeadDays != null ||
dto.windowOpenHour != null ||
dto.windowCloseHour != null ||
dto.windowDurationHours != null ||
dto.docReviewMinutes != null ||
dto.paymentWindowMinutes != null ||
dto.exportBookingLeadHours != null;
const saved = await this.dataSource
@@ -298,6 +369,156 @@ export class TrainSchedulingService {
return saved;
}
/**
* Override the booking-window rule for ONE schedule (staff action on the ops
* board). Only the fields provided are changed; the rest keep the schedule's
* existing snapshot (falling back to the live global config for legacy rows).
* The window must not have opened yet — an OPEN/past schedule stays frozen so
* customers keep the times they were shown. windowOpensAt/ClosesAt are
* re-derived from the merged rule, and the snapshot is updated so the board
* draws the new cycles.
*/
async updateScheduleWindowRule(
id: string,
dto: UpdateScheduleWindowRuleDto,
): Promise<TrainSchedule> {
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(
'Booking window settings can only be changed before the window opens ' +
`(this schedule is "${schedule.windowPhase ?? 'not window-managed'}").`,
);
}
const now = new Date();
if (!schedule.scheduledDepartureDate || schedule.scheduledDepartureDate <= now) {
throw new BadRequestException(
'This schedule has already departed or has no departure date.',
);
}
// Merge the override onto the schedule's current effective rule (its snapshot,
// or the live config where a legacy row has no snapshot).
const liveCfg = await this.getWindowConfig();
const merged: BookingWindowConfig = {
importWindowLeadDays:
dto.importWindowLeadDays ??
schedule.ruleImportWindowLeadDays ??
liveCfg.importWindowLeadDays,
exportBookingLeadHours:
schedule.ruleExportBookingLeadHours ?? liveCfg.exportBookingLeadHours,
windowOpenHour:
dto.windowOpenHour ?? schedule.ruleWindowOpenHour ?? liveCfg.windowOpenHour,
windowCloseHour:
dto.windowCloseHour ?? schedule.ruleWindowCloseHour ?? liveCfg.windowCloseHour,
windowDurationHours:
dto.windowDurationHours ??
(schedule.ruleWindowDurationHours != null
? Number(schedule.ruleWindowDurationHours)
: liveCfg.windowDurationHours),
// The reopen gap is doc review + payment; keep the config values unless the
// override changes them, so the derived snapshot delay stays consistent.
docReviewMinutes: dto.docReviewMinutes ?? liveCfg.docReviewMinutes,
paymentWindowMinutes: dto.paymentWindowMinutes ?? liveCfg.paymentWindowMinutes,
reopenDelayMinutes: liveCfg.reopenDelayMinutes,
};
// Same-day, 24-hour, and overnight (openHour > closeHour) desks are all valid
// — officeHoursOpen resolves each, so no close-vs-open ordering guard here.
const times =
schedule.direction === 'EXPORT'
? computeExportWindowTimes(schedule.scheduledDepartureDate, merged)
: computeImportWindowTimes(schedule.scheduledDepartureDate, merged, now);
await this.dataSource.getRepository(TrainSchedule).update(id, {
windowOpensAt: times.windowOpensAt,
windowClosesAt: times.windowClosesAt,
...windowRuleSnapshot(merged),
});
this.logger.log(
`Booking-window rule overridden for schedule ${id} — reopens ${times.windowOpensAt.toISOString()}`,
);
const fresh = await this.trainSchedulesRepository.findById(id);
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<TrainSchedule> {
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 = effectiveWindowConfig(schedule, windowCfg);
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
@@ -329,11 +550,7 @@ export class TrainSchedulingService {
await repo.update(s.id, {
windowOpensAt: times.windowOpensAt,
windowClosesAt: times.windowClosesAt,
ruleWindowOpenHour: cfg.windowOpenHour,
ruleWindowDurationHours: cfg.windowDurationHours,
ruleReopenDelayMinutes: cfg.reopenDelayMinutes,
ruleImportWindowLeadDays: cfg.importWindowLeadDays,
ruleExportBookingLeadHours: cfg.exportBookingLeadHours,
...windowRuleSnapshot(cfg),
});
restamped += 1;
}
@@ -359,6 +576,7 @@ export class TrainSchedulingService {
importWindowLeadDays: num(row?.importWindowLeadDays, 3),
exportBookingLeadHours: num(row?.exportBookingLeadHours, 24),
windowOpenHour: num(row?.windowOpenHour, 8),
windowCloseHour: num(row?.windowCloseHour, 17),
windowDurationHours: num(row?.windowDurationHours, 3),
docReviewMinutes: num(row?.docReviewMinutes, 30),
paymentWindowMinutes: num(row?.paymentWindowMinutes, 60),
@@ -503,13 +721,7 @@ export class TrainSchedulingService {
// only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an
// already-open schedule keeps this snapshot, and the batch board draws its
// windows from it rather than the live config.
const ruleSnapshot = {
ruleWindowOpenHour: windowCfg.windowOpenHour,
ruleWindowDurationHours: windowCfg.windowDurationHours,
ruleReopenDelayMinutes: windowCfg.reopenDelayMinutes,
ruleImportWindowLeadDays: windowCfg.importWindowLeadDays,
ruleExportBookingLeadHours: windowCfg.exportBookingLeadHours,
};
const ruleSnapshot = windowRuleSnapshot(windowCfg);
const windowFields =
direction === 'EXPORT'
? {
@@ -593,11 +805,49 @@ export class TrainSchedulingService {
const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet);
const limitLoco = minLocomotiveLimits(setLocomotives) ?? undefined;
const limits = await this.resolveTrainLimitConfig(previewDto, limitLoco);
// Callers that add bookings without hand-picking container slots (the
// workspace "Add from pool" button, re-adding a removed booking) send no
// containerPlacements. Auto-fill them the same way the batch engine does:
// preview the wagon plan first, then lay containers into the plan's slots.
// Without this the placement validator rejects container bookings outright
// ("Container placements are required for container bookings").
let containerPlacements = dto.containerPlacements;
if (!containerPlacements?.length) {
const preview = await this.validateBookingsForScheduling(
previewDto,
freightType ?? null,
dto.forceAssign,
[],
false,
limits,
scheduleId,
);
const containerBookings = preview.bookings.filter(
(b) => b.freightType === 'CONTAINER',
);
if (containerBookings.length) {
const units = expandBookingContainerUnits(containerBookings);
const slots = getContainerSlotSequenceNos(preview.wagonPlan);
const generated = autoFillPlacements(units, slots);
const missing = findMissingContainerNumberIssues(units, generated);
if (missing.length) {
throw new BadRequestException({
message: `Booking validation failed: ${missing
.map((m) => m.issue)
.join('; ')}`,
violations: missing.map((m) => m.issue),
});
}
containerPlacements = generated;
}
}
const validation = await this.validateBookingsForScheduling(
previewDto,
freightType ?? null,
dto.forceAssign,
dto.containerPlacements,
containerPlacements,
true,
limits,
scheduleId,
@@ -693,7 +943,7 @@ export class TrainSchedulingService {
savedWagons,
wagonPlan,
bookings,
dto.containerPlacements ?? [],
containerPlacements ?? [],
);
for (const booking of bookings) {
@@ -809,32 +1059,34 @@ export class TrainSchedulingService {
}
private async runWarehouseArrivalAutomation(scheduleId: string) {
const [schedule]: Array<{
originCountry: string | null;
destinationCountry: string | null;
destinationCode: string | null;
destinationName: string | null;
}> = await this.dataSource.query(
`SELECT oy.country AS "originCountry",
dy.country AS "destinationCountry",
dy.code AS "destinationCode",
dy.name AS "destinationName"
FROM freight.train_schedules ts
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
WHERE ts.id = $1 AND ts.deleted_at IS NULL
LIMIT 1`,
[scheduleId],
);
if (!schedule) return { status: 'SKIPPED', reason: 'Train schedule not found' };
const direction = deriveTradeDirection(
{ country: schedule.originCountry },
{ country: schedule.destinationCountry },
);
// Runs after the arrival transaction has committed — must never throw, or a
// successfully arrived train reports a 500 and looks stuck to the operator.
let direction: string | undefined;
try {
const [schedule]: Array<{
originCountry: string | null;
destinationCountry: string | null;
destinationCode: string | null;
destinationName: string | null;
}> = await this.dataSource.query(
`SELECT oy.country AS "originCountry",
dy.country AS "destinationCountry",
dy.code AS "destinationCode",
dy.label AS "destinationName"
FROM freight.train_schedules ts
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
WHERE ts.id = $1 AND ts.deleted_at IS NULL
LIMIT 1`,
[scheduleId],
);
if (!schedule) return { status: 'SKIPPED', reason: 'Train schedule not found' };
direction = deriveTradeDirection(
{ country: schedule.originCountry },
{ country: schedule.destinationCountry },
);
if (direction === 'IMPORT') {
return {
direction,
@@ -957,6 +1209,50 @@ export class TrainSchedulingService {
return this.getImportLoadingBookings(scheduleId);
}
/**
* Flip loaded/unloaded on the schedule↔booking link from the workspace, for any
* direction (import/export/domestic). Distinct from unassign: the booking stays
* on its wagon; this only records whether cargo is physically loaded. Allowed
* only before dispatch — once the train is DISPATCHED/ARRIVED the on-arrival
* warehouse automation owns unload, so staff can no longer hand-edit the flag.
*/
async setBookingLoadingStatus(scheduleId: string, dto: UpdateImportLoadingStatusDto) {
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
throw new BadRequestException(
`Loading status can only be changed before dispatch (schedule is ${schedule.status})`,
);
}
const [scheduleBookings, allocations] = await Promise.all([
this.trainScheduleBookingsRepository.findByScheduleId(scheduleId),
this.wagonBookingAllocationsRepository.findByScheduleId(scheduleId),
]);
const scheduledIds = new Set(scheduleBookings.map((sb) => sb.bookingId));
const allocatedIds = new Set(allocations.map((a) => a.bookingId));
// Only bookings that are on this train AND pinned to a wagon can be loaded —
// no direction/payment filter, staff load whatever is physically on the set.
const invalid = dto.bookingIds.filter(
(id) => !scheduledIds.has(id) || !allocatedIds.has(id),
);
if (invalid.length) {
throw new BadRequestException(
`Not allocated to a wagon on this schedule: ${invalid.join(', ')}`,
);
}
await this.trainScheduleBookingsRepository.updateLoadingStatusMany(
scheduleId,
dto.bookingIds,
dto.loadingStatus,
);
return this.getTrainScheduleById(scheduleId);
}
async pinWagons(scheduleId: string, dto: PinWagonsDto) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
@@ -1239,6 +1535,36 @@ export class TrainSchedulingService {
return this.getImportDjiboutiOperation(schedule.id);
}
/**
* Confirm cargo is loaded on the train from the workspace, for any direction.
* For import-from-Djibouti trains this stamps the ImportDjiboutiOperation's
* loadedOnTrainAt (the flag dispatch checks) — gatepass must already be granted.
* For every other schedule there is no departure loading gate, so this is a
* success no-op and simply returns the current detail.
*/
async confirmScheduleLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
// Import-Djibouti trains gate dispatch on the operation's loadedOnTrainAt.
if (this.isImportDjiboutiSchedule(schedule)) {
await this.confirmImportLoadedOnTrain(scheduleId, dto);
}
// Confirming loading also marks every wagon-assigned booking LOADED, so the
// per-booking loading flag and the dispatch gate agree (otherwise the
// dispatch pre-check keeps reporting these bookings as unloaded).
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
if (wagonAssignedIds.size) {
await this.trainScheduleBookingsRepository.updateLoadingStatusMany(
scheduleId,
[...wagonAssignedIds],
LoadingStatus.Loaded,
);
}
return this.getTrainScheduleById(scheduleId);
}
async departImportFromDjibouti(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
@@ -3647,9 +3973,25 @@ export class TrainSchedulingService {
private async mapScheduleDetail(
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
) {
const allocationIds = (schedule.trainSet?.wagons ?? [])
.flatMap((w) => w.allocations ?? [])
.map((a) => a.id);
const allocations = (schedule.trainSet?.wagons ?? []).flatMap(
(w) => w.allocations ?? [],
);
const allocationIds = allocations.map((a) => a.id);
const allocatedBookingIds = new Set(allocations.map((a) => a.bookingId));
// Import-from-Djibouti trains can only dispatch once loading is confirmed
// (loadedOnTrainAt on the operation). Other directions have no departure
// loading gate, so the workspace shows the confirm button as already done.
const requiresLoadingConfirmation = this.isImportDjiboutiSchedule(schedule);
let loadingConfirmed = !requiresLoadingConfirmation;
if (requiresLoadingConfirmation) {
const op = await this.dataSource
.getRepository(ImportDjiboutiOperation)
.findOne({ where: { trainScheduleId: schedule.id } });
loadingConfirmed = Boolean(op?.loadedOnTrainAt);
}
const windowCfg = await this.getWindowConfig();
const [containerItems, bulkLoads] = await Promise.all([
allocationIds.length
@@ -3682,6 +4024,8 @@ export class TrainSchedulingService {
freightType: this.resolveScheduleFreightType(schedule),
trainNumber: schedule.trainNumber ?? null,
direction: schedule.direction ?? null,
requiresLoadingConfirmation,
loadingConfirmed,
// Booking-window phase + phase deadlines drive the countdown timers in the
// operations workspace (display only — the window engine enforces them).
windowPhase: schedule.windowPhase ?? null,
@@ -3697,6 +4041,22 @@ export class TrainSchedulingService {
paymentPhaseEndsAt: schedule.paymentPhaseEndsAt
? schedule.paymentPhaseEndsAt.toISOString()
: null,
// Per-schedule booking-window rule snapshot — powers the "Booking window
// settings" editor on the ops board (prefill + save one schedule's
// override). docReview/payment are not snapshotted per schedule (only their
// sum, as reopenDelayMinutes), so the editor prefills them from live config.
windowRule: {
windowOpenHour: schedule.ruleWindowOpenHour ?? null,
windowCloseHour: schedule.ruleWindowCloseHour ?? null,
windowDurationHours:
schedule.ruleWindowDurationHours != null
? Number(schedule.ruleWindowDurationHours)
: null,
reopenDelayMinutes: schedule.ruleReopenDelayMinutes ?? null,
importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null,
docReviewMinutes: windowCfg.docReviewMinutes,
paymentWindowMinutes: windowCfg.paymentWindowMinutes,
},
route: schedule.route
? { id: schedule.route.id, name: formatRouteLabel(schedule.route) }
: null,
@@ -3790,6 +4150,11 @@ export class TrainSchedulingService {
status: sb.booking?.status ?? null,
schedulingStatus: sb.booking?.schedulingStatus ?? null,
freightType: sb.booking?.freightType ?? null,
// Loaded/unloaded is tracked on the schedule↔booking link, not the
// booking itself — staff flip it per booking in the workspace before
// dispatch. Defaults UNLOADED for links written before the column.
loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded,
wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId),
})) ?? [],
};
}

View File

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

View File

@@ -53,8 +53,9 @@ import { bookingsService } from "@/services/bookings.service";
/**
* Export customs flow, ordered per the stakeholder process:
* customer docs → RO (DJ) → declaration (ET, auto-releases) → create booking (ET)
* → payment + wagons → transport document (ET) → train to Djibouti → gate pass (DJ)
* → accept T1 (DJ) → final invoice (DJ) + customer slip + GL confirm.
* → payment + wagons → transport document / T1 (ET) → train to Djibouti
* → accept T1 (DJ, one button after arrival) → gate pass (DJ)
* → final invoice (DJ) + customer slip + GL confirm.
*/
export function computeExportActiveStep(
clearance: ClearanceViewLike,
@@ -77,8 +78,8 @@ export function computeExportActiveStep(
}
if (!isBookingMilestoneDone(bookingMilestones, "EXPORT_TRANSPORT_ISSUED")) return 5;
if (!clearance.train?.arrivedAt) return 6;
if (!clearance.gatepassGranted) return 7;
if (!clearance.t1Closed) return 8;
if (!clearance.t1Closed) return 7;
if (!clearance.gatepassGranted) return 8;
if (clearance.finalInvoice?.status !== "PAID") return 9;
return 10;
}
@@ -395,17 +396,9 @@ export function ExportClearanceStepper({
</Stack>
</Stepper.Step>
<Stepper.Step
label="Gate pass"
description="Secured on the train schedule after arrival"
icon={clearance.gatepassGranted ? <CheckCircle2 size={14} /> : <Truck size={14} />}
>
<GatepassStep clearance={clearance} />
</Stepper.Step>
<Stepper.Step
label="Accept T1"
description="GL Djibouti closes the transport document"
description="GL Djibouti closes once the train arrives"
icon={clearance.t1Closed ? <CheckCircle2 size={14} /> : <PackageCheck size={14} />}
>
<AcceptT1Step
@@ -420,6 +413,14 @@ export function ExportClearanceStepper({
/>
</Stepper.Step>
<Stepper.Step
label="Gate pass"
description="Secured on the train schedule after arrival"
icon={clearance.gatepassGranted ? <CheckCircle2 size={14} /> : <Truck size={14} />}
>
<GatepassStep clearance={clearance} />
</Stepper.Step>
<Stepper.Step
label="Final invoice & payment"
description="GL Djibouti invoices after offload; customer pays"
@@ -554,6 +555,7 @@ function AcceptT1Step({
}) {
const [loading, setLoading] = useState(false);
const files = exportTransitFilesFromWorkflow(workflowFiles);
const arrived = Boolean(clearance.train?.arrivedAt);
return (
<Stack gap="sm">
@@ -582,9 +584,9 @@ function AcceptT1Step({
pendingLabel={
!transportIssued
? "Waiting for the transport document."
: clearance.gatepassGranted
? "Gate pass granted — GL Djibouti accepts (closes) the T1."
: "Available after the gate pass is granted."
: arrived
? "Train arrived — GL Djibouti accepts (closes) the T1."
: "Available once the train arrives at Djibouti."
}
doneLabel=""
/>
@@ -592,7 +594,7 @@ function AcceptT1Step({
<Button
color="edr-green"
loading={loading}
disabled={!transportIssued || !clearance.gatepassGranted}
disabled={!transportIssued || !arrived}
leftSection={<PackageCheck size={16} />}
onClick={async () => {
setLoading(true);

View File

@@ -120,7 +120,7 @@ export function GlClearanceUploadModal({
/>
) : (
<DateInput
label="Vessel departure date (optional)"
label="Vessel arrival date (optional)"
value={vesselDate}
onChange={(v) => setVesselDate(v ? new Date(v) : null)}
size="sm"

View File

@@ -98,6 +98,7 @@ function computeImportActiveStep(
clearance: ClearanceViewLike,
bookingCreated: boolean,
bookingMilestones: MilestoneRow[],
t1Uploaded: boolean,
): number {
if (!isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")) return 0;
if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 1;
@@ -119,16 +120,17 @@ function computeImportActiveStep(
if (!isMilestoneDone(clearance.milestones, "DO_COLLECTED")) return 6;
if (!bookingCreated) return 7;
if (!clearance.gatepassGranted) return 8;
if (!clearance.t1?.closed) return 9;
if (!isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED")) return 10;
if (!t1Uploaded && !clearance.t1?.closed) return 9;
if (!clearance.t1?.closed) return 10;
if (!isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED")) return 11;
// Additional duty round is optional — resolved once skipped or paid.
const secondDutyResolved =
clearance.secondDuty?.skipped ||
clearance.secondDuty?.paid ||
isBookingMilestoneDone(bookingMilestones, "SECOND_DUTY_PAID");
if (!secondDutyResolved) return 11;
if (!clearance.importReleaseGranted) return 12;
return 13;
if (!secondDutyResolved) return 12;
if (!clearance.importReleaseGranted) return 13;
return 14;
}
function t1FilesFromWorkflow(
@@ -226,12 +228,18 @@ export function PhasedClearanceActionPanel({
// The booking that carries the post-booking steps (gate pass, risk, duty, release).
const actionBookingId =
clearance.t1?.bookingId ?? clearance.linkedBookingId ?? bookingId ?? null;
const t1Uploaded = t1FilesFromWorkflow(workflowFiles).length > 0;
const activeStep = useMemo(
() =>
isImport
? computeImportActiveStep(clearance, effectiveBookingCreated, bookingMilestones)
? computeImportActiveStep(
clearance,
effectiveBookingCreated,
bookingMilestones,
t1Uploaded,
)
: 0,
[clearance, isImport, effectiveBookingCreated, bookingMilestones],
[clearance, isImport, effectiveBookingCreated, bookingMilestones, t1Uploaded],
);
if (isImport) {
@@ -544,22 +552,45 @@ export function PhasedClearanceActionPanel({
<Stepper.Step
label="T1 transport documents"
description="GL Djibouti uploads after wagon allocation; GL Ethiopia closes on arrival"
description="GL Djibouti uploads after the gate pass is secured"
icon={
clearance.t1?.closed ? <CheckCircle2 size={14} /> : <Truck size={14} />
t1Uploaded || clearance.t1?.closed ? (
<CheckCircle2 size={14} />
) : (
<Truck size={14} />
)
}
>
<ImportT1Section
<ImportT1UploadStep
t1={clearance.t1 ?? null}
gatepassGranted={Boolean(clearance.gatepassGranted)}
workflowFiles={workflowFiles}
canDjAct={showDj && canDj}
canEtAct={showEt && canEt}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
</Stepper.Step>
<Stepper.Step
label="Close T1"
description="GL Ethiopia closes once the train arrives"
icon={
clearance.t1?.closed ? (
<CheckCircle2 size={14} />
) : (
<PackageCheck size={14} />
)
}
>
<ImportT1CloseStep
t1={clearance.t1 ?? null}
t1Uploaded={t1Uploaded}
canEtAct={showEt && canEt}
onChanged={onChanged}
/>
</Stepper.Step>
<Stepper.Step
label="Customs risk"
description="GL Ethiopia assigns Green / Yellow / Red"
@@ -645,26 +676,29 @@ export function PhasedClearanceActionPanel({
);
}
function ImportT1Section({
/**
* GL Djibouti uploads the T1 transport documents (multi-file) once the gate
* pass is secured on the train schedule; locked on departure or T1 close.
*/
function ImportT1UploadStep({
t1,
gatepassGranted,
workflowFiles = [],
canDjAct,
canEtAct,
onChanged,
onViewFile,
onDownloadFile,
}: {
t1: Freight.ClearanceT1State | null;
gatepassGranted: boolean;
workflowFiles?: Freight.ClearanceWorkflowFile[];
canDjAct: boolean;
canEtAct: boolean;
onChanged?: () => void;
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
}) {
const [files, setFiles] = useState<File[]>([]);
const [uploading, setUploading] = useState(false);
const [closing, setClosing] = useState(false);
const uploaded = t1FilesFromWorkflow(workflowFiles);
const replaceMode = uploaded.length > 0;
@@ -680,8 +714,8 @@ function ImportT1Section({
}
const departed = Boolean(t1.trainDepartedAt);
const arrived = Boolean(t1.trainArrivedAt);
const canUpload = canDjAct && t1.wagonAllocated && !departed && !t1.closed;
const canUpload =
canDjAct && t1.wagonAllocated && gatepassGranted && !departed && !t1.closed;
return (
<Stack gap="sm">
@@ -707,7 +741,7 @@ function ImportT1Section({
<StepStatus
done
pendingLabel=""
doneLabel="T1 accepted and closed by GL Ethiopia — documents are final."
doneLabel="T1 documents are final — closed by GL Ethiopia."
/>
) : !t1.wagonAllocated ? (
<StepStatus
@@ -715,6 +749,12 @@ function ImportT1Section({
pendingLabel="Waiting for operations to allocate wagons."
doneLabel=""
/>
) : !gatepassGranted ? (
<StepStatus
done={false}
pendingLabel="Waiting for the gate pass to be secured on the train schedule."
doneLabel=""
/>
) : departed ? (
<Alert color="orange" variant="light" icon={<AlertTriangle size={16} />}>
The train has departed T1 documents are locked and can no longer be changed.
@@ -725,6 +765,8 @@ function ImportT1Section({
pendingLabel="Waiting for GL Djibouti to upload T1 transport documents."
doneLabel=""
/>
) : uploaded.length > 0 && !canUpload ? (
<StepStatus done pendingLabel="" doneLabel="T1 documents uploaded." />
) : null}
{canUpload ? (
@@ -771,40 +813,103 @@ function ImportT1Section({
</Button>
</>
) : null}
</Stack>
);
}
{canEtAct && !t1.closed ? (
arrived ? (
<Stack gap="xs">
<Text size="sm" c="dimmed">
The train has arrived review the T1 documents and close (accept) them.
</Text>
<Button
color="edr-green"
loading={closing}
disabled={uploaded.length === 0}
leftSection={<PackageCheck size={16} />}
onClick={async () => {
setClosing(true);
try {
await contractsService.closeT1(t1.bookingId);
toast.success("T1 closed");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setClosing(false);
}
}}
>
Accept &amp; close T1
</Button>
</Stack>
) : departed ? (
<Alert color="blue" variant="light" icon={<Clock size={16} />}>
Train en route T1 can be closed once it arrives in Ethiopia.
</Alert>
) : null
) : null}
/**
* GL Ethiopia closes (accepts) the T1 set with one click once the train has
* arrived. Separate step from the GL Djibouti upload.
*/
function ImportT1CloseStep({
t1,
t1Uploaded,
canEtAct,
onChanged,
}: {
t1: Freight.ClearanceT1State | null;
t1Uploaded: boolean;
canEtAct: boolean;
onChanged?: () => void;
}) {
const [closing, setClosing] = useState(false);
if (!t1) {
return (
<StepStatus
done={false}
pendingLabel="Available once the shipment booking is created."
doneLabel=""
/>
);
}
if (t1.closed) {
return (
<StepStatus
done
pendingLabel=""
doneLabel={`T1 accepted and closed by GL Ethiopia${
t1.closedAt ? ` · ${new Date(t1.closedAt).toLocaleString()}` : ""
}`}
/>
);
}
if (!t1Uploaded) {
return (
<StepStatus
done={false}
pendingLabel="Waiting for GL Djibouti to upload T1 transport documents."
doneLabel=""
/>
);
}
if (!t1.trainArrivedAt) {
return (
<Alert color="blue" variant="light" icon={<Clock size={16} />}>
{t1.trainDepartedAt
? "Train en route — T1 can be closed once it arrives in Ethiopia."
: "T1 can be closed once the train arrives in Ethiopia."}
</Alert>
);
}
if (!canEtAct) {
return (
<StepStatus
done={false}
pendingLabel="Train arrived — waiting for GL Ethiopia to close the T1."
doneLabel=""
/>
);
}
return (
<Stack gap="xs">
<Text size="sm" c="dimmed">
The train has arrived review the T1 documents and close (accept) them.
</Text>
<Button
color="edr-green"
loading={closing}
leftSection={<PackageCheck size={16} />}
onClick={async () => {
setClosing(true);
try {
await contractsService.closeT1(t1.bookingId);
toast.success("T1 closed");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setClosing(false);
}
}}
>
Accept &amp; close T1
</Button>
</Stack>
);
}

View File

@@ -0,0 +1,404 @@
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Badge,
Box,
Button,
Divider,
Group,
Loader,
Modal,
NumberInput,
Select,
Stack,
Switch,
Text,
ThemeIcon,
} from "@mantine/core";
import { isAxiosError } from "axios";
import { Clock, Info, Moon, Sun } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import DurationField from "@/components/trainScheduling/DurationField";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type { UpdateScheduleWindowRulePayload } from "@/types/trainScheduling";
/** Fallbacks matching the API's global-rules defaults (used when a field is null). */
const DEFAULTS = {
windowOpenHour: 8,
windowCloseHour: 17,
windowDurationHours: 3,
docReviewMinutes: 30,
paymentWindowMinutes: 60,
importWindowLeadDays: 3,
};
/** 12-hour label for an EAT hour 023, e.g. 8 → "8:00 AM", 17 → "5:00 PM". */
function hourLabel(hour: number): string {
const period = hour < 12 ? "AM" : "PM";
const h12 = hour % 12 === 0 ? 12 : hour % 12;
return `${h12}:00 ${period}`;
}
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, h) => ({
value: String(h),
label: `${hourLabel(h)} · ${String(h).padStart(2, "0")}:00`,
}));
interface FormState {
windowOpenHour: number;
windowCloseHour: number;
windowDurationHours: number | "";
docReviewMinutes: number | "";
paymentWindowMinutes: number | "";
importWindowLeadDays: number | "";
}
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;
}
export interface BookingWindowSettingsModalProps {
scheduleId: string | null;
opened: boolean;
onClose: () => void;
/** Called after a successful save (e.g. to refetch a list). */
onSaved?: () => void;
}
/**
* Per-schedule booking-window settings editor. Prefills from the schedule's own
* rule snapshot, lets staff tune the daily desk hours / durations for just that
* train, and saves an override. Only editable before the window opens.
*/
export default function BookingWindowSettingsModal({
scheduleId,
opened,
onClose,
onSaved,
}: BookingWindowSettingsModalProps) {
const { toast } = useToast();
const detailQuery = useQuery({
...api.trainScheduling.scheduleDetail.queryOptions({
input: { id: scheduleId ?? "" },
}),
enabled: opened && Boolean(scheduleId),
});
const schedule = detailQuery.data;
const save = useMutation(
api.trainScheduling.updateScheduleWindowRule.mutationOptions(),
);
const [form, setForm] = useState<FormState | null>(null);
// Seed the form from the schedule's snapshot once it loads (or when reopened).
useEffect(() => {
if (!opened || !schedule) return;
const r = schedule.windowRule;
setForm({
windowOpenHour: r?.windowOpenHour ?? DEFAULTS.windowOpenHour,
windowCloseHour: r?.windowCloseHour ?? DEFAULTS.windowCloseHour,
windowDurationHours: r?.windowDurationHours ?? DEFAULTS.windowDurationHours,
docReviewMinutes: r?.docReviewMinutes ?? DEFAULTS.docReviewMinutes,
paymentWindowMinutes:
r?.paymentWindowMinutes ?? DEFAULTS.paymentWindowMinutes,
importWindowLeadDays:
r?.importWindowLeadDays ?? DEFAULTS.importWindowLeadDays,
});
}, [opened, schedule]);
const isExport = schedule?.direction === "EXPORT";
const canEdit = schedule?.windowPhase === "PRE_WINDOW";
const is24h =
form != null && form.windowOpenHour === form.windowCloseHour;
// Close < open is a valid OVERNIGHT desk (e.g. 08:00 → 07:00 next morning),
// not an error — the engine wraps it across midnight.
const isOvernight =
form != null && form.windowCloseHour < form.windowOpenHour;
const reopenSummary = useMemo(() => {
if (!form) return "";
const doc = Number(form.docReviewMinutes) || 0;
const pay = Number(form.paymentWindowMinutes) || 0;
const total = doc + pay;
const h = Math.floor(total / 60);
const m = total % 60;
const parts = [h ? `${h}h` : "", m ? `${m}m` : ""].filter(Boolean);
return parts.length ? parts.join(" ") : "0m";
}, [form]);
const handleSave = async () => {
if (!scheduleId || !form) return;
// Numeric fields must hold real values.
const duration = Number(form.windowDurationHours);
const doc = Number(form.docReviewMinutes);
const pay = Number(form.paymentWindowMinutes);
const lead = Number(form.importWindowLeadDays);
if (
form.windowDurationHours === "" ||
form.docReviewMinutes === "" ||
form.paymentWindowMinutes === "" ||
form.importWindowLeadDays === "" ||
!Number.isFinite(duration) ||
!Number.isFinite(doc) ||
!Number.isFinite(pay) ||
!Number.isFinite(lead)
) {
toast({
title: "Fill every field before saving",
variant: "destructive",
});
return;
}
const payload: UpdateScheduleWindowRulePayload = {
windowOpenHour: form.windowOpenHour,
windowCloseHour: form.windowCloseHour,
windowDurationHours: duration,
docReviewMinutes: doc,
paymentWindowMinutes: pay,
importWindowLeadDays: lead,
};
try {
await save.mutateAsync({ id: scheduleId, payload });
toast({ title: "Booking window settings updated" });
onSaved?.();
onClose();
} catch (err) {
toast({
title: "Update failed",
description: parseError(err, "Could not update booking window"),
variant: "destructive",
});
}
};
return (
<Modal
opened={opened}
onClose={onClose}
centered
radius="lg"
size="lg"
title={
<Group gap="sm">
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
<Clock size={18} />
</ThemeIcon>
<Box>
<Text fw={600} lh={1.2}>
Booking window settings
</Text>
<Text size="xs" c="dimmed" lh={1.2}>
{schedule?.route?.name ?? "This schedule only"}
</Text>
</Box>
</Group>
}
>
{detailQuery.isLoading || !form ? (
<Group justify="center" py="xl">
<Loader size="sm" />
</Group>
) : !canEdit ? (
<Alert
variant="light"
color="yellow"
icon={<Info size={16} />}
title="Window already open"
>
Booking window settings can only be changed before the window opens.
This schedule is currently{" "}
<b>{String(schedule?.windowPhase ?? "not window-managed")}</b>.
</Alert>
) : (
<Stack gap="lg">
{isExport ? (
<Alert variant="light" color="blue" icon={<Info size={16} />}>
Export schedules use a single FCFS lead window the daily desk
hours below don't apply, only the lead time does.
</Alert>
) : null}
{/* ── Daily desk hours ─────────────────────────────────────────── */}
<Box>
<Group justify="space-between" align="center" mb={6}>
<Text size="sm" fw={600}>
Daily desk hours (EAT)
</Text>
{is24h ? (
<Badge
variant="light"
color="grape"
leftSection={<Moon size={12} />}
>
24-hour desk
</Badge>
) : (
<Badge
variant="light"
color="edr-green"
leftSection={<Sun size={12} />}
>
{hourLabel(form.windowOpenHour)} {hourLabel(form.windowCloseHour)}
</Badge>
)}
</Group>
<Group grow align="flex-start">
<Select
label="Opens"
data={HOUR_OPTIONS}
value={String(form.windowOpenHour)}
onChange={(v) =>
v != null &&
setForm((f) => f && { ...f, windowOpenHour: Number(v) })
}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
disabled={isExport}
/>
<Select
label="Closes"
data={HOUR_OPTIONS}
value={String(form.windowCloseHour)}
onChange={(v) =>
v != null &&
setForm((f) => f && { ...f, windowCloseHour: Number(v) })
}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
disabled={isExport}
/>
</Group>
{isOvernight && !is24h ? (
<Text size="xs" c="dimmed" mt={4}>
Overnight desk opens {form.windowOpenHour}:00 and runs past
midnight, closing {form.windowCloseHour}:00 the next morning.
</Text>
) : null}
<Switch
mt="sm"
size="sm"
color="grape"
label="Run 24 hours a day (never pause overnight)"
checked={is24h}
disabled={isExport}
onChange={(e) => {
const checked = e.currentTarget.checked;
setForm((f) => {
if (!f) return f;
// On → close == open (24h desk). Off → restore a normal ~9h
// day, always kept ≥ open hour so it never lands invalid.
const close = checked
? f.windowOpenHour
: Math.min(23, f.windowOpenHour + 9);
return { ...f, windowCloseHour: close };
});
}}
/>
{!isExport ? (
<Text size="xs" c="dimmed" mt={6}>
A not-yet-full train pauses at the close hour and resumes the next
morning at the open hour, every day until it fills or departs.
</Text>
) : null}
</Box>
<Divider />
{/* ── Cycle timing ─────────────────────────────────────────────── */}
<Box>
<Text size="sm" fw={600} mb={6}>
Cycle timing
</Text>
<Stack gap="sm">
<DurationField
label="Window duration"
description="How long each booking cycle stays open before it closes for review"
value={form.windowDurationHours}
nativeUnit="hours"
onChange={(v) =>
setForm((f) => f && { ...f, windowDurationHours: v })
}
min={0.0166}
disabled={isExport}
/>
<Group grow align="flex-start">
<DurationField
label="Document review"
description="Staff time to accept documents after the window closes"
value={form.docReviewMinutes}
nativeUnit="minutes"
onChange={(v) =>
setForm((f) => f && { ...f, docReviewMinutes: v })
}
min={0}
disabled={isExport}
/>
<DurationField
label="Payment window"
description="Time a selected customer has to pay"
value={form.paymentWindowMinutes}
nativeUnit="minutes"
onChange={(v) =>
setForm((f) => f && { ...f, paymentWindowMinutes: v })
}
min={1}
disabled={isExport}
/>
</Group>
{!isExport ? (
<Text size="xs" c="dimmed">
Reopen gap after each cycle = document review + payment ={" "}
<b>{reopenSummary}</b>.
</Text>
) : null}
</Stack>
</Box>
<Divider />
{/* ── Lead time ────────────────────────────────────────────────── */}
<NumberInput
label={isExport ? "Booking lead (days)" : "Window lead (days)"}
description={
isExport
? "How many days before departure export booking opens"
: "How many days before departure the booking window starts"
}
value={form.importWindowLeadDays}
onChange={(v) =>
setForm(
(f) =>
f && {
...f,
importWindowLeadDays: v === "" ? "" : Number(v),
},
)
}
min={0}
clampBehavior="none"
allowDecimal={false}
/>
<Group justify="flex-end" mt="xs">
<Button variant="default" onClick={onClose} disabled={save.isPending}>
Cancel
</Button>
<Button onClick={handleSave} loading={save.isPending}>
Save settings
</Button>
</Group>
</Stack>
)}
</Modal>
);
}

View File

@@ -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 (
<Modal
opened={opened}
onClose={onClose}
centered
radius="lg"
title={
<Group gap="sm">
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
<CalendarClock size={18} />
</ThemeIcon>
<Box>
<Text fw={600} lh={1.2}>
Edit departure date
</Text>
<Text size="xs" c="dimmed" lh={1.2}>
{routeName ?? "This schedule only"}
</Text>
</Box>
</Group>
}
>
<Stack gap="md">
<Alert variant="light" color="blue" icon={<Info size={16} />}>
The date can only be changed before the booking window opens, and must
still leave room for the booking lead window before departure.
</Alert>
<TextInput
label="Departure date"
type="datetime-local"
value={value}
onChange={(e) => setValue(e.currentTarget.value)}
/>
<Group justify="flex-end" mt="xs">
<Button variant="default" onClick={onClose} disabled={save.isPending}>
Cancel
</Button>
<Button onClick={handleSave} loading={save.isPending}>
Save
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -23,7 +23,8 @@ import {
CheckCircle2,
Inbox,
PackageCheck,
Repeat,
PackageX,
// Repeat, // used by the hidden Move (reassign) button
Train,
Weight,
X,
@@ -152,6 +153,12 @@ export function ScheduleWorkspacePanel({
// ── Mutations (reuse the existing endpoints) ───────────────────────────────
const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions());
const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
const setLoading = useMutation(
api.trainScheduling.setLoadingStatus.mutationOptions(),
);
const confirmLoading = useMutation(
api.trainScheduling.confirmLoading.mutationOptions(),
);
const moveSchedule = useMutation(
api.trainScheduling.moveBookingSchedule.mutationOptions(),
);
@@ -237,6 +244,50 @@ export function ScheduleWorkspacePanel({
);
};
const toggleLoaded = (
bookingId: string,
ref: string,
next: "LOADED" | "UNLOADED",
) => {
setLoading
.mutateAsync({ id: schedule.id, bookingIds: [bookingId], loadingStatus: next })
.then(() => {
toast({
title:
next === "LOADED"
? `${ref} marked loaded`
: `${ref} marked unloaded`,
});
onChanged();
})
.catch((error) =>
toast({
title: "Could not update loading status",
description: apiErrorMessage(error, "Please try again."),
variant: "destructive",
}),
);
};
const doConfirmLoading = () => {
confirmLoading
.mutateAsync({ id: schedule.id })
.then(() => {
toast({ title: "Loading confirmed", description: "The train is cleared to dispatch." });
onChanged();
})
.catch((error) =>
toast({
title: "Could not confirm loading",
description: apiErrorMessage(
error,
"Grant the Djibouti gatepass first, then confirm loading.",
),
variant: "destructive",
}),
);
};
const doMove = () => {
if (!moveBookingId || !moveTarget) return;
moveSchedule
@@ -268,7 +319,7 @@ export function ScheduleWorkspacePanel({
<div>
<Text fw={700}>Allocation workspace</Text>
<Text size="xs" c="dimmed">
Manually add ready-to-pay bookings, remove, or reassign them
Manually add paid, unassigned bookings, remove, or reassign them
</Text>
</div>
</Group>
@@ -347,17 +398,65 @@ export function ScheduleWorkspacePanel({
</Text>
) : null}
{/* Loading confirmation — required before dispatch for import-Djibouti
trains; shown for every direction so staff have one place to confirm. */}
{canManage ? (
<Group
gap={10}
p="sm"
wrap="nowrap"
align="center"
justify="space-between"
style={{
borderRadius: 10,
background: schedule.loadingConfirmed
? "var(--mantine-color-edr-green-0)"
: "var(--mantine-color-yellow-0)",
border: `1px solid ${
schedule.loadingConfirmed
? "var(--mantine-color-edr-green-2)"
: "var(--mantine-color-yellow-3)"
}`,
}}
>
<Group gap={8} wrap="nowrap" align="center" style={{ minWidth: 0 }}>
{schedule.loadingConfirmed ? (
<CheckCircle2 size={18} color="var(--mantine-color-edr-green-7)" />
) : (
<PackageCheck size={18} color="#B7791F" />
)}
<Text size="sm" fw={600}>
{schedule.loadingConfirmed
? "Loading confirmed — cleared to dispatch"
: "Confirm loading before dispatching this train"}
</Text>
</Group>
{!schedule.loadingConfirmed ? (
<Button
size="compact-sm"
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={14} />}
loading={confirmLoading.isPending}
onClick={doConfirmLoading}
>
Confirm loading
</Button>
) : null}
</Group>
) : null}
{/* Two-panel board */}
<Group align="stretch" gap="lg" grow wrap="wrap">
{/* Pool */}
<PanelColumn
title="Ready to pay"
hint="Accepted · this route & day"
title="Paid · unassigned"
hint="Paid · this route & day · not on a train"
count={pool.length}
accent="#F2A516"
loading={poolQuery.isLoading}
emptyIcon={Inbox}
emptyText="No ready-to-pay bookings waiting for this train."
emptyText="No paid, unassigned bookings waiting for this train."
>
{pool.map((b) => (
<BookingCard
@@ -402,9 +501,53 @@ export function ScheduleWorkspacePanel({
customer={b.customer}
weightTons={b.weightTons}
status={b.status}
loadingStatus={b.wagonAssigned ? b.loadingStatus ?? "UNLOADED" : undefined}
right={
canManage ? (
<Group gap={6} wrap="nowrap" justify="flex-end">
{b.wagonAssigned ? (
<Tooltip
label={
(b.loadingStatus ?? "UNLOADED") === "LOADED"
? "Mark cargo unloaded from wagon"
: "Mark cargo loaded onto wagon"
}
withArrow
>
<Button
size="compact-sm"
variant={
(b.loadingStatus ?? "UNLOADED") === "LOADED"
? "light"
: "filled"
}
color="edr-green"
radius="md"
leftSection={
(b.loadingStatus ?? "UNLOADED") === "LOADED" ? (
<PackageX size={13} />
) : (
<PackageCheck size={13} />
)
}
loading={setLoading.isPending}
onClick={() =>
toggleLoaded(
b.id,
b.reference ?? b.id.slice(0, 8),
(b.loadingStatus ?? "UNLOADED") === "LOADED"
? "UNLOADED"
: "LOADED",
)
}
>
{(b.loadingStatus ?? "UNLOADED") === "LOADED"
? "Unload"
: "Load"}
</Button>
</Tooltip>
) : null}
{/* Reassign-to-another-train — hidden for now.
<Tooltip label="Reassign to another train" withArrow>
<Button
size="compact-sm"
@@ -420,6 +563,7 @@ export function ScheduleWorkspacePanel({
Move
</Button>
</Tooltip>
*/}
<Tooltip label="Remove from this train" withArrow>
<Button
size="compact-sm"
@@ -565,12 +709,14 @@ function BookingCard({
customer,
weightTons,
status,
loadingStatus,
right,
}: {
reference: string;
customer?: string | null;
weightTons?: number | null;
status?: string | null;
loadingStatus?: "LOADED" | "UNLOADED";
right?: React.ReactNode;
}) {
return (
@@ -596,6 +742,16 @@ function BookingCard({
{reference}
</Text>
{status ? <BookingStatusBadge status={status} /> : null}
{loadingStatus ? (
<Badge
size="sm"
radius="sm"
variant={loadingStatus === "LOADED" ? "filled" : "light"}
color={loadingStatus === "LOADED" ? "edr-green" : "gray"}
>
{loadingStatus === "LOADED" ? "Loaded" : "Unloaded"}
</Badge>
) : null}
</Group>
<Group gap={10} align="center" wrap="nowrap">
<Text size="xs" c="dimmed" truncate>

View File

@@ -281,6 +281,10 @@ export const URL_CONSTANTS = {
`/train-scheduling/schedules/${id}/assign-unassigned-booking`,
BOOKING_WINDOW: (id: string) =>
`/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) =>
@@ -324,6 +328,10 @@ export const URL_CONSTANTS = {
`/train-scheduling/schedules/${id}/import-loading-bookings`,
IMPORT_LOADING_STATUS: (id: string) =>
`/train-scheduling/schedules/${id}/import-loading-status`,
LOADING_STATUS: (id: string) =>
`/train-scheduling/schedules/${id}/loading-status`,
CONFIRM_LOADING: (id: string) =>
`/train-scheduling/schedules/${id}/confirm-loading`,
IMPORT_DJIBOUTI: (id: string) =>
`/train-scheduling/schedules/${id}/import-djibouti`,
IMPORT_DJIBOUTI_DOCUMENTS: (id: string) =>

View File

@@ -1,25 +1,28 @@
import {
Alert,
Badge,
Box,
Button,
Checkbox,
Group,
List,
Loader,
Modal,
Paper,
RingProgress,
Stack,
Tabs,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
} from "@mantine/core";
import { isAxiosError } from "axios";
import {
AlertTriangle,
ArrowLeft,
CalendarClock,
CheckCircle2,
Clock,
Container as ContainerIcon,
Eye,
FileText,
@@ -45,9 +48,10 @@ import {
} from "@/components/trainScheduling/containerPlacement.util";
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
// import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
import { ScheduleWorkspacePanel } from "@/components/trainScheduling/ScheduleWorkspacePanel";
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
@@ -95,10 +99,12 @@ export default function TrainScheduleV2DetailPage() {
const [previewResult, setPreviewResult] = useState<TrainSchedulePreviewResponse | null>(null);
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
const [windowSettingsOpen, setWindowSettingsOpen] = useState(false);
const [gatepassSecuredAt, setGatepassSecuredAt] = useState("");
const [gatepassReference, setGatepassReference] = useState("");
const [gatepassFileUrl, setGatepassFileUrl] = useState("");
const [gatepassNotes, setGatepassNotes] = useState("");
const [dispatchConfirmOpen, setDispatchConfirmOpen] = useState(false);
const autoPreviewedRef = useRef(false);
const detailQuery = useQuery(
@@ -125,6 +131,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, {
@@ -146,12 +153,14 @@ export default function TrainScheduleV2DetailPage() {
},
});
const importLoadingQuery = useQuery(
api.trainScheduling.importLoadingBookings.queryOptions({
input: { id: scheduleId ?? "" },
enabled: Boolean(scheduleId && schedule?.direction === "IMPORT"),
}),
);
// Superseded by the Workspace tab Load/Unload toggle — see commented
// "Import loading confirmation" card below.
// const importLoadingQuery = useQuery(
// api.trainScheduling.importLoadingBookings.queryOptions({
// input: { id: scheduleId ?? "" },
// enabled: Boolean(scheduleId && schedule?.direction === "IMPORT"),
// }),
// );
const eligibleFilters = useMemo(
() =>
@@ -358,6 +367,22 @@ export default function TrainScheduleV2DetailPage() {
const canEditBookings = ["DRAFT", "SCHEDULED"].includes(schedule.status);
const canFinalize = schedule.status === "DRAFT" && (schedule.bookings?.length ?? 0) > 0;
const canDispatch = schedule.status === "SCHEDULED";
// Dispatch readiness: bookings with no wagon, and wagon-loaded bookings whose
// cargo staff never marked loaded. Both are warnings, not blockers — staff can
// still dispatch after confirming.
const dispatchBookings = schedule.bookings ?? [];
const unassignedCount = dispatchBookings.filter((b) => !b.wagonAssigned).length;
const unloadedCount = dispatchBookings.filter(
(b) => b.wagonAssigned && (b.loadingStatus ?? "UNLOADED") !== "LOADED",
).length;
// Import-Djibouti trains are HARD-blocked from dispatch until loading is
// confirmed in the workspace — surface it as a blocker, not just a warning.
const loadingBlocksDispatch =
schedule.requiresLoadingConfirmation === true &&
schedule.loadingConfirmed !== true;
const hasDispatchWarnings = unassignedCount > 0 || unloadedCount > 0;
const finalizeStep = hasContainerStep ? 3 : 2;
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
const canPrintMarshalling =
@@ -396,6 +421,24 @@ export default function TrainScheduleV2DetailPage() {
}
};
const runDispatch = async () => {
setDispatchConfirmOpen(false);
try {
await dispatch.mutateAsync(scheduleId);
await openMarshallingDocument({
title: "Train dispatched",
successDescription: "Marshalling document generated for the dispatched train.",
errorTitle: "Train dispatched, but document could not open",
});
} catch (err) {
toast({
title: "Dispatch failed",
description: parseError(err, "Could not dispatch"),
variant: "destructive",
});
}
};
const handleAssign = async () => {
if (!allSelectedIds.length) return;
@@ -777,22 +820,7 @@ export default function TrainScheduleV2DetailPage() {
radius="md"
leftSection={<Send size={18} />}
loading={dispatch.isPending}
onClick={async () => {
try {
await dispatch.mutateAsync(scheduleId);
await openMarshallingDocument({
title: "Train dispatched",
successDescription: "Marshalling document generated for the dispatched train.",
errorTitle: "Train dispatched, but document could not open",
});
} catch (err) {
toast({
title: "Dispatch failed",
description: parseError(err, "Could not dispatch"),
variant: "destructive",
});
}
}}
onClick={() => setDispatchConfirmOpen(true)}
>
Dispatch train
</Button>
@@ -886,6 +914,18 @@ export default function TrainScheduleV2DetailPage() {
Track train
</Button>
) : null}
{schedule.windowPhase === "PRE_WINDOW" ? (
<Button
variant="light"
color="edr-green"
radius="lg"
size="sm"
leftSection={<Clock size={16} />}
onClick={() => setWindowSettingsOpen(true)}
>
Window settings
</Button>
) : null}
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
<Button
variant="default"
@@ -896,6 +936,31 @@ export default function TrainScheduleV2DetailPage() {
Reschedule train
</Button>
) : null}
{gatepassApplies ? (
gatepassSecured ? (
<Button
variant="light"
color="edr-green"
radius="lg"
size="sm"
leftSection={<CheckCircle2 size={16} />}
disabled
>
Gate pass secured
</Button>
) : (
<Button
color="edr-green"
radius="lg"
size="sm"
leftSection={<FileText size={16} />}
loading={secureGatepass.isPending}
onClick={() => secureGatepass.mutate()}
>
Secure gate pass
</Button>
)
) : null}
</Group>
</Group>
@@ -963,6 +1028,9 @@ export default function TrainScheduleV2DetailPage() {
]}
/>
{/* Import loading confirmation — superseded by the per-booking Load/Unload
toggle in the Workspace tab (works for all directions). Kept commented
in case the import-only confirmation flow is needed again.
{schedule?.direction === "IMPORT" ? (
<Paper radius="xl" p="lg" withBorder>
<Stack gap="md">
@@ -979,83 +1047,7 @@ export default function TrainScheduleV2DetailPage() {
</Stack>
</Paper>
) : null}
{gatepassApplies ? (
<Paper radius="xl" p="lg" withBorder>
<Stack gap="md">
<Group justify="space-between" align="flex-start" wrap="wrap">
<Group gap="md" align="flex-start" wrap="nowrap">
<ThemeIcon
size={44}
radius="md"
variant="light"
color={gatepassQuery.data?.gatepassStatus === "SECURED" ? "edr-green" : "orange"}
>
<FileText size={22} />
</ThemeIcon>
<Stack gap={4}>
<Group gap="sm">
<Title order={4} fw={700}>
Djibouti Port gate pass
</Title>
<Badge
color={gatepassQuery.data?.gatepassStatus === "SECURED" ? "green" : "orange"}
variant="light"
>
{gatepassQuery.data?.gatepassStatus ?? "NOT_SECURED"}
</Badge>
</Group>
<Text size="sm" c="dimmed">
{schedule.direction === "IMPORT"
? "Secure before dispatch from Djibouti."
: "Secure after dispatch before Djibouti Port entry / unloading."}
</Text>
</Stack>
</Group>
{gatepassQuery.isLoading ? <Loader size="sm" /> : null}
</Group>
<Group align="flex-end" grow>
<TextInput
label="Secured date"
type="datetime-local"
value={gatepassSecuredAt}
onChange={(event) => setGatepassSecuredAt(event.currentTarget.value)}
/>
<TextInput
label="Document reference"
placeholder="Optional"
value={gatepassReference}
onChange={(event) => setGatepassReference(event.currentTarget.value)}
/>
<TextInput
label="Document URL"
placeholder="Optional upload/link"
value={gatepassFileUrl}
onChange={(event) => setGatepassFileUrl(event.currentTarget.value)}
/>
</Group>
<Textarea
label="Notes"
placeholder="Optional"
autosize
minRows={2}
value={gatepassNotes}
onChange={(event) => setGatepassNotes(event.currentTarget.value)}
/>
<Group justify="flex-end">
<Button
color="edr-green"
leftSection={<CheckCircle2 size={16} />}
loading={secureGatepass.isPending}
onClick={() => secureGatepass.mutate()}
>
Save as Secured
</Button>
</Group>
</Stack>
</Paper>
) : null}
*/}
<Tabs defaultValue="workflow" radius="md" color="edr-green" keepMounted={false}>
<Tabs.List mb="md">
@@ -1126,7 +1118,7 @@ export default function TrainScheduleV2DetailPage() {
</Stack>
</Paper>
<ScheduleBatchPanel schedule={schedule} />
{/* <ScheduleBatchPanel schedule={schedule} /> */}
</Stack>
</Tabs.Panel>
@@ -1150,6 +1142,109 @@ export default function TrainScheduleV2DetailPage() {
onComplete={() => void detailQuery.refetch()}
/>
) : null}
<BookingWindowSettingsModal
scheduleId={scheduleId ?? null}
opened={windowSettingsOpen}
onClose={() => setWindowSettingsOpen(false)}
onSaved={() => void detailQuery.refetch()}
/>
<Modal
opened={dispatchConfirmOpen}
onClose={() => setDispatchConfirmOpen(false)}
centered
radius="lg"
title={
<Group gap={8}>
<Send size={18} />
<Text fw={700}>Dispatch this train?</Text>
</Group>
}
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Dispatch locks the composition and begins rail movement. This cannot be
undone.
</Text>
{loadingBlocksDispatch ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertTriangle size={18} />}
title="Loading not confirmed"
>
This import train cannot depart until loading is confirmed. Use{" "}
<Text span fw={700}>
Confirm loading
</Text>{" "}
in the Workspace tab first.
</Alert>
) : null}
{hasDispatchWarnings ? (
<Alert
color="orange"
variant="light"
radius="md"
icon={<AlertTriangle size={18} />}
title="Some bookings are not fully ready"
>
<List size="sm" spacing={4}>
{unassignedCount > 0 ? (
<List.Item>
<Text span fw={700}>
{unassignedCount}
</Text>{" "}
booking{unassignedCount === 1 ? "" : "s"} not assigned to a wagon
</List.Item>
) : null}
{unloadedCount > 0 ? (
<List.Item>
<Text span fw={700}>
{unloadedCount}
</Text>{" "}
wagon-assigned booking{unloadedCount === 1 ? "" : "s"} still marked
unloaded
</List.Item>
) : null}
</List>
<Text size="xs" c="dimmed" mt={6}>
You can still dispatch confirm to proceed.
</Text>
</Alert>
) : (
<Alert
color="edr-green"
variant="light"
radius="md"
icon={<CheckCircle2 size={18} />}
>
All bookings are assigned to a wagon and marked loaded.
</Alert>
)}
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={() => setDispatchConfirmOpen(false)}
>
Cancel
</Button>
<Button
color="edr-green"
leftSection={<Send size={16} />}
loading={dispatch.isPending}
disabled={loadingBlocksDispatch}
onClick={() => void runDispatch()}
>
{hasDispatchWarnings ? "Dispatch anyway" : "Dispatch train"}
</Button>
</Group>
</Stack>
</Modal>
</PageContainer>
);
}

View File

@@ -20,9 +20,11 @@ import {
ArrowRight,
Ban,
CalendarClock,
Clock,
Eye,
MoreHorizontal,
Navigation,
Pencil,
Send,
Train,
Weight,
@@ -36,6 +38,8 @@ import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
import EditScheduleDateModal from "@/components/trainScheduling/EditScheduleDateModal";
import {
locomotiveOption,
showScheduleWarnings,
@@ -86,6 +90,9 @@ export default function TrainScheduleV2ListPage() {
const [statusFilter, setStatusFilter] = useState("ALL");
const [freightFilter, setFreightFilter] = useState("ALL");
const [createOpen, setCreateOpen] = useState(false);
const [windowSettingsId, setWindowSettingsId] = useState<string | null>(null);
const [editDateSchedule, setEditDateSchedule] =
useState<TrainScheduleListItem | null>(null);
const [routeId, setRouteId] = useState("");
const [scheduleDate, setScheduleDate] = useState("");
const [locomotiveIds, setLocomotiveIds] = useState<string[]>([]);
@@ -315,6 +322,22 @@ export default function TrainScheduleV2ListPage() {
Track
</Menu.Item>
) : null}
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
<Menu.Item
leftSection={<Pencil size={15} />}
onClick={() => setEditDateSchedule(schedule)}
>
Edit departure date
</Menu.Item>
) : null}
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
<Menu.Item
leftSection={<Clock size={15} />}
onClick={() => setWindowSettingsId(schedule.id)}
>
Booking window settings
</Menu.Item>
) : null}
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
<Menu.Item
color="red"
@@ -583,6 +606,22 @@ export default function TrainScheduleV2ListPage() {
</Group>
</Stack>
</Modal>
<BookingWindowSettingsModal
scheduleId={windowSettingsId}
opened={windowSettingsId != null}
onClose={() => setWindowSettingsId(null)}
onSaved={() => void schedulesQuery.refetch()}
/>
<EditScheduleDateModal
scheduleId={editDateSchedule?.id ?? null}
currentDate={editDateSchedule?.scheduleDate ?? null}
routeName={editDateSchedule?.routeName ?? null}
opened={editDateSchedule != null}
onClose={() => setEditDateSchedule(null)}
onSaved={() => void schedulesQuery.refetch()}
/>
</PageContainer>
);
}

View File

@@ -58,6 +58,7 @@ export default function TrainSchedulingGlobalRulesPage() {
"importWindowLeadDays",
"exportBookingLeadHours",
"windowOpenHour",
"windowCloseHour",
"windowDurationHours",
"docReviewMinutes",
"paymentWindowMinutes",
@@ -196,7 +197,7 @@ export default function TrainSchedulingGlobalRulesPage() {
/>
<NumberInput
label="Window open hour (EAT)"
description="Local hour the import window opens on its booking day (e.g. 8 = 08:00)"
description="Local hour the booking desk opens each day (e.g. 8 = 08:00)"
value={form.windowOpenHour ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, windowOpenHour: value }))
@@ -207,6 +208,19 @@ export default function TrainSchedulingGlobalRulesPage() {
max={23}
disabled={loading}
/>
<NumberInput
label="Window close hour (EAT)"
description="Local hour the booking desk shuts each day; a not-yet-full window resumes next morning at the open hour. Set equal to the open hour for a 24-hour desk."
value={form.windowCloseHour ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, windowCloseHour: value }))
}
clampBehavior="none"
allowDecimal
min={0}
max={23}
disabled={loading}
/>
<DurationField
label="Window duration"
description="How long the import booking window stays open"

View File

@@ -58,6 +58,7 @@ import type {
TrainScheduleDetail,
TrainScheduleFilters,
TrainScheduleListItem,
UpdateScheduleWindowRulePayload,
TrainSchedulePreviewPayload,
TrainSchedulePreviewResponse,
TrainTrackResponse,
@@ -438,6 +439,30 @@ export const api = {
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
updateScheduleWindowRule: endpoint<
{ id: string; payload: UpdateScheduleWindowRulePayload },
TrainScheduleDetail
>(
"train-scheduling",
"update-schedule-window-rule",
({ id, payload }) =>
trainSchedulingService.updateScheduleWindowRule(id, payload),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
updateScheduleDate: endpoint<
{ id: string; scheduleDate: string },
TrainScheduleDetail
>(
"train-scheduling",
"update-schedule-date",
({ id, scheduleDate }) =>
trainSchedulingService.updateScheduleDate(id, scheduleDate),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
markBookingPaid: endpoint<string, void>(
"train-scheduling",
"mark-booking-paid",
@@ -521,6 +546,26 @@ export const api = {
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
setLoadingStatus: endpoint<
{ id: string; bookingIds: string[]; loadingStatus: LoadingStatus },
TrainScheduleDetail
>(
"train-scheduling",
"set-loading-status",
({ id, bookingIds, loadingStatus }) =>
trainSchedulingService.setLoadingStatus(id, { bookingIds, loadingStatus }),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
confirmLoading: endpoint<{ id: string }, TrainScheduleDetail>(
"train-scheduling",
"confirm-loading",
({ id }) => trainSchedulingService.confirmLoading(id),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
pinWagons: endpoint<
{ id: string; payload: PinWagonsPayload },
TrainScheduleDetail

View File

@@ -23,6 +23,7 @@ import type {
RecordCheckpointPayload,
StaffBookingWindow,
TrainScheduleDetail,
UpdateScheduleWindowRulePayload,
TrainScheduleFilters,
TrainScheduleListItem,
TrainSchedulePreviewPayload,
@@ -210,6 +211,28 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
updateScheduleWindowRule: async (
scheduleId: string,
payload: UpdateScheduleWindowRulePayload,
): Promise<TrainScheduleDetail> => {
const response = await client.patch<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.WINDOW_RULE(scheduleId),
payload,
);
return unwrap(response.data);
},
updateScheduleDate: async (
scheduleId: string,
scheduleDate: string,
): Promise<TrainScheduleDetail> => {
const response = await client.patch<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.SCHEDULE_DATE(scheduleId),
{ scheduleDate },
);
return unwrap(response.data);
},
markBookingPaid: async (bookingId: string): Promise<void> => {
await client.post(
URL_CONSTANTS.TRAIN_SCHEDULING.MARK_BOOKING_PAID(bookingId),
@@ -335,6 +358,27 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
setLoadingStatus: async (
scheduleId: string,
payload: { bookingIds: string[]; loadingStatus: LoadingStatus },
): Promise<TrainScheduleDetail> => {
const response = await client.patch<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.LOADING_STATUS(scheduleId),
payload,
);
return unwrap(response.data);
},
confirmLoading: async (
scheduleId: string,
): Promise<TrainScheduleDetail> => {
const response = await client.post<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.CONFIRM_LOADING(scheduleId),
{},
);
return unwrap(response.data);
},
getImportDjiboutiOperation: async (
scheduleId: string,
): Promise<ImportDjiboutiOperation> => {

View File

@@ -108,6 +108,7 @@ export interface TrainSchedulingGlobalRules {
importWindowLeadDays: number;
exportBookingLeadHours: number;
windowOpenHour: number;
windowCloseHour: number;
windowDurationHours: number;
docReviewMinutes: number;
paymentWindowMinutes: number;
@@ -399,6 +400,28 @@ export interface TrainScheduleWagonAllocation {
} | null;
}
/** Per-schedule booking-window rule snapshot (null fields fall back to global config). */
export interface ScheduleWindowRule {
windowOpenHour: number | null;
windowCloseHour: number | null;
windowDurationHours: number | null;
reopenDelayMinutes: number | null;
importWindowLeadDays: number | null;
/** Live global values (not snapshotted per schedule) — editor prefill baseline. */
docReviewMinutes: number;
paymentWindowMinutes: number;
}
/** Editable window-rule override for one schedule; every field optional. */
export interface UpdateScheduleWindowRulePayload {
windowOpenHour?: number;
windowCloseHour?: number;
windowDurationHours?: number;
docReviewMinutes?: number;
paymentWindowMinutes?: number;
importWindowLeadDays?: number;
}
export interface TrainScheduleDetail {
id: string;
status: TrainScheduleStatus | string;
@@ -406,11 +429,17 @@ export interface TrainScheduleDetail {
freightType?: FreightType | null;
trainNumber?: string | null;
direction?: string | null;
/** True when this schedule needs loading confirmed before dispatch (import-Djibouti). */
requiresLoadingConfirmation?: boolean;
/** True when loading is already confirmed (or not required for this direction). */
loadingConfirmed?: boolean;
windowPhase?: BookingWindowPhase | string | null;
windowOpensAt?: string | null;
windowClosesAt?: string | null;
docReviewEndsAt?: string | null;
paymentPhaseEndsAt?: string | null;
/** Booking-window rule snapshot — prefills the per-schedule settings editor. */
windowRule?: ScheduleWindowRule | null;
route?: {
id: string;
name: string;
@@ -479,6 +508,8 @@ export interface TrainScheduleDetail {
status: string | null;
schedulingStatus?: SchedulingStatus | null;
freightType?: FreightType | string | null;
loadingStatus?: "LOADED" | "UNLOADED";
wagonAssigned?: boolean;
}>;
warnings?: string[];
}

View File

@@ -5,6 +5,7 @@ import {
Layers,
Loader2,
MapPin,
Package,
Receipt,
Settings,
} from "lucide-react";
@@ -35,6 +36,7 @@ import InvoiceDetailPage from "./pages/billing/InvoiceDetailPage";
import InvoicesList from "./pages/billing/InvoicesList";
import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
import BookingsListPage from "./pages/bookings/BookingsListPage";
import EditBookingPage from "./pages/bookings/EditBookingPage";
import ContractClearanceFlow from "./pages/contracts/ContractClearanceFlow";
import ContractDetailPage from "./pages/contracts/ContractDetailPage";
@@ -184,6 +186,11 @@ const sidebarItems: SidebarItem[] = [
href: "/contracts",
icon: <Layers size={18} />,
},
{
label: "Bookings",
href: "/bookings",
icon: <Package size={18} />,
},
{
label: "Tracking",
href: "/tracking",
@@ -255,12 +262,9 @@ const App = () => {
}
>
<Route path="/portal" element={<MyPortalPage />} />
{/* Bookings live under contracts now — the standalone list is gone.
Legacy /bookings* entry points redirect into the contract flow. */}
<Route
path="/bookings"
element={<Navigate to="/contracts" replace />}
/>
{/* Bookings are created against a contract, but the full list is
browsable here. New-booking entry still routes via a contract. */}
<Route path="/bookings" element={<BookingsListPage />} />
<Route
path="/bookings/new"
element={<Navigate to="/contracts/new" replace />}

View File

@@ -0,0 +1,953 @@
import { useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
ActionIcon,
Box,
Button,
Card,
Group,
Menu,
Paper,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
} from "@mantine/core";
import type { LucideIcon } from "lucide-react";
import {
ArrowRight,
CheckCircle2,
FileEdit,
LayoutList,
MoreVertical,
Package,
Plus,
Search,
Train,
Wallet,
X,
} from "lucide-react";
import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
import { PayNowButton } from "./payments/PayNowButton";
import { BookingActionButton } from "./clearance/BookingActionButton";
import { bookingHasInlineAction } from "./clearance/bookingNextAction";
import {
BookingTypeBadge,
CargoModeCell,
PaymentBadge,
SchedulingCell,
} from "./booking-display";
import { api } from "@/services/api";
import type { BookingListFilter } from "@/services/bookings.service";
import { STATUS_CONFIG } from "@/pages/MyPortalPage/constants";
import type { Freight } from "@edr/types";
import {
DataTable,
DataTableFooter,
type ColumnDef,
usePagination,
} from "@edr/ui-common";
// Bookings that have left (or are leaving) the yard can be tracked live.
const TRACKABLE_STATUSES = new Set([
"PAID",
"IN_TRANSIT",
"COMPLETED",
"DELIVERED",
]);
// ── Status filter options (grouped by lifecycle) ──────────────────────────────
const STATUS_FILTERS = [
{
key: "all",
label: "All bookings",
statuses: undefined as string | undefined,
},
{
key: "active",
label: "In progress",
statuses:
"SUBMITTED,CHANGES_REQUESTED,PENDING_APPROVAL,APPROVED_PENDING_SIGNATURE,APPROVED,CONTRACT_READY,SIGNED_CUSTOMER,FULLY_EXECUTED,PENDING_CONSOLIDATION,CONSOLIDATED",
},
{ key: "draft", label: "Drafts", statuses: "DRAFT" },
{
key: "payment",
label: "Awaiting payment",
statuses:
"SELECTED_FOR_BATCH,PNR_GENERATED,PAYMENT_VERIFICATION_IN_PROGRESS,EXPIRED",
},
{ key: "transit", label: "In transit", statuses: "PAID,IN_TRANSIT" },
{ key: "done", label: "Completed", statuses: "COMPLETED,DELIVERED" },
{
key: "closed",
label: "Cancelled / rejected",
statuses: "CANCELLED,REJECTED",
},
] as const;
type StatusFilterKey = (typeof STATUS_FILTERS)[number]["key"];
const SELECT_DATA = STATUS_FILTERS.map((f) => ({
value: f.key,
label: f.label,
}));
// Sort options — server-side ordering on the booking list.
const SORT_OPTIONS = [
{ value: "createdAt:DESC", label: "Newest first" },
{ value: "createdAt:ASC", label: "Oldest first" },
{ value: "scheduledDate:ASC", label: "Ship date ↑" },
{ value: "scheduledDate:DESC", label: "Ship date ↓" },
{ value: "reference:ASC", label: "Reference AZ" },
{ value: "reference:DESC", label: "Reference ZA" },
] as const;
// ── Summary stat cards (clickable lifecycle filters) ──────────────────────────
const STAT_CARDS: Array<{
key: StatusFilterKey;
label: string;
icon: LucideIcon;
iconBg: string;
iconColor: string;
}> = [
{
key: "all",
label: "All bookings",
icon: LayoutList,
iconBg: "#ECF6F1",
iconColor: "#0A8A5F",
},
{
key: "active",
label: "In progress",
icon: Package,
iconBg: "#FDF3E0",
iconColor: "#C77F09",
},
{
key: "payment",
label: "Awaiting payment",
icon: Wallet,
iconBg: "#FEF6E6",
iconColor: "#F2A516",
},
{
key: "draft",
label: "Drafts",
icon: FileEdit,
iconBg: "#F1F4F7",
iconColor: "#475569",
},
{
key: "done",
label: "Completed",
icon: CheckCircle2,
iconBg: "#ECF6F1",
iconColor: "#0A8A5F",
},
];
// ── Status badge (reuses the shared portal status config) ─────────────────────
function StatusBadge({ status }: { status: string }) {
const cfg = STATUS_CONFIG[status];
const label = cfg?.badgeLabel ?? status.replace(/_/g, " ");
const bg = cfg ? `var(--mantine-color-${cfg.badgeBg}-0, #F1F4F7)` : "#F1F4F7";
const text = cfg
? `var(--mantine-color-${cfg.badgeText}-7, #475569)`
: "#475569";
const dot = cfg
? `var(--mantine-color-${cfg.badgeDot}-6, #94A3B8)`
: "#94A3B8";
return (
<Group
gap={6}
align="center"
wrap="nowrap"
style={{
display: "inline-flex",
borderRadius: 999,
backgroundColor: bg,
padding: "5px 11px",
}}
>
<Box
style={{
width: 6,
height: 6,
borderRadius: "50%",
backgroundColor: dot,
flexShrink: 0,
}}
/>
<Text fz={11} fw={700} style={{ color: text, whiteSpace: "nowrap" }}>
{label}
</Text>
</Group>
);
}
// ── Context-sensitive action button ───────────────────────────────────────────
function PrimaryAction({
booking,
onNavigate,
}: {
booking: Freight.IBooking;
onNavigate: (path: string) => void;
}) {
const { status, id } = booking;
const go = () => onNavigate(`/bookings/${id}`);
// A general contract is payable as soon as it's FULLY_EXECUTED (signed); a
// one-time booking only after it's SELECTED_FOR_BATCH.
const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT";
if (status === "DRAFT") {
return (
<Button
size="xs"
radius="md"
fw={700}
fz={13}
rightSection={<ArrowRight size={14} />}
style={{
backgroundColor: "var(--mantine-color-edr-ink-0)",
color: "#fff",
}}
onClick={go}
>
Continue
</Button>
);
}
// CHANGES_REQUESTED + clearance/operation steps are handled in place by a
// modal (update & resubmit, upload clearance docs, schedule & proceed).
if (bookingHasInlineAction(booking)) {
return <BookingActionButton booking={booking} size="xs" />;
}
const payableStatus = isGeneralContract
? "FULLY_EXECUTED"
: "SELECTED_FOR_BATCH";
if (status === payableStatus && booking.paymentStatus !== "PAID") {
return <PayNowButton booking={booking} />;
}
return (
<Button
size="xs"
radius="md"
variant="default"
fw={600}
fz={13}
onClick={go}
>
View
</Button>
);
}
function ColHeader({ label }: { label: string }) {
return (
<Text
fz={11}
fw={700}
c="edr-muted"
style={{
letterSpacing: "0.6px",
textTransform: "uppercase",
whiteSpace: "nowrap",
}}
>
{label}
</Text>
);
}
const hMeta = { headerClassName: "bg-[#F4F7FA]" };
function fmtDate(iso?: string | null): string {
if (!iso) return "";
const d = new Date(iso);
return Number.isNaN(d.getTime())
? ""
: d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
// ── Main component ────────────────────────────────────────────────────────────
// Lightweight count query for a single lifecycle filter (reads only `total`).
function useStatusCount(statuses: string | undefined): number | undefined {
const { data } = useQuery(
api.bookings.list.queryOptions({
input: { statuses, page: 1, pageSize: 1 },
staleTime: 30_000,
}),
);
return data?.meta?.total;
}
function StatCard({
card,
active,
count,
onSelect,
}: {
card: (typeof STAT_CARDS)[number];
active: boolean;
count: number | undefined;
onSelect: () => void;
}) {
const Icon = card.icon;
return (
<Paper
role="button"
tabIndex={0}
onClick={onSelect}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect();
}
}}
p="md"
radius="lg"
withBorder
style={{
cursor: "pointer",
transition: "box-shadow 140ms ease, border-color 140ms ease",
borderColor: active ? "#F2A516" : "var(--mantine-color-edr-border-0)",
boxShadow: active ? "0 0 0 1px #F2A516" : "none",
}}
>
<Group gap={12} wrap="nowrap" align="center">
<Box
style={{
width: 42,
height: 42,
borderRadius: 11,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: card.iconBg,
color: card.iconColor,
}}
>
<Icon size={20} strokeWidth={2} />
</Box>
<Box style={{ minWidth: 0 }}>
<Text fz={24} fw={800} lh={1.05} c="edr-text">
{count ?? "—"}
</Text>
<Text fz={12} fw={600} c="edr-muted" truncate>
{card.label}
</Text>
</Box>
</Group>
</Paper>
);
}
export default function BookingsListPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [statusFilter, setStatusFilter] = useState<StatusFilterKey>("all");
const [query, setQuery] = useState("");
const [typeFilter, setTypeFilter] = useState<string | null>(null);
const [freightFilter, setFreightFilter] = useState<string | null>(null);
const [sort, setSort] = useState<string>("createdAt:DESC");
const [createdFrom, setCreatedFrom] = useState<string>("");
const [createdTo, setCreatedTo] = useState<string>("");
const [trackingBooking, setTrackingBooking] =
useState<Freight.IBooking | null>(null);
const statuses = STATUS_FILTERS.find((t) => t.key === statusFilter)?.statuses;
const [sortBy, sortOrder] = sort.split(":") as [string, "ASC" | "DESC"];
const resetPage = () =>
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
const selectFilter = (key: StatusFilterKey) => {
setStatusFilter(key);
resetPage();
};
const hasExtraFilters =
!!typeFilter || !!freightFilter || !!createdFrom || !!createdTo;
const clearExtraFilters = () => {
setTypeFilter(null);
setFreightFilter(null);
setCreatedFrom("");
setCreatedTo("");
resetPage();
};
const filter: BookingListFilter = useMemo(
() => ({
statuses,
bookingType: typeFilter ?? undefined,
freightType: freightFilter ?? undefined,
createdFrom: createdFrom || undefined,
// include the whole selected end day
createdTo: createdTo ? `${createdTo}T23:59:59.999Z` : undefined,
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
sortBy,
sortOrder,
}),
[
statuses,
typeFilter,
freightFilter,
createdFrom,
createdTo,
pagination.pageIndex,
pagination.pageSize,
sortBy,
sortOrder,
],
);
const { data, isLoading, isError } = useQuery(
api.bookings.list.queryOptions({ input: filter }),
);
// Per-card lifecycle counts (one cheap query each, total-only).
const allCount = useStatusCount(undefined);
const activeCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "active")!.statuses,
);
const paymentCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "payment")!.statuses,
);
const draftCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "draft")!.statuses,
);
const doneCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "done")!.statuses,
);
const cardCounts: Record<StatusFilterKey, number | undefined> = {
all: allCount,
active: activeCount,
payment: paymentCount,
draft: draftCount,
done: doneCount,
transit: undefined,
closed: undefined,
};
const allItems = data?.items ?? [];
const total = data?.meta?.total ?? allItems.length;
// Server handles status + pagination; reference search is applied on the page
// (matches booking reference, contract reference, and route yards).
const rows = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return allItems;
return allItems.filter((b) =>
[
b.reference,
b.contractReference,
b.originYard?.label,
b.destinationYard?.label,
]
.filter(Boolean)
.some((v) => String(v).toLowerCase().includes(q)),
);
}, [allItems, query]);
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";
const showEmpty = !isLoading && !isError && rows.length === 0;
const columns: ColumnDef<Freight.IBooking>[] = [
{
id: "booking",
size: 244,
meta: hMeta,
header: () => <ColHeader label="Booking" />,
cell: ({ row }) => {
const b = row.original;
const cargoLabel =
b.freightType === "BULK" ? "Bulk cargo" : "Container";
return (
<Group gap={12} wrap="nowrap" align="center">
<Box
style={{
width: 36,
height: 36,
borderRadius: 9,
flexShrink: 0,
backgroundColor: "var(--mantine-color-edr-soft-0)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Package
size={18}
color="var(--mantine-color-edr-green-7)"
strokeWidth={2}
/>
</Box>
<Box style={{ minWidth: 0 }}>
<Text fz={14} fw={700} c="edr-text" truncate>
{b.reference}
</Text>
<Text fz={12} c="edr-muted">
{cargoLabel}
</Text>
</Box>
</Group>
);
},
},
{
id: "contract",
size: 150,
meta: hMeta,
header: () => <ColHeader label="Contract" />,
cell: ({ row }) => {
const ref = row.original.contractReference;
const cid = row.original.contractId;
if (!ref) {
return (
<Text fz={13} c="edr-muted">
</Text>
);
}
return (
<Text
fz={13}
fw={600}
c={cid ? "edr-green" : "edr-text"}
style={{ whiteSpace: "nowrap", cursor: cid ? "pointer" : "default" }}
onClick={
cid
? (e) => {
e.stopPropagation();
navigate(`/contracts/${cid}`);
}
: undefined
}
>
{ref}
</Text>
);
},
},
{
id: "type",
size: 150,
meta: hMeta,
header: () => <ColHeader label="Type" />,
cell: ({ row }) => <BookingTypeBadge booking={row.original} />,
},
{
id: "cargo",
size: 168,
meta: hMeta,
header: () => <ColHeader label="Cargo" />,
cell: ({ row }) => <CargoModeCell booking={row.original} />,
},
{
id: "route",
size: 196,
meta: hMeta,
header: () => <ColHeader label="Route" />,
cell: ({ row }) => {
const b = row.original;
const origin = b.originYard?.label ?? b.originYard?.code ?? "—";
const dest = b.destinationYard?.label ?? b.destinationYard?.code ?? "—";
const sub = fmtDate(b.scheduledDate ?? b.createdAt);
return (
<Box>
<Text fz={13} fw={600} c="edr-text">
{origin} {dest}
</Text>
{sub && (
<Text fz={12} c="edr-muted">
{sub}
</Text>
)}
</Box>
);
},
},
{
id: "payment",
size: 130,
meta: hMeta,
header: () => <ColHeader label="Payment" />,
cell: ({ row }) => <PaymentBadge status={row.original.paymentStatus} />,
},
{
id: "scheduling",
size: 140,
meta: hMeta,
header: () => <ColHeader label="Train" />,
cell: ({ row }) => <SchedulingCell booking={row.original} />,
},
{
id: "status",
size: 190,
meta: hMeta,
header: () => <ColHeader label="Status" />,
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: "amount",
size: 140,
meta: hMeta,
header: () => <ColHeader label="Amount" />,
cell: ({ row }) => {
const b = row.original as Freight.IBooking & {
totalAmount?: number;
amount?: number;
};
const amount = b.totalAmount ?? b.amount ?? null;
if (!amount) {
return (
<Text fz={14} fw={700} style={{ color: "#94A3B8" }}>
</Text>
);
}
return (
<Text fz={14} fw={700} c="edr-text">
ETB {amount.toLocaleString()}
</Text>
);
},
},
{
id: "actions",
meta: hMeta,
header: () => null,
cell: ({ row }) => {
const booking = row.original;
const trackable = TRACKABLE_STATUSES.has(booking.status);
return (
<Group
justify="flex-end"
gap={8}
wrap="nowrap"
onClick={(e) => e.stopPropagation()}
>
{trackable && (
<Button
size="xs"
radius="md"
variant="light"
color="edr-green"
fw={700}
fz={13}
leftSection={<Train size={14} />}
onClick={() => setTrackingBooking(booking)}
>
Track
</Button>
)}
<PrimaryAction booking={booking} onNavigate={navigate} />
<Menu position="bottom-end" withinPortal shadow="md" radius="md">
<Menu.Target>
<ActionIcon
variant="transparent"
size={30}
radius="md"
aria-label="More options"
>
<MoreVertical size={16} color="#9AA8B5" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={() => navigate(`/bookings/${booking.id}`)}>
View details
</Menu.Item>
{trackable && (
<Menu.Item
leftSection={<Train size={15} />}
onClick={() => setTrackingBooking(booking)}
>
Track shipment
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Group>
);
},
},
];
return (
<Box style={{ padding: "28px 32px 32px" }}>
<Stack gap="lg">
{/* ── Page header ─────────────────────────────────────────────── */}
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md">
<Box>
<Group gap={10} align="center">
<Title
order={1}
fw={800}
fz={26}
style={{ letterSpacing: "-0.01em" }}
>
Bookings
</Title>
</Group>
<Text size="sm" c="edr-muted" mt={4}>
Track every cargo booking from draft to delivery.
</Text>
</Box>
<Button
component={Link}
to="/contracts"
color="edr-green"
radius="md"
leftSection={<Plus size={16} />}
>
New booking
</Button>
</Group>
{/* ── Summary stat cards ──────────────────────────────────────── */}
<SimpleGrid cols={{ base: 2, sm: 3, lg: 5 }} spacing="md">
{STAT_CARDS.map((card) => (
<StatCard
key={card.key}
card={card}
active={statusFilter === card.key}
count={cardCounts[card.key]}
onSelect={() => selectFilter(card.key)}
/>
))}
</SimpleGrid>
{/* ── Bookings table card ──────────────────────────────────────── */}
<Card p={0} style={{ overflow: "hidden" }}>
<Group
justify="space-between"
gap={12}
px={20}
py={14}
wrap="wrap"
style={{
borderBottom: "1px solid var(--mantine-color-edr-border-0)",
}}
>
<Group gap={10} wrap="wrap" style={{ flex: 1, minWidth: 260 }}>
<TextInput
placeholder="Search booking, contract, or route…"
leftSection={<Search size={16} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
rightSection={
query ? (
<ActionIcon
size="sm"
variant="transparent"
color="gray"
onClick={() => setQuery("")}
>
<X size={14} />
</ActionIcon>
) : null
}
radius="md"
style={{ flex: 1, minWidth: 200, maxWidth: 340 }}
/>
<Select
data={SELECT_DATA}
value={statusFilter}
onChange={(value) =>
selectFilter((value as StatusFilterKey) ?? "all")
}
allowDeselect={false}
radius="md"
checkIconPosition="right"
comboboxProps={{ withinPortal: true }}
style={{ width: 190 }}
aria-label="Filter by status"
/>
<Select
placeholder="Any type"
data={[
{ value: "ONE_TIME", label: "One-time" },
{ value: "GENERAL_CONTRACT", label: "General contract" },
]}
value={typeFilter}
onChange={(v) => {
setTypeFilter(v);
resetPage();
}}
clearable
radius="md"
comboboxProps={{ withinPortal: true }}
style={{ width: 170 }}
aria-label="Filter by booking type"
/>
<Select
placeholder="Any cargo"
data={[
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
]}
value={freightFilter}
onChange={(v) => {
setFreightFilter(v);
resetPage();
}}
clearable
radius="md"
comboboxProps={{ withinPortal: true }}
style={{ width: 150 }}
aria-label="Filter by cargo type"
/>
<Select
data={SORT_OPTIONS.map((o) => ({
value: o.value,
label: o.label,
}))}
value={sort}
onChange={(v) => {
setSort(v ?? "createdAt:DESC");
resetPage();
}}
allowDeselect={false}
radius="md"
comboboxProps={{ withinPortal: true }}
style={{ width: 160 }}
aria-label="Sort bookings"
/>
<TextInput
type="date"
value={createdFrom}
onChange={(e) => {
setCreatedFrom(e.currentTarget.value);
resetPage();
}}
radius="md"
style={{ width: 150 }}
aria-label="Created from"
placeholder="From"
/>
<TextInput
type="date"
value={createdTo}
onChange={(e) => {
setCreatedTo(e.currentTarget.value);
resetPage();
}}
radius="md"
style={{ width: 150 }}
aria-label="Created to"
placeholder="To"
/>
{hasExtraFilters && (
<Button
variant="subtle"
color="gray"
radius="md"
size="sm"
leftSection={<X size={14} />}
onClick={clearExtraFilters}
>
Clear
</Button>
)}
</Group>
<Text fz={12} c="edr-muted">
{total} booking{total !== 1 ? "s" : ""}
</Text>
</Group>
{showEmpty ? (
<Stack align="center" gap={4} px="lg" py={64} ta="center">
<ThemeIcon
size={56}
radius="lg"
color="edr-green"
variant="light"
mb="xs"
>
<Package size={28} />
</ThemeIcon>
<Text size="sm" fw={600} c="edr-text">
{query
? "No bookings match your search"
: "No bookings here yet"}
</Text>
<Text size="xs" c="edr-muted" maw={320}>
{query
? "Try a different reference or clear the search."
: "Bookings are created against a contract. Open a contract to book a shipment."}
</Text>
{!query && (
<Button
component={Link}
to="/contracts"
size="sm"
mt="md"
variant="light"
color="edr-green"
>
Go to contracts
</Button>
)}
</Stack>
) : (
<DataTable
columns={columns}
data={rows}
status={dataTableStatus}
onRowClick={(row) =>
navigate(`/bookings/${(row as Freight.IBooking).id}`)
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none rounded-none"
footer={DataTableFooter}
/>
)}
</Card>
</Stack>
<ShipmentTrackingModal
opened={trackingBooking !== null}
onClose={() => setTrackingBooking(null)}
bookingId={trackingBooking?.id ?? ""}
bookingReference={trackingBooking?.reference ?? ""}
originLabel={
trackingBooking?.originYard?.label ??
trackingBooking?.originYard?.code
}
destinationLabel={
trackingBooking?.destinationYard?.label ??
trackingBooking?.destinationYard?.code
}
/>
</Box>
);
}

View File

@@ -414,6 +414,8 @@ export interface IBooking extends BaseEntity {
customerId: string;
/** The contract this booking was created under (Path A / Path B). */
contractId?: string | null;
/** Human-readable reference of the parent contract, joined onto list rows. */
contractReference?: string | null;
trainId?: string | null;
status: BookingStatus;
/** ONE_TIME for normal bookings; GENERAL_CONTRACT for umbrella contracts. */