Files
edr-platform/apps/edr-freight-web/backoffice/src/services/bookings.service.ts
Marshal e2189040fa feat: add pagination to schedule history and consolidation approvals
- Implemented pagination in ScheduleHistoryPanel to manage large history entries.
- Updated API to support pagination parameters for schedule history.
- Enhanced ConsolidationApprovalsPage with tabbed navigation and pagination for approval rows.
- Introduced new types for paginated responses in bookings and train scheduling services.
- Added a database migration to create an index on wagon_booking_allocations for performance improvements.
2026-08-23 04:49:58 +00:00

784 lines
27 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;
/**
* One shared-wagon approval. Covers BOTH bookings on the wagon — the pair is
* decided as a unit, never one side at a time.
*/
export interface ConsolidationApprovalRow {
id: string;
bookingId: string;
partnerBookingId: string;
status: "PENDING" | "APPROVED" | "REJECTED";
requestedBy?: string | null;
requestedAt: string;
decidedBy?: string | null;
/** Display name of the approver/rejecter — the id alone means nothing. */
decidedByName?: string | null;
requestedByName?: string | null;
decidedAt?: string | null;
decisionNote?: string | null;
scheduledDate?: string | null;
bookingReference?: string | null;
partnerBookingReference?: string | null;
booking?: {
id: string;
reference?: string;
company?: { name?: string } | null;
} | null;
partnerBooking?: {
id: string;
reference?: string;
company?: { name?: string } | null;
} | null;
}
/** One page of approval rows plus the whole-queue counts behind the tabs. */
export interface ConsolidationApprovalPage {
items: ConsolidationApprovalRow[];
total: number;
counts: Record<ConsolidationApprovalRow["status"], number>;
meta: {
page: number;
pageSize: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}
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;
/** Service type (rule-engine service_types.id). */
serviceTypeId?: 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.serviceTypeId) params.serviceTypeId = filter.serviceTypeId;
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.serviceTypeId) params.serviceTypeId = filter.serviceTypeId;
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 }),
// ── Shared-wagon approval gate ──────────────────────────────────────────
/**
* One page of the gate. `status` picks the tab; the counts come back for all
* three tabs regardless, so the badges show the whole queue and not the page.
*/
consolidationApprovalQueue: async (
params: {
status?: ConsolidationApprovalRow["status"];
page?: number;
pageSize?: number;
} = {},
): Promise<ConsolidationApprovalPage> => {
const response = await client.get(B.CONSOLIDATION_APPROVAL_QUEUE, {
params,
});
const data = unwrap(response.data) as ConsolidationApprovalPage | null;
return (
data ?? {
items: [],
total: 0,
counts: { PENDING: 0, APPROVED: 0, REJECTED: 0 },
meta: {
page: 1,
pageSize: params.pageSize ?? 10,
total: 0,
totalPages: 0,
hasNextPage: false,
hasPreviousPage: false,
},
}
);
},
/** Decision history for one booking's shared wagon — who, when, and why. */
consolidationApprovalHistory: async (
bookingId: string,
): Promise<ConsolidationApprovalRow[]> => {
const response = await client.get(
B.CONSOLIDATION_APPROVAL_HISTORY(bookingId),
);
return (unwrap(response.data) ?? []) as ConsolidationApprovalRow[];
},
/** Approve: both bookings leave the gate and continue to Operations. */
approveConsolidation: async (approvalId: string, note?: string) => {
const response = await client.post(B.CONSOLIDATION_APPROVE(approvalId), {
note,
});
return unwrap(response.data);
},
/** Reject: both bookings go back to GL for changes with the reason. */
rejectConsolidation: async (approvalId: string, reason: string) => {
const response = await client.post(B.CONSOLIDATION_REJECT(approvalId), {
reason,
});
return unwrap(response.data);
},
/**
* 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 asks the customer for additional clearance document(s). */
requestAdditionalDocuments: async (
id: string,
note: string,
): Promise<void> => {
await client.post(`/bookings/${id}/clearance/doc-requests`, { note });
},
/** Clearance action history — reviews, workflow steps, charges (newest first). */
getClearanceHistory: async (
id: string,
): Promise<Freight.ClearanceHistoryEvent[]> => {
const response = await client.get(`/bookings/${id}/clearance/history`);
return unwrap(response.data) as Freight.ClearanceHistoryEvent[];
},
// ── Clearance charges (post-finalization customer billing) ──
getClearanceCharges: async (
id: string,
): Promise<Freight.ClearanceCharge[]> => {
const response = await client.get(`/bookings/${id}/clearance/charges`);
return unwrap(response.data) as Freight.ClearanceCharge[];
},
/** GL Djibouti uploads (or replaces, until billed) the port-charges document. */
uploadPortChargeDocument: async (
id: string,
file: File,
): Promise<Freight.ClearanceCharge[]> => {
const form = new FormData();
form.append("file", file);
const response = await client.post(
`/bookings/${id}/clearance/charges/port-document`,
form,
{ headers: { "Content-Type": "multipart/form-data" } },
);
return unwrap(response.data) as Freight.ClearanceCharge[];
},
/** GL Ethiopia sets or revises a charge's amount, currency and description. */
billClearanceCharge: async (
id: string,
chargeId: string,
payload: { amount: number; currency: string; description?: string },
): Promise<Freight.ClearanceCharge[]> => {
const response = await client.patch(
`/bookings/${id}/clearance/charges/${chargeId}/bill`,
payload,
);
return unwrap(response.data) as Freight.ClearanceCharge[];
},
/** GL Ethiopia sends the priced charge to the customer for approval. */
sendClearanceCharge: async (
id: string,
chargeId: string,
): Promise<Freight.ClearanceCharge[]> => {
const response = await client.post(
`/bookings/${id}/clearance/charges/${chargeId}/send`,
);
return unwrap(response.data) as Freight.ClearanceCharge[];
},
/** GL Ethiopia creates a miscellaneous charge (document + amount + currency + description). */
createMiscellaneousCharge: async (
id: string,
file: File,
payload: { amount: number; currency: string; description: string },
): Promise<Freight.ClearanceCharge[]> => {
const form = new FormData();
form.append("file", file);
form.append("amount", String(payload.amount));
form.append("currency", payload.currency);
form.append("description", payload.description);
const response = await client.post(
`/bookings/${id}/clearance/charges/miscellaneous`,
form,
{ headers: { "Content-Type": "multipart/form-data" } },
);
return unwrap(response.data) as Freight.ClearanceCharge[];
},
// ── Additional charges (ad-hoc finance billing) ──
getAdditionalCharges: async (
id: string,
): Promise<Freight.AdditionalCharge[]> => {
const response = await client.get(`/bookings/${id}/additional-charges`);
return unwrap(response.data) as Freight.AdditionalCharge[];
},
/** Finance raises a new charge — 'draft' just saves it, 'send' also issues the invoice and notifies the customer. */
createAdditionalCharge: async (
id: string,
payload: {
reason: string;
amount: number;
currency: string;
action: "draft" | "send";
file?: File | null;
},
): Promise<Freight.AdditionalCharge[]> => {
const form = new FormData();
form.append("reason", payload.reason);
form.append("amount", String(payload.amount));
form.append("currency", payload.currency);
form.append("action", payload.action);
if (payload.file) form.append("file", payload.file);
const response = await client.post(
`/bookings/${id}/additional-charges`,
form,
{
headers: { "Content-Type": "multipart/form-data" },
},
);
return unwrap(response.data) as Freight.AdditionalCharge[];
},
/** Issues the draft charge's payable invoice and notifies the customer. */
sendAdditionalCharge: async (
id: string,
chargeId: string,
): Promise<Freight.AdditionalCharge[]> => {
const response = await client.post(
`/bookings/${id}/additional-charges/${chargeId}/send`,
);
return unwrap(response.data) as Freight.AdditionalCharge[];
},
/** Withdraws a draft or unpaid additional charge. */
cancelAdditionalCharge: async (
id: string,
chargeId: string,
reason?: string,
): Promise<Freight.AdditionalCharge[]> => {
const response = await client.post(
`/bookings/${id}/additional-charges/${chargeId}/cancel`,
{ reason },
);
return unwrap(response.data) as Freight.AdditionalCharge[];
},
/** 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;
}