mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
- Added support for viewing and managing consolidated bookings in BookingRequestDetailPage. - Enhanced BookingRequestsPage to display paired bookings in a single row. - Introduced pairedDecision method in bookings service to handle decisions for both halves of a consolidated pair. - Updated contracts service to include methods for manual consolidation of odd-20ft bookings. - Created new components for selecting and editing consolidation partners. - Added tests for paired decision logic and manual consolidation scenarios. - Updated UI to reflect changes in booking handling and provide user feedback for odd container counts.
518 lines
18 KiB
TypeScript
518 lines
18 KiB
TypeScript
import { api as client } from "../auth/http";
|
|
import { unwrap } from "@/utils/endpoint";
|
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
|
import type { BookingDetail } from "@/types/booking";
|
|
import type { Freight } from "@edr/types";
|
|
|
|
const B = URL_CONSTANTS.BOOKINGS;
|
|
|
|
export interface BookingListFilter {
|
|
status?: string;
|
|
/** Comma-separated statuses for grouped tabs */
|
|
statuses?: string;
|
|
schedulingStatuses?: string;
|
|
assignedToSchedule?: "true" | "false";
|
|
/** Tab key for React Query cache (not sent to API) */
|
|
tab?: string;
|
|
// customerId?: string;
|
|
companyId?: string;
|
|
/** Bookings drawn down under this contract (contract detail's Shipments tab). */
|
|
contractId?: string;
|
|
freightType?: string;
|
|
/** ONE_TIME | GENERAL_CONTRACT — the booking-kind tab filter. */
|
|
bookingType?: string;
|
|
/** 'true' → customs bookings, 'false' → self-clearance (non-customs). */
|
|
customsClearingEnabled?: "true" | "false";
|
|
tradeDirection?: string;
|
|
paymentCurrency?: string;
|
|
paymentStatus?: string;
|
|
/** ISO date-time — bookings created on/after. */
|
|
createdFrom?: string;
|
|
/** ISO date-time — bookings created on/before (pass end-of-day for inclusive). */
|
|
createdTo?: string;
|
|
/** ISO date-time — bookings scheduled on/after. */
|
|
scheduledFrom?: string;
|
|
/** ISO date-time — bookings scheduled on/before (pass end-of-day for inclusive). */
|
|
scheduledTo?: string;
|
|
originYardId?: string;
|
|
destinationYardId?: string;
|
|
/** SHIPPING_LINE = booked by a shipping line; CUSTOMER = ordinary customer company. */
|
|
customerKind?: "SHIPPING_LINE" | "CUSTOMER";
|
|
/** "true" = government bookings only, "false" = private only. */
|
|
isGovernment?: "true" | "false";
|
|
/** Free-text search: booking reference, customer name, contract reference (server-side). */
|
|
search?: string;
|
|
page?: number;
|
|
pageSize?: number;
|
|
sortBy?: string;
|
|
sortOrder?: "ASC" | "DESC";
|
|
}
|
|
|
|
export interface PaginatedBookings {
|
|
items: BookingDetail[];
|
|
total: number;
|
|
}
|
|
|
|
export interface BookingListSummaryMetrics {
|
|
inQueue: number;
|
|
onThisPage: number;
|
|
needsAction: number;
|
|
urgent: number;
|
|
}
|
|
|
|
export interface BookingListSummaryTabs {
|
|
all: number;
|
|
intake: number;
|
|
in_approval: number;
|
|
approved_contract: number;
|
|
payment: number;
|
|
operations: number;
|
|
completed: number;
|
|
closed: number;
|
|
}
|
|
|
|
export interface BookingListSummary {
|
|
metrics: BookingListSummaryMetrics;
|
|
tabs: BookingListSummaryTabs;
|
|
}
|
|
|
|
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;
|
|
stampImageUrl?: string | null;
|
|
} | null;
|
|
}
|
|
|
|
export interface ConsolidationWagonSlot {
|
|
containerTypeCode: string;
|
|
quantity: number;
|
|
containersPerWagon: number;
|
|
remainder: number;
|
|
slotsNeeded: number;
|
|
}
|
|
|
|
export interface ConsolidationDetails {
|
|
statusMessage: string;
|
|
wagonSlots: ConsolidationWagonSlot[];
|
|
partner: { id: string; reference: string } | null;
|
|
splitBilling: { bookingShare: number; partnerShare: number } | null;
|
|
}
|
|
|
|
/**
|
|
* No stamp field: the backoffice only ever signs as STAFF, and EDR's seal is
|
|
* the ONE global company stamp applied server-side. Customer stamps are posted
|
|
* from the portal, not here.
|
|
*/
|
|
export interface SignContractPayload {
|
|
role: "CUSTOMER" | "STAFF";
|
|
signatureImageBase64: string;
|
|
signerDisplayName: string;
|
|
consentText?: string;
|
|
}
|
|
|
|
async function postBooking<T>(url: string, body?: unknown): Promise<T> {
|
|
const response = await client.post<T>(url, body ?? {});
|
|
return unwrap(response.data);
|
|
}
|
|
|
|
export const bookingsService = {
|
|
getListSummary: async (filter?: BookingListFilter): Promise<BookingListSummary> => {
|
|
const params: Record<string, string | number | boolean | undefined> = {};
|
|
if (filter) {
|
|
if (filter.statuses) params.statuses = filter.statuses;
|
|
else if (filter.status) params.status = filter.status;
|
|
if (filter.page != null) params.page = filter.page;
|
|
if (filter.pageSize != null) params.pageSize = filter.pageSize;
|
|
if (filter.companyId) params.companyId = filter.companyId;
|
|
if (filter.freightType) params.freightType = filter.freightType;
|
|
if (filter.bookingType) params.bookingType = filter.bookingType;
|
|
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
|
|
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
|
|
if (filter.paymentStatus) params.paymentStatus = filter.paymentStatus;
|
|
if (filter.createdFrom) params.createdFrom = filter.createdFrom;
|
|
if (filter.createdTo) params.createdTo = filter.createdTo;
|
|
if (filter.scheduledFrom) params.scheduledFrom = filter.scheduledFrom;
|
|
if (filter.scheduledTo) params.scheduledTo = filter.scheduledTo;
|
|
if (filter.originYardId) params.originYardId = filter.originYardId;
|
|
if (filter.destinationYardId) params.destinationYardId = filter.destinationYardId;
|
|
if (filter.isGovernment) params.isGovernment = filter.isGovernment;
|
|
if (filter.customerKind) params.customerKind = filter.customerKind;
|
|
}
|
|
const response = await client.get<BookingListSummary>(B.LIST_SUMMARY, {
|
|
params,
|
|
});
|
|
return unwrap(response.data) as BookingListSummary;
|
|
},
|
|
|
|
list: async (filter?: BookingListFilter): Promise<PaginatedBookings> => {
|
|
const params: Record<string, string | number | boolean | undefined> = {};
|
|
if (filter) {
|
|
if (filter.statuses) params.statuses = filter.statuses;
|
|
else if (filter.status) params.status = filter.status;
|
|
// filter.tab is intentionally omitted from API params
|
|
if (filter.page != null) params.page = filter.page;
|
|
if (filter.pageSize != null) params.pageSize = filter.pageSize;
|
|
if (filter.sortBy) params.sortBy = filter.sortBy;
|
|
if (filter.sortOrder) params.sortOrder = filter.sortOrder;
|
|
if (filter.schedulingStatuses) params.schedulingStatuses = filter.schedulingStatuses;
|
|
if (filter.assignedToSchedule) params.assignedToSchedule = filter.assignedToSchedule;
|
|
if (filter.companyId) params.companyId = filter.companyId;
|
|
if (filter.contractId) params.contractId = filter.contractId;
|
|
if (filter.freightType) params.freightType = filter.freightType;
|
|
if (filter.bookingType) params.bookingType = filter.bookingType;
|
|
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
|
|
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
|
|
if (filter.paymentStatus) params.paymentStatus = filter.paymentStatus;
|
|
if (filter.createdFrom) params.createdFrom = filter.createdFrom;
|
|
if (filter.createdTo) params.createdTo = filter.createdTo;
|
|
if (filter.scheduledFrom) params.scheduledFrom = filter.scheduledFrom;
|
|
if (filter.scheduledTo) params.scheduledTo = filter.scheduledTo;
|
|
if (filter.originYardId) params.originYardId = filter.originYardId;
|
|
if (filter.destinationYardId) params.destinationYardId = filter.destinationYardId;
|
|
if (filter.isGovernment) params.isGovernment = filter.isGovernment;
|
|
if (filter.customerKind) params.customerKind = filter.customerKind;
|
|
if (filter.customsClearingEnabled)
|
|
params.customsClearingEnabled = filter.customsClearingEnabled;
|
|
if (filter.search) params.search = filter.search;
|
|
}
|
|
const response = await client.get<PaginatedBookings>(B.BASE, {
|
|
params,
|
|
});
|
|
const data = unwrap(response.data);
|
|
return {
|
|
items: (data.items ?? []) as BookingDetail[],
|
|
total: data.total ?? 0,
|
|
};
|
|
},
|
|
|
|
getById: async (id: string): Promise<BookingDetail> => {
|
|
const response = await client.get<BookingDetail>(B.BY_ID(id));
|
|
return unwrap(response.data) as BookingDetail;
|
|
},
|
|
|
|
remove: async (id: string): Promise<void> => {
|
|
await client.delete(B.BY_ID(id));
|
|
},
|
|
|
|
// ── Document clearance (GL workflow) ──
|
|
reviewClearanceDocument: (
|
|
id: string,
|
|
payload: { fileKey: string; status: "APPROVED" | "QUERIED"; note?: string },
|
|
) => postBooking<BookingDetail>(`/bookings/${id}/clearance/review`, payload),
|
|
|
|
uploadClearanceOutput: async (
|
|
id: string,
|
|
files: Record<string, File | null>,
|
|
): Promise<BookingDetail> => {
|
|
const form = new FormData();
|
|
for (const [key, file] of Object.entries(files)) {
|
|
if (file) form.append(key, file);
|
|
}
|
|
const response = await client.post(
|
|
`/bookings/${id}/clearance/output-documents`,
|
|
form,
|
|
{ headers: { "Content-Type": "multipart/form-data" } },
|
|
);
|
|
return unwrap(response.data) as BookingDetail;
|
|
},
|
|
|
|
finalizeClearance: (id: string) =>
|
|
postBooking<BookingDetail>(`/bookings/${id}/clearance/finalize`),
|
|
|
|
staffAccept: (id: string, validityDays: number) =>
|
|
postBooking<BookingDetail>(B.STAFF_ACCEPT(id), { validityDays }),
|
|
|
|
requestChanges: (id: string, note: string) =>
|
|
postBooking<BookingDetail>(B.STAFF_REQUEST_CHANGES(id), { note }),
|
|
|
|
staffReject: (id: string, reason: string) =>
|
|
postBooking<BookingDetail>(B.STAFF_REJECT(id), { reason }),
|
|
|
|
/** Marketing/operations review of a drawdown order's operation request. */
|
|
reviewOperation: (
|
|
id: string,
|
|
decision: "ACCEPT" | "REQUEST_CHANGES",
|
|
options: { note?: string } = {},
|
|
) =>
|
|
postBooking<BookingDetail>(`/bookings/${id}/operation/review`, {
|
|
decision,
|
|
...options,
|
|
}),
|
|
|
|
/**
|
|
* Re-request operation on a booking Operations sent back for changes. The
|
|
* customer path uses the same endpoint from the portal; GL needs it here
|
|
* because a customs booking is GL's to fix, not the customer's.
|
|
*/
|
|
proceedToOperation: (id: string, scheduledDate: string) =>
|
|
postBooking<BookingDetail>(B.CLEARANCE_PROCEED(id), { scheduledDate }),
|
|
|
|
generateContract: (id: string) =>
|
|
postBooking<BookingDetail>(B.CONTRACT_GENERATE(id)),
|
|
|
|
getContractView: async (id: string): Promise<ContractView> => {
|
|
const response = await client.get<ContractView>(B.CONTRACT_VIEW(id));
|
|
return unwrap(response.data) as ContractView;
|
|
},
|
|
|
|
downloadContract: async (id: string): Promise<Blob> => {
|
|
const response = await client.get(B.CONTRACT_DOWNLOAD(id), {
|
|
responseType: "blob",
|
|
});
|
|
return ensurePdfBlob(response.data as Blob);
|
|
},
|
|
|
|
downloadContractDocument: async (id: string): Promise<Blob> => {
|
|
const response = await client.get(B.CONTRACT_DOCUMENT(id), {
|
|
responseType: "blob",
|
|
});
|
|
return ensurePdfBlob(response.data as Blob);
|
|
},
|
|
|
|
signContract: (id: string, payload: SignContractPayload) =>
|
|
postBooking<BookingDetail>(B.CONTRACT_SIGN(id), payload),
|
|
|
|
getSummary: async (id: string): Promise<{ summary: string }> => {
|
|
const response = await client.get<{ summary: string }>(B.SUMMARY(id));
|
|
return unwrap(response.data);
|
|
},
|
|
|
|
getConsolidationDetails: async (
|
|
id: string,
|
|
): Promise<ConsolidationDetails> => {
|
|
const response = await client.get<ConsolidationDetails>(B.CONSOLIDATION(id));
|
|
return unwrap(response.data) as ConsolidationDetails;
|
|
},
|
|
|
|
customerSign: (id: string, payload: SignContractPayload) =>
|
|
postBooking<BookingDetail>(B.CUSTOMER_SIGN(id), {
|
|
...payload,
|
|
role: "CUSTOMER",
|
|
}),
|
|
|
|
marketingApprove: (id: string, payload: SignContractPayload) =>
|
|
postBooking<BookingDetail>(B.MARKETING_APPROVE(id), {
|
|
...payload,
|
|
role: "STAFF",
|
|
}),
|
|
|
|
payBooking: (id: string) => postBooking<BookingDetail>(B.PAYMENT_PAY(id)),
|
|
|
|
startTransit: (id: string) =>
|
|
postBooking<BookingDetail>(B.START_TRANSIT(id)),
|
|
|
|
complete: (id: string) => postBooking<BookingDetail>(B.COMPLETE(id)),
|
|
|
|
cancel: (id: string, reason: string) =>
|
|
postBooking<BookingDetail>(B.CANCEL(id), { reason }),
|
|
|
|
/**
|
|
* Apply one staff decision to BOTH halves of a consolidated pair. The two
|
|
* bookings share a wagon, so they advance or cancel together — all-or-nothing
|
|
* on the server. Each half keeps its own invoice and payment.
|
|
*/
|
|
pairedDecision: async (
|
|
id: string,
|
|
decision: "accept" | "cancel" | "operationAccept" | "requestChanges",
|
|
options: { reason?: string; note?: string; validityDays?: number } = {},
|
|
): Promise<{ booking: BookingDetail; partner: BookingDetail }> => {
|
|
const response = await client.post(B.PAIRED_DECISION(id), {
|
|
decision,
|
|
...options,
|
|
});
|
|
return unwrap(response.data) as {
|
|
booking: BookingDetail;
|
|
partner: BookingDetail;
|
|
};
|
|
},
|
|
|
|
create: async (payload: Record<string, unknown>): Promise<BookingDetail> => {
|
|
const response = await client.post<{ booking: BookingDetail } | BookingDetail>(
|
|
B.BASE,
|
|
payload,
|
|
);
|
|
const data = unwrap(response.data) as { booking?: BookingDetail };
|
|
return (data.booking ?? data) as BookingDetail;
|
|
},
|
|
|
|
getReferenceData: async () => {
|
|
const response = await client.get(B.REFERENCE_DATA);
|
|
return unwrap(response.data);
|
|
},
|
|
|
|
governmentExpedite: (id: string) =>
|
|
postBooking<BookingDetail>(B.GOVERNMENT_EXPEDITE(id)),
|
|
|
|
getClearance: async (id: string): Promise<Freight.ClearanceView> => {
|
|
const response = await client.get(B.CLEARANCE(id));
|
|
return unwrap(response.data) as Freight.ClearanceView;
|
|
},
|
|
|
|
/** GL ET asks Djibouti to name the officer handling the shipment in transit. */
|
|
requestTransitAssignee: (id: string, note?: string) =>
|
|
postBooking<BookingDetail>(B.CLEARANCE_TRANSIT_ASSIGNEE_REQUEST(id), {
|
|
note,
|
|
}),
|
|
|
|
/** GL Djibouti picks (or changes) that officer — unblocks the declaration. */
|
|
assignTransitAssignee: (id: string, transitAgentId: string) =>
|
|
postBooking<BookingDetail>(B.CLEARANCE_TRANSIT_ASSIGNEE_ASSIGN(id), {
|
|
transitAgentId,
|
|
}),
|
|
|
|
uploadDeclaration: async (
|
|
id: string,
|
|
files: Record<string, File | null>,
|
|
): Promise<BookingDetail> => {
|
|
const form = new FormData();
|
|
for (const [key, file] of Object.entries(files)) {
|
|
if (file) form.append(key, file);
|
|
}
|
|
const response = await client.post(B.CLEARANCE_DECLARATION(id), form, {
|
|
headers: { "Content-Type": "multipart/form-data" },
|
|
});
|
|
return unwrap(response.data) as BookingDetail;
|
|
},
|
|
|
|
adviseDuty: async (
|
|
id: string,
|
|
payload: {
|
|
dutyRequired: boolean;
|
|
amount?: number;
|
|
currency?: string;
|
|
declarationSerial?: string;
|
|
attachment?: File | null;
|
|
},
|
|
): Promise<BookingDetail> => {
|
|
const form = new FormData();
|
|
form.append("dutyRequired", String(payload.dutyRequired));
|
|
if (payload.amount != null) form.append("amount", String(payload.amount));
|
|
if (payload.currency) form.append("currency", payload.currency);
|
|
if (payload.declarationSerial) {
|
|
form.append("declarationSerial", payload.declarationSerial);
|
|
}
|
|
if (payload.attachment) form.append("attachment", payload.attachment);
|
|
const response = await client.post(B.CLEARANCE_DUTY(id), form, {
|
|
headers: { "Content-Type": "multipart/form-data" },
|
|
});
|
|
return unwrap(response.data) as BookingDetail;
|
|
},
|
|
|
|
uploadDraftDeclaration: async (
|
|
id: string,
|
|
files: File[],
|
|
price: number,
|
|
currency: string,
|
|
): Promise<BookingDetail> => {
|
|
const form = new FormData();
|
|
files.forEach((file, index) => form.append(`draft_declaration_${index}`, file));
|
|
form.append("price", String(price));
|
|
form.append("currency", currency);
|
|
const response = await client.post(B.CLEARANCE_DRAFT_DECLARATION(id), form, {
|
|
headers: { "Content-Type": "multipart/form-data" },
|
|
});
|
|
return unwrap(response.data) as BookingDetail;
|
|
},
|
|
|
|
finalizePreClearance: (id: string) =>
|
|
postBooking<BookingDetail>(B.CLEARANCE_FINALIZE_PRE(id)),
|
|
|
|
uploadTransitPermit: async (
|
|
id: string,
|
|
files: Record<string, File | null>,
|
|
): Promise<BookingDetail> => {
|
|
const form = new FormData();
|
|
for (const [key, file] of Object.entries(files)) {
|
|
if (file) form.append(key, file);
|
|
}
|
|
const response = await client.post(B.CLEARANCE_TRANSIT_PERMIT(id), form, {
|
|
headers: { "Content-Type": "multipart/form-data" },
|
|
});
|
|
return unwrap(response.data) as BookingDetail;
|
|
},
|
|
|
|
uploadDeliveryOrder: async (
|
|
id: string,
|
|
files: File[],
|
|
dates: { vesselArrivalDate: string; doCollectedDate: string },
|
|
): Promise<BookingDetail> => {
|
|
const form = new FormData();
|
|
files.forEach((file) => form.append("files", file));
|
|
form.append("vesselArrivalDate", dates.vesselArrivalDate);
|
|
form.append("doCollectedDate", dates.doCollectedDate);
|
|
const response = await client.post(B.CLEARANCE_DELIVERY_ORDER(id), form, {
|
|
headers: { "Content-Type": "multipart/form-data" },
|
|
});
|
|
return unwrap(response.data) as BookingDetail;
|
|
},
|
|
|
|
uploadReleaseOrder: async (
|
|
id: string,
|
|
files: File[],
|
|
vesselDepartureDate: string,
|
|
): Promise<{ hold?: boolean; holdReason?: string }> => {
|
|
const form = new FormData();
|
|
files.forEach((file) => form.append("files", file));
|
|
form.append("vesselDepartureDate", vesselDepartureDate);
|
|
const response = await client.post(B.CLEARANCE_RELEASE_ORDER(id), form, {
|
|
headers: { "Content-Type": "multipart/form-data" },
|
|
});
|
|
return unwrap(response.data) as { hold?: boolean; holdReason?: string };
|
|
},
|
|
|
|
requestRoAmendment: (id: string, note?: string) =>
|
|
postBooking<BookingDetail>(B.CLEARANCE_RO_AMENDMENT(id), { note }),
|
|
|
|
confirmExportRelease: (id: string) =>
|
|
postBooking<BookingDetail>(B.CLEARANCE_EXPORT_RELEASE(id), {}),
|
|
|
|
getEtClearanceQueue: async (): Promise<BookingDetail[]> => {
|
|
const response = await client.get(B.CLEARANCE_ET_QUEUE);
|
|
return (unwrap(response.data) ?? []) as BookingDetail[];
|
|
},
|
|
|
|
setExportHandoverMode: async (
|
|
id: string,
|
|
exportHandoverMode: "DIRECT_TO_TRAIN" | "WAREHOUSE",
|
|
): Promise<void> => {
|
|
await client.patch(B.EXPORT_HANDOVER_MODE(id), { exportHandoverMode });
|
|
},
|
|
|
|
downloadCarriageAcceptanceSheet: async (id: string): Promise<Blob> => {
|
|
const response = await client.get(B.CARRIAGE_ACCEPTANCE_SHEET(id), {
|
|
responseType: "blob",
|
|
});
|
|
return ensurePdfBlob(response.data as Blob);
|
|
},
|
|
|
|
getDjClearanceQueue: async (): Promise<BookingDetail[]> => {
|
|
const response = await client.get(B.CLEARANCE_DJ_QUEUE);
|
|
return (unwrap(response.data) ?? []) as BookingDetail[];
|
|
},
|
|
};
|
|
|
|
async function ensurePdfBlob(blob: Blob): Promise<Blob> {
|
|
if (blob.type.includes("application/json")) {
|
|
const body = JSON.parse(await blob.text()) as { message?: string };
|
|
throw new Error(body.message ?? "Contract PDF download failed");
|
|
}
|
|
return blob;
|
|
}
|