mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 07:08:18 +00:00
- 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.
375 lines
11 KiB
TypeScript
375 lines
11 KiB
TypeScript
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;
|
||
},
|
||
};
|