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; tradeDirection?: string; contractKind?: string; paymentCurrency?: 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; 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; } 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; } export interface ContractView { contractId: 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; }>; savedSignature?: { signerDisplayName: string; signatureImageUrl?: string | null; } | null; } export interface SignContractPayload { role: "CUSTOMER" | "STAFF"; signatureImageBase64: string; signerDisplayName: string; consentText?: string; } 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.tradeDirection) params.tradeDirection = filter.tradeDirection; if (filter.contractKind) params.contractKind = filter.contractKind; if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency; } 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): Promise => { const response = await client.get(C.BY_ID(id)); return unwrap(response.data) as Freight.IContract; }, // ── Staff review ── staffAccept: (id: string, validityDays: number) => postContract(C.STAFF_ACCEPT(id), { validityDays }), requestChanges: (id: string, note: string) => postContract(C.STAFF_REQUEST_CHANGES(id), { note }), reject: (id: string, reason: string) => postContract(C.STAFF_REJECT(id), { reason }), approveStep: ({ id, stepId, requiredRole, }: { id: string; stepId: string; requiredRole: string; }) => postContract(C.APPROVE_STEP(id, stepId), { requiredRole, }), rejectStep: ({ id, stepId, reason, }: { id: string; stepId: string; reason: string; }) => postContract(C.REJECT_STEP(id, stepId), { reason }), // ── 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): Promise => { const response = await client.get(C.CLEARANCE(id)); return unwrap(response.data) as Freight.ContractClearanceView; }, reviewClearanceDocument: ( id: string, payload: { fileKey: string; status: "APPROVED" | "QUERIED"; note?: string }, ) => postContract(C.CLEARANCE_REVIEW(id), 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(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)), getEtClearanceQueue: async (): Promise => { const response = await client.get(C.CLEARANCE_ET_QUEUE); const data = unwrap(response.data); return { items: (data.items ?? []) as Freight.IContract[], total: data.total ?? 0, }; }, getDjClearanceQueue: async (): Promise => { const response = await client.get(C.CLEARANCE_DJ_QUEUE); const data = unwrap(response.data); return { items: (data.items ?? []) as Freight.IContract[], total: data.total ?? 0, }; }, 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, file: File, vesselDepartureDate?: string, ): Promise => { const form = new FormData(); form.append("file", file); if (vesselDepartureDate) form.append("vesselDepartureDate", vesselDepartureDate); 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, file: File, vesselDepartureDate: string, ): Promise<{ contract: Freight.IContract; hold: boolean; holdReason?: string }> => { const form = new FormData(); form.append("file", 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 }; }, // ── Path A self-clearance (Operations review) ── getOpsClearanceQueue: async (filter?: { page?: number; pageSize?: number; search?: string; /** Comma-separated ops-clearance lifecycle statuses; omitted → under-review queue. */ statuses?: string; }): Promise => { const response = await client.get( C.OPS_CLEARANCE_QUEUE, { params: filter }, ); const data = unwrap(response.data); return { items: (data.items ?? []) as Freight.IContract[], total: data.total ?? 0, }; }, 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, }; }, /** * 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, ) => postContract(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; }, };