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(url: string, body?: unknown): Promise { const response = await client.post(url, body ?? {}); return unwrap(response.data); } export const bookingsService = { getListSummary: async (filter?: BookingListFilter): Promise => { const params: Record = {}; 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(B.LIST_SUMMARY, { params, }); return unwrap(response.data) as BookingListSummary; }, list: async (filter?: BookingListFilter): Promise => { const params: Record = {}; 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(B.BASE, { params, }); const data = unwrap(response.data); return { items: (data.items ?? []) as BookingDetail[], total: data.total ?? 0, }; }, getById: async (id: string): Promise => { const response = await client.get(B.BY_ID(id)); return unwrap(response.data) as BookingDetail; }, remove: async (id: string): Promise => { await client.delete(B.BY_ID(id)); }, // ── Document clearance (GL workflow) ── reviewClearanceDocument: ( id: string, payload: { fileKey: string; status: "APPROVED" | "QUERIED"; note?: string }, ) => postBooking(`/bookings/${id}/clearance/review`, payload), uploadClearanceOutput: async ( id: string, files: Record, ): Promise => { 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(`/bookings/${id}/clearance/finalize`), staffAccept: (id: string, validityDays: number) => postBooking(B.STAFF_ACCEPT(id), { validityDays }), requestChanges: (id: string, note: string) => postBooking(B.STAFF_REQUEST_CHANGES(id), { note }), staffReject: (id: string, reason: string) => postBooking(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(`/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(B.CLEARANCE_PROCEED(id), { scheduledDate }), generateContract: (id: string) => postBooking(B.CONTRACT_GENERATE(id)), getContractView: async (id: string): Promise => { const response = await client.get(B.CONTRACT_VIEW(id)); return unwrap(response.data) as ContractView; }, downloadContract: async (id: string): Promise => { const response = await client.get(B.CONTRACT_DOWNLOAD(id), { responseType: "blob", }); return ensurePdfBlob(response.data as Blob); }, downloadContractDocument: async (id: string): Promise => { const response = await client.get(B.CONTRACT_DOCUMENT(id), { responseType: "blob", }); return ensurePdfBlob(response.data as Blob); }, signContract: (id: string, payload: SignContractPayload) => postBooking(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 => { const response = await client.get(B.CONSOLIDATION(id)); return unwrap(response.data) as ConsolidationDetails; }, customerSign: (id: string, payload: SignContractPayload) => postBooking(B.CUSTOMER_SIGN(id), { ...payload, role: "CUSTOMER", }), marketingApprove: (id: string, payload: SignContractPayload) => postBooking(B.MARKETING_APPROVE(id), { ...payload, role: "STAFF", }), payBooking: (id: string) => postBooking(B.PAYMENT_PAY(id)), startTransit: (id: string) => postBooking(B.START_TRANSIT(id)), complete: (id: string) => postBooking(B.COMPLETE(id)), cancel: (id: string, reason: string) => postBooking(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): Promise => { 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(B.GOVERNMENT_EXPEDITE(id)), getClearance: async (id: string): Promise => { 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(B.CLEARANCE_TRANSIT_ASSIGNEE_REQUEST(id), { note, }), /** GL Djibouti picks (or changes) that officer — unblocks the declaration. */ assignTransitAssignee: (id: string, transitAgentId: string) => postBooking(B.CLEARANCE_TRANSIT_ASSIGNEE_ASSIGN(id), { transitAgentId, }), uploadDeclaration: async ( id: string, files: Record, ): Promise => { 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 => { 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 => { 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(B.CLEARANCE_FINALIZE_PRE(id)), uploadTransitPermit: async ( id: string, files: Record, ): Promise => { 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 => { 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(B.CLEARANCE_RO_AMENDMENT(id), { note }), confirmExportRelease: (id: string) => postBooking(B.CLEARANCE_EXPORT_RELEASE(id), {}), getEtClearanceQueue: async (): Promise => { 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 => { await client.patch(B.EXPORT_HANDOVER_MODE(id), { exportHandoverMode }); }, downloadCarriageAcceptanceSheet: async (id: string): Promise => { const response = await client.get(B.CARRIAGE_ACCEPTANCE_SHEET(id), { responseType: "blob", }); return ensurePdfBlob(response.data as Blob); }, getDjClearanceQueue: async (): Promise => { const response = await client.get(B.CLEARANCE_DJ_QUEUE); return (unwrap(response.data) ?? []) as BookingDetail[]; }, }; async function ensurePdfBlob(blob: Blob): Promise { 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; }