import type { Freight, PaginatedResponse } from "@edr/types"; import { URL_CONSTANTS } from "@/constants/URLS"; import type { BookingDocuments } from "@/pages/bookings/new-booking-form/schema"; import { buildBookingFormData } from "./booking-form-data"; import { client } from "../utils/api"; const B = URL_CONSTANTS.BOOKINGS; export interface EmptyContainerReturn { id: string; containerNumber: string; containerSize: "20" | "40" | null; returnDate: string; facility: string | null; yard: string | null; zone: string | null; condition: string | null; status: string; returnedBy: "EDR" | "CUSTOMER" | null; } export interface MileVehicleSummary { plate: string | null; code: string | null; driverName: string | null; containerNumber: string | null; distanceKm: number | null; } export interface MileLegSummary { status: string; exactKm: number | null; remainingPayment: number | null; currency: string; invoiced: boolean; vehicles: MileVehicleSummary[]; } export interface MileSummaryResponse { firstMile: MileLegSummary | null; lastMile: MileLegSummary | null; } export type CreateBookingPayload = Freight.CreateBookingDto; export interface ContractView { bookingId: string; reference: string; status: string; templateKey: string; title: string; html: string; canSignCustomer: boolean; canSignStaff: boolean; hasContractDocument: boolean; signatures: Array<{ role: string; signerDisplayName: string; signedAt: string; signatureImageUrl?: string | null; stampImageUrl?: string | null; }>; /** Current viewer's reusable saved signature, if they have one. */ savedSignature?: { signerDisplayName: string; signatureImageUrl?: string | null; stampImageUrl?: string | null; } | null; } export interface PriceLineItem { code: string; description: string; /** Computed line total (unitAmount × quantity). */ amount: number; /** Price for a single unit of this charge (e.g. one 20ft container, one ton). */ unitAmount?: number; /** Unit the rate is charged per: PER_CONTAINER | PER_TON | PER_WAGON | PER_KM | FLAT. */ unit?: string; /** How many units this charge applies to (containers, tons, wagons; 1 for FLAT). */ quantity?: number; currency: string; } /** * An announced upcoming/open booking window, shown to every signed-in customer * regardless of contract. Import trains open a window on one booking day; * export trains open 24h before departure (first come, first served). */ export interface MyBookingWindow { scheduleId: string; /** Train schedule reference (e.g. TS-2026-000123), shown on the window card. */ reference: string | null; /** Operational run number (e.g. 8001 import / 8002 export), when staff set one. */ trainNumber: string | null; /** * The customer's active contract on this lane, when they hold one — enables * "Book now" to target it. Null for lanes they have no contract on. */ contractId: string | null; /** ONE_TIME contracts can't draw down against a window — button is hidden. */ contractKind: "ONE_TIME" | "GENERAL" | null; direction: "IMPORT" | "EXPORT" | null; windowPhase: string | null; isOpenNow: boolean; windowOpensAt: string | null; windowClosesAt: string | null; docReviewEndsAt: string | null; paymentPhaseEndsAt: string | null; /** End of the payment drain tail — pending payments may settle until then. */ paymentDrainEndsAt: string | null; bookingWindowStatus: string; bookingCycleNo: number; departureDate: string; origin: string | null; destination: string | null; /** * Full ordered corridor for the window's route — origin, every intermediate * milestone stop, then destination (e.g. Djibouti → Adama → Dire Dawa). * Falls back to [origin, destination] when the route has no milestones. */ routeStations: string[]; } export interface GeneratePriceResponse { bookingId: string; totalAmount: number; currency: string; lineItems: PriceLineItem[]; warnings: string[]; } export interface SubmitBookingResponse { bookingId: string; status: string; priceChanged: boolean; previousTotalAmount?: number; totalAmount: number; currency: string; lineItems?: PriceLineItem[]; message?: string; } export interface SignContractPayload { role: "CUSTOMER" | "STAFF"; signatureImageBase64: string; /** Company stamp/seal image; required to sign a contract (not booking contracts). */ stampImageBase64?: string; signerDisplayName: string; consentText?: string; /** Sudo-mode OTP challenge; required when role=CUSTOMER. */ otp?: string; } export interface ApproveDeliveryResponse { bookingId: string; inventoryId: string; approvedAt: string; signerDisplayName: string; } /** One import handover record — booking-level or per truck (EDR last-mile). */ export interface BookingHandoverRecord { id: string; reference: string; truckPlate: string | null; mileType: "SELF_HAUL" | "EDR_LAST_MILE"; generatedAt: string; signedAt: string | null; signerName: string | null; deliveredAt: string | null; } export interface SignHandoverResponse { handoverId: string; bookingId: string; signedAt: string | null; signerDisplayName: string; allSigned: boolean; } export interface CustomerTruckAssignmentPayload { truckPlateNumber: string; driverName: string; truckType: string; containerNumberToLoad: string; } export interface BookingListFilter { status?: string; /** Comma-separated statuses (overrides `status` when set). */ statuses?: string; /** 'true' = has a train assigned (allocated), 'false' = not yet assigned. */ assignedToSchedule?: "true" | "false"; /** ONE_TIME or GENERAL_CONTRACT. */ bookingType?: string; /** CONTAINER or BULK. */ freightType?: string; /** Service type (rule-engine service_types.id). */ serviceTypeId?: string; /** IMPORT / EXPORT / DOMESTIC. */ tradeDirection?: string; /** Narrow to a single operational profile (importer/exporter/freight_forwarder). */ companyProfileId?: string; /** Created-date range (ISO). */ createdFrom?: string; createdTo?: string; /** Free-text search: booking reference, company name, contract reference (server-side). */ search?: string; page?: number; pageSize?: number; sortBy?: string; sortOrder?: "ASC" | "DESC"; } export interface BookingWagonContainer { containerNumber: string | null; sealNumber: string | null; positionOnWagon: number | null; grossWeightTons: string | null; /** Container size in feet (20/40) — identifies the shared consolidation wagon. */ sizeFt: number | null; } /** One allocated wagon of a booking, as returned by GET /bookings/:id/wagons. */ export interface BookingWagonAllocation { /** wagon_booking_allocations id — the handle for cancelling this specific wagon. */ allocationId: string; sequenceNo: number; wagonNumber: string | null; wagonType: string | null; wagonTypeCode: string | null; tareWeightTons: string | null; capacityTons: string | null; lengthMeters: string | null; allocatedWeightTons: string | null; loadType: "CONTAINER" | "BULK"; status: "PLANNED" | "RESERVED" | "LOADED" | "DEPARTED"; trainNumber: string | null; departureAt: string | null; originStation: string | null; destinationStation: string | null; bulkCargoDescription: string | null; bulkQuantity: string | null; containers: BookingWagonContainer[]; } // ── Partial wagon cancellation (paid bookings) ────────────────────────────── export type WagonCancellationStatus = | "FEE_PENDING" | "CREDIT_AVAILABLE" | "REBOOKED" | "WITHDRAWN" | "EXPIRED"; /** One partial-cancellation ledger row of a paid booking. */ export interface WagonCancellation { id: string; bookingId: string; rebookedBookingId?: string | null; wagonsCancelled: number; weightTons: number; /** What was cut: bulk tons, or container units per size (ft). */ cancelledQuantities: { bulkTons?: number; bySize?: Record; /** Exact physical containers leaving with the cancelled wagons. */ units?: Array<{ containerSize: string; containerNumber: string; sealNumber?: string | null; vgmTons: number; isHazardous: boolean; isReefer: boolean; }>; /** Wagons already left the schedule when the request was made. */ releasedAtRequest?: boolean; }; /** Rebooking credit — the cancelled share of the original freight price. */ creditAmount: number; feeAmount: number; feeCurrency: string; feeInvoiceId?: string | null; feePaidAt?: string | null; status: WagonCancellationStatus; reason?: string | null; rebookedAt?: string | null; createdAt: string; booking?: { id: string; reference: string } | null; rebookedBooking?: { id: string; reference: string } | null; } /** Fee/credit preview of a partial wagon cancellation (no writes). */ export interface WagonCancellationPreview { wagons: number; weightTons: number; feePerWagon: number; feeAmount: number; feeCurrency: string; creditAmount: number; } export interface RequestWagonCancellationPayload { /** Cancel SPECIFIC wagons: allocationIds from getWagons. Overrides the fields below. */ wagonAllocationIds?: string[]; /** BULK bookings: number of wagons to cancel (tons derived proportionally). */ wagons?: number; /** CONTAINER bookings: units to cancel per size ("20"/"40", as stored on the line). */ containers?: Array<{ containerSize: string; quantity: number }>; reason?: string; } export interface WagonCancellationListFilter { statuses?: string[]; search?: string; from?: string; to?: string; page?: number; pageSize?: number; } export interface WagonCancellationListResponse { items: WagonCancellation[]; total: number; } export const bookingsService = { list: async ( filter: BookingListFilter | void = {}, ): Promise> => { const { data } = await client.get("/api/bookings", { params: filter }); return data.data; }, get: async (id: string): Promise => { const { data } = await client.get(`/api/bookings/${id}`); return data.data; }, mileSummary: async (id: string): Promise => { const { data } = await client.get(`/api/bookings/${id}/mile-summary`); return data.data; }, /** Ad-hoc extra charges finance has raised against this booking. */ getAdditionalCharges: async (id: string): Promise => { const { data } = await client.get(`/api/bookings/${id}/additional-charges`); return data.data; }, assignCustomerTruck: async ( id: string, payload: CustomerTruckAssignmentPayload, ): Promise => { const { data } = await client.post( `/api/bookings/${id}/customer-truck-assignment`, payload, ); return data.data; }, downloadCustomerTruckFreightOrder: async (id: string, copies?: number[]): Promise => { const { data } = await client.get( `/api/bookings/${id}/customer-truck-assignment/freight-order`, { responseType: "blob", params: copies?.length ? { copies: copies.join(",") } : undefined }, ); return data; }, downloadHandoverDocument: async (inventoryId: string): Promise => { const { data } = await client.get( `/api/warehouse-inventory/${inventoryId}/handover-document`, { responseType: "blob" }, ); return data; }, downloadBookingHandoverDocument: async ( bookingId: string, handoverId?: string, ): Promise => { const { data } = await client.get( `/api/warehouse-inventory/bookings/${bookingId}/handover-document`, { responseType: "blob", params: handoverId ? { handoverId } : undefined }, ); return data; }, listBookingHandovers: async ( bookingId: string, ): Promise => { const { data } = await client.get( `/api/warehouse-inventory/bookings/${bookingId}/handovers`, ); return data.data ?? data; }, signHandover: async ( handoverId: string, signerName: string, ): Promise => { const { data } = await client.post( `/api/warehouse-inventory/handovers/${handoverId}/sign`, { signerName }, ); return data.data ?? data; }, listEmptyContainerReturns: async (bookingId: string): Promise => { const { data } = await client.get( `/api/import-operations/bookings/${bookingId}/empty-container-returns`, ); return data.data ?? data; }, downloadEquipmentInterchangeDocument: async (returnId: string): Promise => { const { data } = await client.get( `/api/import-operations/empty-container-returns/${returnId}/document`, { responseType: "blob" }, ); return data; }, downloadBookingGrnDocument: async (bookingId: string): Promise => { const { data } = await client.get( `/api/warehouse-inventory/bookings/${bookingId}/grn-document`, { responseType: "blob" }, ); return data; }, downloadBookingReleaseDocument: async (bookingId: string): Promise => { const { data } = await client.get( `/api/warehouse-inventory/bookings/${bookingId}/release-document`, { responseType: "blob" }, ); return data; }, tracking: async (id: string): Promise => { const { data } = await client.get(`/api/bookings/${id}/tracking`); return data.data; }, create: async ( payload: CreateBookingPayload, documents?: BookingDocuments, ): Promise => { const formData = buildBookingFormData(payload, documents); const { data } = await client.post("/api/bookings", formData, { headers: { "Content-Type": "multipart/form-data" }, }); return data.data.booking; }, getReferenceData: async (): Promise => { const { data } = await client.get("/api/bookings/reference-data"); return data.data; }, update: async ( id: string, payload: Partial, documents?: BookingDocuments, ): Promise<{ booking: Freight.IBooking; warnings: string[] }> => { const formData = buildBookingFormData(payload, documents); const { data } = await client.patch(`/api/bookings/${id}`, formData, { headers: { "Content-Type": "multipart/form-data" }, }); return data.data; }, remove: async (id: string): Promise => { await client.delete(`/api/bookings/${id}`); }, cancel: async (id: string, reason: string): Promise => { const { data } = await client.post(`/api/bookings/${id}/cancel`, { reason }); return data.data; }, customerCancel: async ( id: string, reason?: string, ): Promise => { const { data } = await client.post(`/api/bookings/${id}/customer-cancel`, { reason, }); return data.data; }, reject: async (id: string, reason?: string): Promise => { const { data } = await client.post(`/api/bookings/${id}/reject`, { reason }); return data.data; }, generatePrice: async (id: string): Promise => { const { data } = await client.post(`/api/bookings/${id}/generate-price`); return data.data; }, submit: async (id: string): Promise => { const { data } = await client.post(`/api/bookings/${id}/submit`); return data.data; }, confirmSubmit: async (id: string): Promise => { const { data } = await client.post(`/api/bookings/${id}/confirm-submit`); return data.data; }, uploadDocuments: async ( id: string, files: Record, ): Promise => { const formData = new FormData(); for (const [key, fileOrFiles] of Object.entries(files)) { if (!fileOrFiles) continue; if (Array.isArray(fileOrFiles)) { for (const f of fileOrFiles) formData.append(key, f); } else { formData.append(key, fileOrFiles); } } const { data } = await client.post( `/api/bookings/${id}/documents`, formData, { headers: { "Content-Type": "multipart/form-data" } }, ); return data.data; }, // ── Document clearance ── getClearance: async (id: string): Promise => { const { data } = await client.get(`/api/bookings/${id}/clearance`); return data.data ?? data; }, submitClearanceDocuments: async ( id: string, files: Record, ): Promise => { const formData = new FormData(); for (const [key, file] of Object.entries(files)) { if (file) formData.append(key, file); } const { data } = await client.post( `/api/bookings/${id}/clearance/documents`, formData, { headers: { "Content-Type": "multipart/form-data" } }, ); return data.data; }, proceedToOperation: async ( id: string, scheduledDate: string, trainScheduleId?: string, ): Promise => { const { data } = await client.post( `/api/bookings/${id}/clearance/proceed`, { scheduledDate, ...(trainScheduleId ? { trainScheduleId } : {}) }, ); return data.data; }, uploadBookingClearanceDutySlip: async ( id: string, file: File, ): Promise => { const form = new FormData(); form.append("file", file); const { data } = await client.post(`/api/bookings/${id}/clearance/duty-slip`, form, { headers: { "Content-Type": "multipart/form-data" }, }); return data.data ?? data; }, /** Outstanding payments per booking — drives the "Pay" badge on list/home rows. */ getMyPayables: async (): Promise => { const { data } = await client.get(`/api/bookings/my-payables`); return data.data ?? data; }, // ── Clearance charges (port + miscellaneous) the customer approves, then pays ── getClearanceCharges: async (id: string): Promise => { const { data } = await client.get(`/api/bookings/${id}/clearance/charges`); return data.data ?? data; }, acceptClearanceCharge: async ( id: string, chargeId: string, ): Promise => { const { data } = await client.post( `/api/bookings/${id}/clearance/charges/${chargeId}/accept`, ); return data.data ?? data; }, rejectClearanceCharge: async ( id: string, chargeId: string, note: string, ): Promise => { const { data } = await client.post( `/api/bookings/${id}/clearance/charges/${chargeId}/reject`, { note }, ); return data.data ?? data; }, acceptDraftDeclaration: async (id: string): Promise => { const { data } = await client.post( `/api/bookings/${id}/clearance/draft-declaration/accept`, ); return data.data ?? data; }, requestDraftDeclarationChange: async ( id: string, note: string, ): Promise => { const { data } = await client.post( `/api/bookings/${id}/clearance/draft-declaration/change`, { note }, ); return data.data ?? data; }, getContractView: async (id: string): Promise => { const { data } = await client.get(B.CONTRACT_VIEW(id)); return data.data ?? data; }, downloadContractDocument: async (id: string): Promise => { const { data } = await client.get(B.CONTRACT_DOCUMENT(id), { responseType: "blob", }); return data; }, downloadCarriageAcceptanceSheet: async (id: string): Promise => { const { data } = await client.get(B.CARRIAGE_ACCEPTANCE_SHEET(id), { responseType: "blob", }); return data; }, checkPayment: async (orderId: string): Promise<{ status: string }> => { const { data } = await client.post(`/api/payments/bookings/check-payment/${orderId}`); return data.data ?? data; }, signContract: async ( id: string, payload: SignContractPayload, ): Promise => { const { data } = await client.post(B.CONTRACT_SIGN(id), payload); return data.data ?? data; }, approveDelivery: async ( id: string, signerName: string, ): Promise => { const { data } = await client.post( `/api/warehouse-inventory/bookings/${id}/approve-delivery`, { signerName }, ); return data.data ?? data; }, getBookableSchedules: async ( query: Freight.BookableSchedulesQuery = {}, ): Promise => { const { data } = await client.get( URL_CONSTANTS.TRAIN_SCHEDULING.BOOKABLE_SCHEDULES, { params: query }, ); return data.data; }, /** * Day-level pool: the days that have a departure on the route. The customer * picks a day; the engine assigns the train. No capacity is returned. */ getAvailableDays: async ( query: Freight.AvailableDaysQuery = {}, ): Promise => { const { data } = await client.get( URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_DAYS, { params: query }, ); return (data.data as Freight.AvailableDaysResponse).days; }, // Cargo-aware day pool: only days where a train has remaining capacity AND a // wagon TYPE that can carry this cargo. `containers`/`containerTypeIds` are // serialized as JSON string params (the server parses them). Days only — no // capacity counts are ever returned. getAvailableDaysForCargo: async ( query: Freight.AvailableDaysForCargoQuery, ): Promise => { const { containers, containerTypeIds, ...rest } = query; const { data } = await client.get( URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_DAYS_FOR_CARGO, { params: { ...rest, ...(containers ? { containers: JSON.stringify(containers) } : {}), ...(containerTypeIds?.length ? { containerTypeIds: JSON.stringify(containerTypeIds) } : {}), }, }, ); return (data.data as Freight.AvailableDaysResponse).days; }, // Days bookable for an EXISTING booking (operation-request step): the server // derives the cargo from the booking and applies the wagon-type gate. getAvailableDaysForBooking: async (bookingId: string): Promise => { const { data } = await client.get( `/api/bookings/${bookingId}/available-days`, ); return (data.data as Freight.AvailableDaysResponse).days; }, // Export train picker: the day's export trains with per-wagon-type free space. // The cargo params cover bare contract instances (nothing persisted yet) — // sizes/code/wagons come from what the customer is entering on the form. getExportTrains: async ( bookingId: string, date: string, cargo?: { containerSizes?: string[]; cargoTypeCode?: string; wagons?: number }, ): Promise => { const { data } = await client.get( `/api/bookings/${bookingId}/export-trains`, { params: { date, ...(cargo?.containerSizes?.length ? { containerSizes: cargo.containerSizes.join(",") } : {}), ...(cargo?.cargoTypeCode ? { cargoTypeCode: cargo.cargoTypeCode } : {}), ...(cargo?.wagons ? { wagons: cargo.wagons } : {}), }, }, ); return data.data as Freight.ExportTrainOption[]; }, // Advisory free-wagon count for a shipment day (planning hint, not enforced). getDayAvailability: async ( bookingId: string, date: string, ): Promise => { const { data } = await client.get( `/api/bookings/${bookingId}/day-availability`, { params: { date } }, ); return data.data as Freight.DayAvailabilityResponse; }, /** * Allocated wagons for a paid booking (empty until placed on a train). * One row per wagon with its containers / bulk load. */ getWagons: async (bookingId: string): Promise => { const { data } = await client.get(`/api/bookings/${bookingId}/wagons`); return (data.data ?? data) as BookingWagonAllocation[]; }, // ── Partial wagon cancellation ── /** Fee/credit preview for the confirm dialog — same math as the request, no writes. */ previewWagonCancellation: async ( id: string, payload: RequestWagonCancellationPayload, ): Promise => { const { data } = await client.post( `/api/bookings/${id}/wagon-cancellations/preview`, payload, ); return data.data ?? data; }, /** Open a cancellation: issues the fee invoice; wagons release once the fee settles. */ requestWagonCancellation: async ( id: string, payload: RequestWagonCancellationPayload, ): Promise => { const { data } = await client.post( `/api/bookings/${id}/wagon-cancellations`, payload, ); return data.data ?? data; }, /** Cancellation history of one booking (as source and as rebooked target). */ listWagonCancellations: async ( bookingId: string, ): Promise => { const { data } = await client.get( `/api/bookings/${bookingId}/wagon-cancellations`, ); return data.data ?? data; }, /** The signed-in customer's wagon cancellations (paginated, filterable). */ listMyWagonCancellations: async ( filter: WagonCancellationListFilter | void = {}, ): Promise => { const { data } = await client.get("/api/bookings/wagon-cancellations/my", { params: filter, }); return data.data ?? data; }, // Withdraw was removed from the portal on purpose: a customer's cancellation // request is final — only backoffice staff (void permission) can revert it. /** Rebook a CREDIT_AVAILABLE cancellation onto a shipment day → new PAID booking. */ rebookWagonCancellation: async ( cancellationId: string, payload: { scheduledDate: string; /** Optional unit edits — sizes/quantities must match the credit exactly. */ containers?: Array<{ containerSize: string; units: Array<{ containerNumber: string; sealNumber?: string; vgmTons?: number; }>; }>; }, ): Promise<{ cancellation: WagonCancellation; bookingId: string }> => { const { data } = await client.post( `/api/bookings/wagon-cancellations/${cancellationId}/rebook`, payload, ); return data.data ?? data; }, /** * Upcoming/open booking windows on the signed-in customer's active-contract * lanes (import booking-day windows + export 24h pre-departure windows). */ getMyBookingWindows: async (): Promise => { const { data } = await client.get( URL_CONSTANTS.TRAIN_SCHEDULING.MY_BOOKING_WINDOWS, ); return data.data ?? data; }, /** * Booking windows for a single contract's routes (same row shape as * `getMyBookingWindows`). Used to gate the direct "New shipment booking" * entry on the contract detail page and the new-shipment form. */ getContractBookingWindows: async ( contractId: string, ): Promise => { const { data } = await client.get( URL_CONSTANTS.TRAIN_SCHEDULING.CONTRACT_BOOKING_WINDOWS(contractId), ); return data.data ?? data; }, };