import type { Freight } from "@edr/types"; /** * Booking statuses that no longer occupy the single active-booking slot of a * ONE_TIME contract. Mirrors the API source of truth in * `contract-booking.service.ts`. */ export const TERMINAL_BOOKING_STATUSES = [ "EXPIRED", "CANCELLED", "COMPLETED", "REJECTED", ]; /** Path A statuses where a customer (no customs) may book against the contract. */ const PATH_A_BOOKABLE = ["FULLY_EXECUTED", "CONTRACT_ACTIVE"]; /** Split bookings in these statuses released their quantity — no remainder to book. */ const RELEASING_BOOKING_STATUSES = ["CANCELLED", "REJECTED", "EXPIRED"]; export type ContractBookingActionKind = | "book" | "rebook" | "request" | "initiate" | "none"; export interface ContractBookingAction { kind: ContractBookingActionKind; to: string; /** * ONE_TIME contract whose booking was split (partial batch payment): the * customer may book again, but ONLY the whole outstanding remainder — the * exact quantities come from the contract capacity endpoint. */ remainderOnly?: boolean; } /** * The single source of truth for whether a contract row should show a * Book / Re-book shipment button, and where it should navigate. * * - ONE_TIME: book only while there is no active booking. If the previous * booking expired, offer Re-book. Once a live booking exists, no button. * - GENERAL: bookable while CONTRACT_ACTIVE (CONTRACT_CLOSED / EXPIRED are * already excluded by the PATH_A_BOOKABLE gate). */ export function getContractBookingAction( contract: Freight.IContract, bookings: Freight.IBooking[], ): ContractBookingAction { // GENERAL + customs: customer submits a shipment request; GL creates the booking. if ( contract.customsClearingEnabled && contract.contractKind === "GENERAL" && contract.status === "CONTRACT_ACTIVE" ) { return { kind: "request", to: `/contracts/${contract.id}/shipment-requests/new`, }; } // Other customs (ONE_TIME): booked by GL — no customer action. if (contract.customsClearingEnabled) return { kind: "none", to: "" }; if (!PATH_A_BOOKABLE.includes(contract.status)) return { kind: "none", to: "" }; const to = `/contracts/${contract.id}/bookings/new`; if (contract.contractKind === "ONE_TIME") { const mine = bookings.filter((b) => b.contractId === contract.id); // Split chain: a paid partial split frees the single-booking slot for the // remainder. The API enforces that the next booking takes the whole // leftover; once it is booked the contract closes (CONTRACT_CLOSED), which // the PATH_A_BOOKABLE gate above already excludes. const hasLiveSplit = mine.some( (b) => b.isSplit && !RELEASING_BOOKING_STATUSES.includes(b.status), ); if (hasLiveSplit) return { kind: "book", to, remainderOnly: true }; const hasActive = mine.some( (b) => !TERMINAL_BOOKING_STATUSES.includes(b.status), ); if (hasActive) return { kind: "none", to: "" }; const hasExpired = mine.some((b) => b.status === "EXPIRED"); return { kind: hasExpired ? "rebook" : "book", to }; } // GENERAL non-customs import/export: one-click bare booking instance — the // per-booking clearance runs first, cargo + shipment day come at completion. // No window gate here; the window is checked when the booking is completed. // DOMESTIC (intercity) keeps the direct booking form. if (contract.tradeDirection !== "DOMESTIC") { return { kind: "initiate", to: `/contracts/${contract.id}` }; } return { kind: "book", to }; }