Files
edr-platform/apps/edr-freight-web/portal/src/services/bookings.service.ts
Marshal 56de90892d Add booking window management and locomotive scheduling features
- Implemented migration to release stuck assigned locomotives.
- Added schedule window phases to train schedules.
- Created booking batch offers table for partial capacity bookings.
- Developed BookingSplitService to handle partial booking offers and splits.
- Introduced BookingWindowService to manage booking window lifecycle and transitions.
- Added BookingBatchOffer entity to represent offers made during booking splits.
- Enhanced locomotive options with warnings for scheduling.
- Created UpcomingWindowsSection component to display upcoming booking windows.
2026-07-03 03:36:06 +00:00

375 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 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;
}>;
/** Current viewer's reusable saved signature, if they have one. */
savedSignature?: {
signerDisplayName: string;
signatureImageUrl?: 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 upcoming/open booking window on one of the signed-in customer's
* active-contract lanes. Import trains open a window on one booking day;
* export trains open 24h before departure (first come, first served).
*/
export interface MyBookingWindow {
scheduleId: string;
direction: "IMPORT" | "EXPORT" | null;
windowPhase: string | null;
isOpenNow: boolean;
windowOpensAt: string | null;
windowClosesAt: string | null;
bookingWindowStatus: string;
bookingCycleNo: number;
departureDate: string;
origin: string | null;
destination: string | null;
}
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;
signerDisplayName: string;
consentText?: string;
}
export interface ApproveDeliveryResponse {
bookingId: string;
inventoryId: string;
approvedAt: string;
signerDisplayName: string;
}
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;
/** ONE_TIME or GENERAL_CONTRACT. */
bookingType?: string;
/** CONTAINER or BULK. */
freightType?: 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;
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: "ASC" | "DESC";
}
export const bookingsService = {
list: async (
filter: BookingListFilter | void = {},
): Promise<PaginatedResponse<Freight.IBooking>> => {
const { data } = await client.get("/api/bookings", { params: filter });
return data.data;
},
get: async (id: string): Promise<Freight.IBooking> => {
const { data } = await client.get(`/api/bookings/${id}`);
return data.data;
},
assignCustomerTruck: async (
id: string,
payload: CustomerTruckAssignmentPayload,
): Promise<Freight.IBooking> => {
const { data } = await client.post(
`/api/bookings/${id}/customer-truck-assignment`,
payload,
);
return data.data;
},
downloadCustomerTruckFreightOrder: async (id: string): Promise<Blob> => {
const { data } = await client.get(
`/api/bookings/${id}/customer-truck-assignment/freight-order`,
{ responseType: "blob" },
);
return data;
},
downloadHandoverDocument: async (inventoryId: string): Promise<Blob> => {
const { data } = await client.get(
`/api/warehouse-inventory/${inventoryId}/handover-document`,
{ responseType: "blob" },
);
return data;
},
tracking: async (id: string): Promise<Freight.IBookingTracking> => {
const { data } = await client.get(`/api/bookings/${id}/tracking`);
return data.data;
},
create: async (
payload: CreateBookingPayload,
documents?: BookingDocuments,
): Promise<Freight.IBooking> => {
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<Freight.BookingReferenceData> => {
const { data } = await client.get("/api/bookings/reference-data");
return data.data;
},
update: async (
id: string,
payload: Partial<CreateBookingPayload>,
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<void> => {
await client.delete(`/api/bookings/${id}`);
},
cancel: async (id: string, reason: string): Promise<Freight.IBooking> => {
const { data } = await client.post(`/api/bookings/${id}/cancel`, { reason });
return data.data;
},
reject: async (id: string, reason?: string): Promise<Freight.IBooking> => {
const { data } = await client.post(`/api/bookings/${id}/reject`, { reason });
return data.data;
},
generatePrice: async (id: string): Promise<GeneratePriceResponse> => {
const { data } = await client.post(`/api/bookings/${id}/generate-price`);
return data.data;
},
submit: async (id: string): Promise<SubmitBookingResponse> => {
const { data } = await client.post(`/api/bookings/${id}/submit`);
return data.data;
},
confirmSubmit: async (id: string): Promise<SubmitBookingResponse> => {
const { data } = await client.post(`/api/bookings/${id}/confirm-submit`);
return data.data;
},
uploadDocuments: async (
id: string,
files: Record<string, File | File[] | null>,
): Promise<Freight.IBooking> => {
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<Freight.ClearanceView> => {
const { data } = await client.get(`/api/bookings/${id}/clearance`);
return data.data ?? data;
},
submitClearanceDocuments: async (
id: string,
files: Record<string, File | null>,
): Promise<Freight.IBooking> => {
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,
): Promise<Freight.IBooking> => {
const { data } = await client.post(
`/api/bookings/${id}/clearance/proceed`,
{ scheduledDate },
);
return data.data;
},
uploadBookingClearanceDutySlip: async (
id: string,
file: File,
): Promise<Freight.IBooking> => {
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;
},
getContractView: async (id: string): Promise<ContractView> => {
const { data } = await client.get(B.CONTRACT_VIEW(id));
return data.data ?? data;
},
downloadContractDocument: async (id: string): Promise<Blob> => {
const { data } = await client.get(B.CONTRACT_DOCUMENT(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<Freight.IBooking> => {
const { data } = await client.post(B.CONTRACT_SIGN(id), payload);
return data.data ?? data;
},
approveDelivery: async (id: string): Promise<ApproveDeliveryResponse> => {
const { data } = await client.post(
`/api/warehouse-inventory/bookings/${id}/approve-delivery`,
);
return data.data ?? data;
},
getBookableSchedules: async (
query: Freight.BookableSchedulesQuery = {},
): Promise<Freight.BookableScheduleItem[]> => {
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<string[]> => {
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
// enough matching-type wagons for this cargo. `containers` is serialized as a
// JSON string param (the server parses it).
getAvailableDaysForCargo: async (
query: Freight.AvailableDaysForCargoQuery,
): Promise<string[]> => {
const { containers, ...rest } = query;
const { data } = await client.get(
URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_DAYS_FOR_CARGO,
{
params: {
...rest,
...(containers ? { containers: JSON.stringify(containers) } : {}),
},
},
);
return (data.data as Freight.AvailableDaysResponse).days;
},
/**
* 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<MyBookingWindow[]> => {
const { data } = await client.get(
URL_CONSTANTS.TRAIN_SCHEDULING.MY_BOOKING_WINDOWS,
);
return data.data ?? data;
},
};