import { api as client } from "../auth/http"; import { unwrap } from "@/utils/endpoint"; import { URL_CONSTANTS } from "@/constants/URLS"; import type { Freight } from "@edr/types"; const C = URL_CONSTANTS.CONTRACTS; export interface ContractListFilter { status?: string; /** Comma-separated statuses for grouped tabs. */ statuses?: string; /** Tab key for React Query cache (not sent to API). */ tab?: string; companyId?: string; freightType?: string; /** Service type (rule-engine service_types.id). */ serviceTypeId?: string; tradeDirection?: string; contractKind?: string; paymentCurrency?: string; /** Created-at range (ISO strings, inclusive). */ createdFrom?: string; createdTo?: string; /** Server-side free-text search (contract reference, company name). */ search?: string; page?: number; pageSize?: number; sortBy?: string; sortOrder?: "ASC" | "DESC"; } export interface PaginatedContracts { items: Freight.IContract[]; total: number; } /** One line of the server-priced booking breakdown (mirrors PriceLineItemDto). */ export interface ShipmentPriceLine { code: string; description: string; amount: number; unitAmount: number; /** Rate unit as stored: PER_CONTAINER | PER_WAGON | PER_TON | PER_KM | FLAT | … */ unit: string; quantity: number; currency: string; } /** * Pre-create validation + authoritative price preview for a booking under a * contract. `lineItems`/`totalAmount` are the full server-computed breakdown — * the same pricing pass the booking persists at create (rail freight, * first/last mile, overweight and every other surcharge). `pairingErrors` and * `capacityErrors` are HARD BLOCKS; `overweightLines` are warnings. */ export interface ShipmentValidation { overweightLines: Array<{ containerTypeCode: string; /** Container number, or " #2" when unnumbered. */ containerLabel: string; /** This container's VGM — the limit is per container, never pooled. */ totalVgmTons: number; maxAllowedTons: number; excessTons: number; }>; overweightSurchargeAmount: number; currency: string | null; pairingErrors: string[]; /** Lines above the container type's hard max capacity — booking cannot be created. */ capacityErrors?: string[]; /** Containers already on another active booking for the same day + route — booking cannot be created. */ containerClashErrors?: string[]; /** EXPORT only: no single open train on the chosen day can carry the whole booking — booking cannot be created. */ spaceErrors?: string[]; lineItems?: ShipmentPriceLine[]; totalAmount?: number; } /** * A booking GL may pick as the shared-wagon partner of an odd-20ft customs * booking. `hasCargo` is false for a bare instance whose containers GL still * enters on the split completion form. */ export interface ConsolidationCandidate { id: string; reference: string; contractId: string | null; companyName: string | null; status: string; tradeDirection: string | null; originYardId: string | null; destinationYardId: string | null; scheduledDate: string | null; ft20Quantity: number; hasCargo: boolean; } /** Both halves of a shared-wagon completion, each with its own full payload. */ export interface CompleteConsolidatedPairPayload { partnerBookingId: string; booking: Freight.CreateBookingUnderContractDto; partner: Freight.CreateBookingUnderContractDto; } export interface ContractListSummaryMetrics { inQueue: number; needsAction: number; urgent: number; completed: number; } export interface ContractListSummaryTabs { all: number; intake: number; in_approval: number; approved_contract: number; clearance: number; active: number; closed: number; } export interface ContractListSummary { metrics: ContractListSummaryMetrics; tabs: ContractListSummaryTabs; /** * Per-column value counts for the filter bar's enum popovers, scoped to * every OTHER currently-active filter. Optional: pages built before the * pill filter bar don't read it, and it degrades gracefully — an absent * key just means that popover shows no counts. */ facets?: Record; } export interface ContractView { contractId: string; reference: string; status: string; freightType: "BULK" | "CONTAINER"; templateKey: string; title: string; html: string; canSignCustomer: boolean; canSignStaff: boolean; hasContractDocument: boolean; signatures: Array<{ role: string; signerDisplayName: string; signedAt: string; signatureImageUrl?: string | null; stampImageUrl?: string | null; }>; savedSignature?: { signerDisplayName: string; signatureImageUrl?: string | null; stampImageUrl?: string | null; } | null; } /** * No stamp field: the backoffice only ever counter-signs as STAFF, and EDR's * seal is the ONE global company stamp, snapshotted server-side from * StampSettingsService when the signature is stored. */ export interface SignContractPayload { role: "CUSTOMER" | "STAFF"; signatureImageBase64: string; signerDisplayName: string; consentText?: string; } /** * One stored version of a clearance document. The current row plus every * superseded upload — staff corrections never erase what the customer sent. */ export interface ClearanceDocumentVersion { id: string; name: string; url: string; size: number; mimeType: string; uploadedAt: string; isCurrent: boolean; replacedAt: string | null; replacedByUserId: string | null; replaceReason: string | null; } async function postContract(url: string, body?: unknown): Promise { const response = await client.post(url, body ?? {}); return unwrap(response.data); } function buildListParams(filter?: ContractListFilter) { const params: Record = {}; if (filter) { if (filter.statuses) params.statuses = filter.statuses; else if (filter.status) params.status = filter.status; if (filter.search) params.search = filter.search; 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.companyId) params.companyId = filter.companyId; if (filter.freightType) params.freightType = filter.freightType; if (filter.serviceTypeId) params.serviceTypeId = filter.serviceTypeId; if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection; if (filter.contractKind) params.contractKind = filter.contractKind; if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency; if (filter.createdFrom) params.createdFrom = filter.createdFrom; if (filter.createdTo) params.createdTo = filter.createdTo; } return params; } export const contractsService = { getListSummary: async ( filter?: ContractListFilter, ): Promise => { const response = await client.get(C.LIST_SUMMARY, { params: buildListParams(filter), }); return unwrap(response.data) as ContractListSummary; }, list: async (filter?: ContractListFilter): Promise => { const response = await client.get(C.BASE, { params: buildListParams(filter), }); const data = unwrap(response.data); return { items: (data.items ?? []) as Freight.IContract[], total: data.total ?? 0, }; }, getById: async ( id: string, opts?: { suppressErrorModal?: boolean }, ): Promise => { const response = await client.get(C.BY_ID(id), opts); return unwrap(response.data) as Freight.IContract; }, // ── Staff review ── staffAccept: ( id: string, validityDays: number, documentSnapshot?: Freight.IContractDocumentSnapshot, window?: { validFrom?: string; validUntil?: string }, ) => postContract(C.STAFF_ACCEPT(id), { validityDays, documentSnapshot, ...window, }), /** The editable per-contract document draft (snapshot or live template). */ getContractDocumentDraft: async ( id: string, ): Promise => { const response = await client.get(C.CONTRACT_DOCUMENT_DRAFT(id)); return unwrap(response.data) as Freight.IContractDocumentDraft; }, /** Audit trail of edits to this contract's document, newest first. */ getContractDocumentRevisions: async ( id: string, ): Promise => { const response = await client.get(C.CONTRACT_DOCUMENT_REVISIONS(id)); return unwrap(response.data) as Freight.IContractDocumentRevision[]; }, /** Save this contract's edited document articles (never touches the templates). */ updateContractDocument: async ( id: string, snapshot: Freight.IContractDocumentSnapshot, ): Promise => { const response = await client.put( C.CONTRACT_DOCUMENT_ARTICLES(id), snapshot, ); return unwrap(response.data) as Freight.IContract; }, requestChanges: (id: string, note: string) => postContract(C.STAFF_REQUEST_CHANGES(id), { note }), reject: (id: string, reason: string) => postContract(C.STAFF_REJECT(id), { reason }), /** * Cancel a contract outright. Terminal — unlike {@link suspend} there is no * way back; a new contract with the same details must be submitted instead. */ cancelByStaff: (id: string, reason: string) => postContract(C.STAFF_CANCEL(id), { reason }), /** Freeze a signed contract. Reversible — see {@link resume}. */ suspend: (id: string, reason: string) => postContract(C.SUSPEND(id), { reason }), /** Lift a suspension; the contract returns to the status it was frozen at. */ resume: (id: string, note?: string) => postContract(C.RESUME(id), { note }), /** * Approve the next pending step. The server resolves the step's required role * and authorizes against it — the client never declares its own role. */ approveStep: ({ id, stepId }: { id: string; stepId: string }) => postContract(C.APPROVE_STEP(id, stepId)), /** * Reject the current step. Without `returnToStepId` the contract is rejected * to the customer (terminal). With it, the contract is sent back to that * earlier approved step and the chain re-runs from there. */ rejectStep: ({ id, stepId, reason, returnToStepId, }: { id: string; stepId: string; reason: string; returnToStepId?: string; }) => postContract(C.REJECT_STEP(id, stepId), { reason, ...(returnToStepId ? { returnToStepId } : {}), }), // ── Contract document ── generateContract: (id: string) => postContract(C.CONTRACT_GENERATE(id)), getContractView: async (id: string): Promise => { const response = await client.get(C.CONTRACT_VIEW(id)); return unwrap(response.data) as ContractView; }, downloadContractDocument: async (id: string): Promise => { const response = await client.get(C.CONTRACT_DOCUMENT(id), { responseType: "blob", }); return response.data as Blob; }, signContract: (id: string, payload: SignContractPayload) => postContract(C.CONTRACT_SIGN(id), payload), // ── Pre-booking clearance (Path B — GL ET) ── getClearanceQueue: async (): Promise => { const response = await client.get(C.CLEARANCE_QUEUE); const data = unwrap(response.data); return { items: (data.items ?? []) as Freight.IContract[], total: data.total ?? 0, }; }, getClearance: async ( id: string, opts?: { suppressErrorModal?: boolean }, ): Promise => { const response = await client.get(C.CLEARANCE(id), opts); return unwrap(response.data) as Freight.ContractClearanceView; }, reviewClearanceDocument: ( id: string, payload: { fileKey: string; status: "APPROVED" | "QUERIED"; note?: string }, ) => postContract(C.CLEARANCE_REVIEW(id), payload), /** GL ET asks Djibouti to name the officer handling the shipment in transit. */ requestTransitAssignee: (id: string, note?: string) => postContract( C.CLEARANCE_TRANSIT_ASSIGNEE_REQUEST(id), { note }, ), /** GL Djibouti picks (or changes) that officer — unblocks the declaration. */ assignTransitAssignee: (id: string, transitAgentId: string) => postContract(C.CLEARANCE_TRANSIT_ASSIGNEE_ASSIGN(id), { transitAgentId, }), /** * Replace a clearance document in place. The customer's original is retired * into the version history rather than overwritten, and the new file comes * back unreviewed so it still has to be approved. */ replaceClearanceDocument: async ( id: string, fileKey: string, file: File, reason: string, ): Promise => { const form = new FormData(); form.append("file", file); form.append("reason", reason); const response = await client.post( C.CLEARANCE_DOC_REPLACE(id, fileKey), form, { headers: { "Content-Type": "multipart/form-data" } }, ); return unwrap(response.data) as Freight.IContract; }, /** Every stored version of one clearance document, newest first. */ getClearanceDocumentVersions: async ( id: string, fileKey: string, ): Promise => { const response = await client.get(C.CLEARANCE_DOC_VERSIONS(id, fileKey)); return (unwrap(response.data) as ClearanceDocumentVersion[]) ?? []; }, 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(C.CLEARANCE_OUTPUT_DOCUMENTS(id), form, { headers: { "Content-Type": "multipart/form-data" }, }); return unwrap(response.data) as Freight.IContract; }, finalizeClearance: (id: string) => postContract(C.CLEARANCE_FINALIZE(id)), /** * GL worklist: executed one-time customs contracts with no shipment instance * yet. GL initiates the booking; the customer then uploads his clearance * documents on it. */ getAwaitingShipmentContracts: async (): Promise => { const response = await client.get(C.AWAITING_SHIPMENT); const data = unwrap(response.data); return (Array.isArray(data) ? data : (data?.items ?? [])) as Freight.IContract[]; }, /** Open a bare shipment instance under a contract (no cargo, no day). */ initiateBookingUnderContract: async ( id: string, contractRouteId?: string, ): Promise<{ id: string; reference: string }> => { const result = await postContract<{ booking?: { id: string; reference: string }; id?: string; reference?: string; }>(C.BOOKINGS_INITIATE(id), contractRouteId ? { contractRouteId } : {}); const booking = result.booking ?? result; return { id: booking.id ?? "", reference: booking.reference ?? "" }; }, 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(C.CLEARANCE_DECLARATION(id), form, { headers: { "Content-Type": "multipart/form-data" }, }); return unwrap(response.data) as Freight.IContract; }, adviseContractDuty: 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(C.CLEARANCE_DUTY(id), form, { headers: { "Content-Type": "multipart/form-data" }, }); return unwrap(response.data) as Freight.IContract; }, finalizePreClearance: (id: string) => postContract(C.CLEARANCE_FINALIZE_PRE(id)), uploadContractTransitPermit: 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(C.CLEARANCE_TRANSIT_PERMIT(id), form, { headers: { "Content-Type": "multipart/form-data" }, }); return unwrap(response.data) as Freight.IContract; }, 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(C.CLEARANCE_DELIVERY_ORDER(id), form, { headers: { "Content-Type": "multipart/form-data" }, }); return unwrap(response.data) as Freight.IContract; }, uploadReleaseOrder: async ( id: string, files: File[], vesselDepartureDate: string, ): Promise<{ contract: Freight.IContract; hold: boolean; holdReason?: string }> => { const form = new FormData(); files.forEach((file) => form.append("files", file)); form.append("vesselDepartureDate", vesselDepartureDate); const response = await client.post(C.CLEARANCE_RELEASE_ORDER(id), form, { headers: { "Content-Type": "multipart/form-data" }, }); return unwrap(response.data) as { contract: Freight.IContract; hold: boolean; holdReason?: string; }; }, requestRoAmendment: (id: string, note?: string) => postContract(C.CLEARANCE_RO_AMENDMENT(id), { note }), confirmExportRelease: (id: string) => postContract(C.CLEARANCE_EXPORT_RELEASE(id)), finalizeExportClearance: (id: string) => postContract(C.CLEARANCE_FINALIZE_EXPORT(id)), uploadTransportDocument: async ( bookingId: string, files: Record, ) => { const form = new FormData(); for (const [key, file] of Object.entries(files)) { if (file) form.append(key, file); } const response = await client.post(C.BOOKING_TRANSPORT_DOCUMENT(bookingId), form, { headers: { "Content-Type": "multipart/form-data" }, }); return unwrap(response.data); }, /** GL Djibouti uploads T1 transit documents (multi-file, post wagon allocation). */ uploadT1Documents: async ( bookingId: string, files: Record, ) => { const form = new FormData(); for (const [key, file] of Object.entries(files)) { if (file) form.append(key, file); } const response = await client.post(C.BOOKING_T1_DOCUMENTS(bookingId), form, { headers: { "Content-Type": "multipart/form-data" }, }); return unwrap(response.data); }, /** GL Ethiopia closes (accepts) the T1 document set after the train arrives. */ closeT1: async (bookingId: string): Promise => { const response = await client.post(C.BOOKING_T1_CLOSE(bookingId)); return unwrap(response.data) as Freight.ClearanceT1State; }, /** GL DJ raises the post-offload final invoice (amount + invoice document). */ sendFinalInvoice: async ( bookingId: string, payload: { amount: number; currency: string; description?: string; file: File }, ): Promise => { const form = new FormData(); form.append("amount", String(payload.amount)); form.append("currency", payload.currency); if (payload.description) form.append("description", payload.description); form.append("file", payload.file); const response = await client.post(C.BOOKING_FINAL_INVOICE(bookingId), form, { headers: { "Content-Type": "multipart/form-data" }, }); return unwrap(response.data) as Freight.ClearanceFinalInvoiceSummary; }, /** GL (ET or DJ) confirms the payment slip — settles the final invoice. */ confirmFinalInvoicePaid: async ( bookingId: string, ): Promise => { const response = await client.post(C.BOOKING_FINAL_INVOICE_CONFIRM(bookingId)); return unwrap(response.data) as Freight.ClearanceFinalInvoiceSummary; }, /** GL ET advises (or skips) the post-arrival additional duty/tax round (import). */ adviseSecondDuty: async ( bookingId: string, payload: { dutyRequired: boolean; amount?: number; currency?: string; declarationSerial?: string; attachment?: File | null; }, ): Promise<{ advised: boolean; skipped: boolean }> => { 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(C.BOOKING_SECOND_DUTY(bookingId), form, { headers: { "Content-Type": "multipart/form-data" }, }); return unwrap(response.data) as { advised: boolean; skipped: boolean }; }, getClearanceHistory: async (): Promise => { const response = await client.get(C.CLEARANCE_HISTORY); const data = unwrap(response.data); return { items: (data.items ?? []) as Freight.IContract[], total: data.total ?? 0 }; }, getOpsClearanceHistory: async (filter?: { page?: number; pageSize?: number; search?: string; }): Promise => { const response = await client.get( C.OPS_CLEARANCE_HISTORY, { params: filter }, ); const data = unwrap(response.data); return { items: (data.items ?? []) as Freight.IContract[], total: data.total ?? 0 }; }, opsReviewClearanceDocument: ( id: string, payload: { fileKey: string; status: "APPROVED" | "QUERIED"; note?: string }, ) => postContract(C.OPS_CLEARANCE_REVIEW(id), payload), opsFinalizeClearance: (id: string) => postContract(C.OPS_CLEARANCE_FINALIZE(id)), // ── Booking under contract (GL ET — Path B) ── // The API returns { booking, warnings } (CreateBookingUnderContractResult) — // unwrap to the booking itself so callers can use its id directly. createBookingUnderContract: async ( id: string, payload: Freight.CreateBookingUnderContractDto, ): Promise<{ id: string; reference: string; warnings?: string[] }> => { const result = await postContract<{ booking?: { id: string; reference: string }; id?: string; reference?: string; warnings?: string[]; }>(C.BOOKINGS(id), payload); const booking = result.booking ?? result; return { id: booking.id ?? "", reference: booking.reference ?? "", warnings: result.warnings, }; }, /** * Complete a bare initiated booking instance once its per-booking clearance * is CLEARANCE_READY — same payload as create; the API persists cargo, * prices, invoices, checks the booking window and moves the booking to the * operations queue. */ completeBookingUnderContract: async ( id: string, bookingId: string, payload: Freight.CreateBookingUnderContractDto, ): Promise<{ id: string; reference: string; warnings?: string[] }> => { const result = await postContract<{ booking?: { id: string; reference: string }; id?: string; reference?: string; warnings?: string[]; }>(C.BOOKINGS_COMPLETE(id, bookingId), payload); const booking = result.booking ?? result; return { id: booking.id ?? "", reference: booking.reference ?? "", warnings: result.warnings, }; }, /** * Bookings GL may link to an odd-20ft customs booking as its shared-wagon * partner (same route and direction, customs, odd 20ft, not already paired). */ listConsolidationCandidates: async ( id: string, bookingId: string, ): Promise => { const response = await client.get( C.CONSOLIDATION_CANDIDATES(id, bookingId), ); return (unwrap(response.data) ?? []) as ConsolidationCandidate[]; }, /** * Complete an odd-20ft booking together with the partner booking sharing its * wagon. All-or-nothing on the server: either both bookings complete and are * linked, or neither does. Each booking keeps its own price and its own * invoice — only the wagon is shared. */ completeConsolidatedPair: async ( id: string, bookingId: string, payload: CompleteConsolidatedPairPayload, ): Promise<{ booking: { id: string; reference: string }; partner: { id: string; reference: string }; warnings?: string[]; }> => { const response = await client.post( C.BOOKINGS_COMPLETE_CONSOLIDATED(id, bookingId), payload, ); return unwrap(response.data) as { booking: { id: string; reference: string }; partner: { id: string; reference: string }; warnings?: string[]; }; }, /** * Pre-create validation + authoritative price preview: the same * BookingPricingService pass that prices the booking on create (rail + * first/last mile + every surcharge), plus overweight warnings and 20ft * pairing hard-blocks. Shown in the GL price-confirm modal. */ validateShipment: ( id: string, payload: Freight.CreateBookingUnderContractDto, // Completion/resubmit preview: exclude this booking's own containers from // the same-train clash check. excludeBookingId?: string, ) => postContract( excludeBookingId ? `${C.VALIDATE_SHIPMENT(id)}?bookingId=${excludeBookingId}` : C.VALIDATE_SHIPMENT(id), payload, ), /** Remaining bookable quantity per cargo line (GENERAL draw-down cap). */ getCapacity: async (id: string): Promise => { const response = await client.get(C.CAPACITY(id)); return (unwrap(response.data) ?? []) as Freight.ContractCapacityLine[]; }, // ── Shipment requests (GENERAL + customs) ── /** GL queue of pending shipment requests across contracts. */ getBookingRequestQueue: async (): Promise => { const response = await client.get(C.BOOKING_REQUEST_QUEUE); return (unwrap(response.data) ?? []) as Freight.IBookingRequest[]; }, listBookingRequests: async ( id: string, ): Promise => { const response = await client.get(C.BOOKING_REQUESTS(id)); return (unwrap(response.data) ?? []) as Freight.IBookingRequest[]; }, getBookingRequest: async ( reqId: string, ): Promise => { const response = await client.get(C.BOOKING_REQUEST_BY_ID(reqId)); return unwrap(response.data) as Freight.IBookingRequest; }, acceptBookingRequest: (reqId: string, bookingId: string) => postContract(C.BOOKING_REQUEST_ACCEPT(reqId), { bookingId, }), rejectBookingRequest: (reqId: string, note?: string) => postContract(C.BOOKING_REQUEST_REJECT(reqId), { note, }), // ── Clearance milestones ── listMilestonesForContract: async ( id: string, ): Promise => { const response = await client.get(C.MILESTONES(id)); return (unwrap(response.data) ?? []) as Freight.IClearanceMilestone[]; }, listMilestonesForBooking: async ( bookingId: string, ): Promise => { const response = await client.get(C.BOOKING_MILESTONES(bookingId)); return (unwrap(response.data) ?? []) as Freight.IClearanceMilestone[]; }, completeMilestone: (bookingId: string, code: string, note?: string) => postContract( C.COMPLETE_BOOKING_MILESTONE(bookingId, code), { note }, ), // ── GL post-booking operational actions ── assignRisk: ( bookingId: string, payload: { riskLevel: Freight.CustomsRiskLevel; note?: string }, ) => postContract( C.BOOKING_RISK(bookingId), payload, ), adviseDuty: ( bookingId: string, payload: { amount: number; currency: string; declarationSerial?: string; note?: string; }, ) => postContract( C.BOOKING_DUTY(bookingId), payload, ), assignStation: ( bookingId: string, payload: { stationYardId: string; staffId?: string }, ) => postContract(C.BOOKING_STATION_ASSIGN(bookingId), payload), uploadGlDocuments: async ( bookingId: string, files: Record, ): Promise<{ uploaded: number; completedMilestones: string[] }> => { const form = new FormData(); for (const [key, file] of Object.entries(files)) { if (file) form.append(key, file); } const response = await client.post(C.BOOKING_GL_DOCUMENTS(bookingId), form, { headers: { "Content-Type": "multipart/form-data" }, }); return unwrap(response.data) as { uploaded: number; completedMilestones: string[]; }; }, listIncidents: async ( bookingId: string, ): Promise => { const response = await client.get(C.BOOKING_INCIDENTS(bookingId)); return (unwrap(response.data) ?? []) as Freight.IClearanceIncident[]; }, reportIncident: async ( bookingId: string, payload: { incidentType: Freight.IncidentType; description: string; photos: File[]; }, ): Promise => { const form = new FormData(); form.append("incidentType", payload.incidentType); form.append("description", payload.description); for (const photo of payload.photos) form.append("photos", photo); const response = await client.post(C.BOOKING_INCIDENTS(bookingId), form, { headers: { "Content-Type": "multipart/form-data" }, }); return unwrap(response.data) as Freight.IClearanceIncident; }, };