import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import toast from "react-hot-toast"; import type { QueryClient } from "@tanstack/react-query"; import type { Freight } from "@edr/types"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { contractsService, type ContractListFilter, type SignContractPayload, } from "@/services/contracts.service"; import { extractErrorMessage } from "@/utils/errorExtractor"; function invalidateContractDetail(qc: QueryClient, id: string): Promise { return Promise.all([ qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.byId(id) }), qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.ROOT }), ]).then(() => undefined); } export function useContractList(filter?: ContractListFilter, enabled = true) { return useQuery({ queryKey: QUERY_KEYS.CONTRACTS.list(filter), queryFn: () => contractsService.list(filter), enabled, }); } export function useContractListSummary( filter?: ContractListFilter, enabled = true, ) { return useQuery({ queryKey: QUERY_KEYS.CONTRACTS.listSummary(filter), queryFn: () => contractsService.getListSummary(filter), enabled, }); } export function useContractDetail(id: string | undefined) { return useQuery({ queryKey: QUERY_KEYS.CONTRACTS.byId(id ?? ""), queryFn: () => contractsService.getById(id!), enabled: Boolean(id), }); } export function useContractClearanceQueue(enabled = true) { return useQuery({ queryKey: QUERY_KEYS.CONTRACTS.clearanceQueue("GL"), queryFn: () => contractsService.getClearanceQueue(), enabled, }); } export function useEtClearanceQueue(enabled = true) { return useQuery({ queryKey: QUERY_KEYS.CONTRACTS.clearanceQueue("ET"), queryFn: () => contractsService.getEtClearanceQueue(), enabled, }); } export function useDjClearanceQueue(enabled = true) { return useQuery({ queryKey: QUERY_KEYS.CONTRACTS.clearanceQueue("DJ"), queryFn: () => contractsService.getDjClearanceQueue(), enabled, }); } /** Path A self-clearance queue (Operations reviews non-customs contracts). */ export function useOpsClearanceQueue(enabled = true) { return useQuery({ queryKey: QUERY_KEYS.CONTRACTS.clearanceQueue("OPS"), queryFn: () => contractsService.getOpsClearanceQueue(), enabled, }); } export function useContractClearanceHistory(enabled = true) { return useQuery({ queryKey: QUERY_KEYS.CONTRACTS.clearanceHistory("GL"), queryFn: () => contractsService.getClearanceHistory(), enabled, }); } export function useOpsClearanceHistory(enabled = true) { return useQuery({ queryKey: QUERY_KEYS.CONTRACTS.clearanceHistory("OPS"), queryFn: () => contractsService.getOpsClearanceHistory(), enabled, }); } export function useContractMilestones(id: string | undefined) { return useQuery({ queryKey: QUERY_KEYS.CONTRACTS.milestones(id ?? ""), queryFn: () => contractsService.listMilestonesForContract(id!), enabled: Boolean(id), }); } export function useContractCapacity(id: string | undefined) { return useQuery({ queryKey: QUERY_KEYS.CONTRACTS.capacity(id ?? ""), queryFn: () => contractsService.getCapacity(id!), enabled: Boolean(id), }); } export function useBookingMilestones(bookingId: string | undefined) { return useQuery({ queryKey: QUERY_KEYS.CONTRACTS.bookingMilestones(bookingId ?? ""), queryFn: () => contractsService.listMilestonesForBooking(bookingId!), enabled: Boolean(bookingId), }); } export function useContractMutations(contractId: string) { const qc = useQueryClient(); const onSuccess = (data: { id: string }, message: string) => { toast.success(message); void invalidateContractDetail(qc, data.id); // Approving or editing can change who holds document-editing rights (it // passes to the next approver), and edits add revisions — so both the draft // and the history are refreshed on every contract mutation. void qc.invalidateQueries({ queryKey: ["contracts", data.id, "document-draft"], }); void qc.invalidateQueries({ queryKey: ["contracts", data.id, "document-revisions"], }); }; const staffAccept = useMutation({ mutationFn: (payload: { validityDays: number; documentSnapshot?: Freight.IContractDocumentSnapshot; }) => contractsService.staffAccept( contractId, payload.validityDays, payload.documentSnapshot, ), onSuccess: (data) => onSuccess(data, "Contract accepted for approval"), onError: (error) => toast.error(extractErrorMessage(error, "Failed to accept contract")), }); // Edit THIS contract's document articles (per-contract; never the templates). const updateDocument = useMutation({ mutationFn: (snapshot: Freight.IContractDocumentSnapshot) => contractsService.updateContractDocument(contractId, snapshot), onSuccess: (data) => onSuccess(data, "Contract document updated"), onError: (error) => toast.error(extractErrorMessage(error, "Failed to update contract document")), }); const requestChanges = useMutation({ mutationFn: (note: string) => contractsService.requestChanges(contractId, note), onSuccess: (data) => onSuccess(data, "Changes requested from customer"), onError: (error) => toast.error(extractErrorMessage(error, "Failed to request changes")), }); const reject = useMutation({ mutationFn: (reason: string) => contractsService.reject(contractId, reason), onSuccess: (data) => onSuccess(data, "Contract rejected"), onError: (error) => toast.error(extractErrorMessage(error, "Failed to reject contract")), }); const approveStep = useMutation({ // The server derives the required role from the step itself, so the client // does not send one. mutationFn: ({ stepId }: { stepId: string }) => contractsService.approveStep({ id: contractId, stepId }), onSuccess: (data) => { // The final approval is what generates the PDF and moves the contract to // CONTRACT_READY — before that approvers review a live preview. const message = data.status === "CONTRACT_READY" ? "Final approval complete — contract generated and ready to sign" : "Approval step completed"; onSuccess(data, message); }, onError: (error) => toast.error(extractErrorMessage(error, "Failed to approve step")), }); // Per-step rejection by an approver (line staff / director / CEO). Two // flavours: without returnToStepId it is terminal (REJECTED, customer must // resubmit); with it the contract is sent back to that earlier approver and // the chain re-runs from there. const rejectStep = useMutation({ mutationFn: ({ stepId, reason, returnToStepId, }: { stepId: string; reason: string; returnToStepId?: string; }) => contractsService.rejectStep({ id: contractId, stepId, reason, returnToStepId }), onSuccess: (data, variables) => onSuccess( data, variables.returnToStepId ? "Contract sent back in the approval chain" : "Contract rejected", ), onError: (error) => toast.error(extractErrorMessage(error, "Failed to reject step")), }); // Manual fallback generate — used only if auto-generation failed. const generateContract = useMutation({ mutationFn: () => contractsService.generateContract(contractId), onSuccess: (data) => onSuccess(data, "Contract generated"), onError: (error) => toast.error(extractErrorMessage(error, "Failed to generate contract")), }); const signContract = useMutation({ mutationFn: (payload: SignContractPayload) => contractsService.signContract(contractId, payload), onSuccess: (data) => onSuccess(data, "Contract signed"), onError: (error) => toast.error(extractErrorMessage(error, "Failed to sign contract")), }); const createBooking = useMutation({ mutationFn: (payload: Freight.CreateBookingUnderContractDto) => contractsService.createBookingUnderContract(contractId, payload), onSuccess: () => { toast.success("Booking created under contract"); void invalidateContractDetail(qc, contractId); }, // Surface the server's reason (e.g. a container already booked on the same // train) instead of a generic failure. onError: (e: Error) => toast.error(e.message || "Failed to create booking"), }); const completeBooking = useMutation({ mutationFn: ({ bookingId, payload, }: { bookingId: string; payload: Freight.CreateBookingUnderContractDto; }) => contractsService.completeBookingUnderContract( contractId, bookingId, payload, ), onSuccess: () => { toast.success("Booking completed"); void invalidateContractDetail(qc, contractId); }, onError: (e: Error) => toast.error(e.message || "Failed to complete booking"), }); const isPending = staffAccept.isPending || updateDocument.isPending || requestChanges.isPending || reject.isPending || approveStep.isPending || rejectStep.isPending || generateContract.isPending || signContract.isPending || createBooking.isPending; return { staffAccept, updateDocument, requestChanges, reject, approveStep, rejectStep, generateContract, signContract, createBooking, completeBooking, isPending, }; } /** * Pre-booking clearance mutations keyed on a contract. Pass `selfClear = true` * for Path A (non-customs) contracts so review/finalize hit the Operations * endpoints instead of the GL ET ones. Path A has no GL output upload step. */ export function useContractClearanceMutations( contractId: string, selfClear = false, ) { const qc = useQueryClient(); const refresh = () => { void qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.clearance(contractId), }); void qc.invalidateQueries({ queryKey: ["contracts", "clearance-queue"], }); void invalidateContractDetail(qc, contractId); }; const reviewDocument = useMutation({ mutationFn: (p: { fileKey: string; status: "APPROVED" | "QUERIED"; note?: string; }) => selfClear ? contractsService.opsReviewClearanceDocument(contractId, p) : contractsService.reviewClearanceDocument(contractId, p), onSuccess: (_d, p) => { toast.success( p.status === "APPROVED" ? "Document approved" : "Query sent to customer", ); refresh(); }, onError: (e) => toast.error( e instanceof Error ? e.message : "Could not update document", ), }); // Approve every still-pending customer document in one click. There is no // server-side bulk endpoint, so fan out the single-document review calls and // refresh once after they all settle. const approveAll = useMutation({ mutationFn: async (fileKeys: string[]) => { const review = selfClear ? contractsService.opsReviewClearanceDocument : contractsService.reviewClearanceDocument; await Promise.all( fileKeys.map((fileKey) => review(contractId, { fileKey, status: "APPROVED" }), ), ); }, onSuccess: (_d, fileKeys) => { toast.success( `${fileKeys.length} document${fileKeys.length === 1 ? "" : "s"} approved`, ); refresh(); }, onError: (error) => toast.error(extractErrorMessage(error, "Could not approve all documents")), }); const uploadOutputDocuments = useMutation({ mutationFn: (files: Record) => contractsService.uploadClearanceOutput(contractId, files), onSuccess: () => { toast.success("Output documents uploaded"); refresh(); }, onError: (error) => toast.error(extractErrorMessage(error, "Upload failed")), }); const finalizeClearance = useMutation({ mutationFn: () => selfClear ? contractsService.opsFinalizeClearance(contractId) : contractsService.finalizeClearance(contractId), onSuccess: () => { toast.success( selfClear ? "Document approval finalized — customer can now book" : "Document approval finalized — ready for booking", ); refresh(); }, onError: (e) => toast.error( e instanceof Error ? e.message : "Could not finalize document approval", ), }); return { reviewDocument, approveAll, uploadOutputDocuments, finalizeClearance }; } /** Complete a post-booking GL milestone. */ export function useCompleteMilestone(bookingId: string) { const qc = useQueryClient(); return useMutation({ mutationFn: ({ code, note }: { code: string; note?: string }) => contractsService.completeMilestone(bookingId, code, note), onSuccess: () => { toast.success("Milestone completed"); void qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.bookingMilestones(bookingId), }); }, onError: (error) => toast.error(extractErrorMessage(error, "Failed to complete milestone")), }); } function invalidateMilestones(qc: QueryClient, bookingId: string) { void qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.bookingMilestones(bookingId), }); } /** Assign a customs risk level (completes RISK_ASSIGNED). */ export function useAssignRisk(bookingId: string) { const qc = useQueryClient(); return useMutation({ mutationFn: (payload: { riskLevel: Freight.CustomsRiskLevel; note?: string; }) => contractsService.assignRisk(bookingId, payload), onSuccess: () => { toast.success("Customs risk assigned"); invalidateMilestones(qc, bookingId); }, onError: (error) => toast.error(extractErrorMessage(error, "Failed to assign risk")), }); } /** Advise duty & tax (completes DUTY_TAXES_ADVISED). */ export function useAdviseDuty(bookingId: string) { const qc = useQueryClient(); return useMutation({ mutationFn: (payload: { amount: number; currency: string; declarationSerial?: string; note?: string; }) => contractsService.adviseDuty(bookingId, payload), onSuccess: () => { toast.success("Duty & tax advised to customer"); invalidateMilestones(qc, bookingId); }, onError: (error) => toast.error(extractErrorMessage(error, "Failed to advise duty & tax")), }); } /** Route the shipment to a station + bind GL staff. */ export function useAssignStation(bookingId: string) { const qc = useQueryClient(); return useMutation({ mutationFn: (payload: { stationYardId: string; staffId?: string }) => contractsService.assignStation(bookingId, payload), onSuccess: () => { toast.success("Shipment routed to station"); void qc.invalidateQueries({ queryKey: QUERY_KEYS.BOOKINGS.byId(bookingId), }); }, onError: (error) => toast.error(extractErrorMessage(error, "Failed to assign station")), }); } /** Upload GL post-booking documents (DO/RO/T1/…); auto-completes milestones. */ export function useUploadGlDocuments(bookingId: string) { const qc = useQueryClient(); return useMutation({ mutationFn: (files: Record) => contractsService.uploadGlDocuments(bookingId, files), onSuccess: (res) => { const n = res.completedMilestones.length; toast.success( n > 0 ? `Uploaded — ${n} milestone${n === 1 ? "" : "s"} advanced` : "Documents uploaded", ); invalidateMilestones(qc, bookingId); }, onError: (error) => toast.error(extractErrorMessage(error, "Failed to upload documents")), }); } /** Incidents for a shipment (damage / exceptions). */ export function useBookingIncidents(bookingId: string | undefined) { return useQuery({ queryKey: QUERY_KEYS.CONTRACTS.bookingIncidents(bookingId ?? ""), queryFn: () => contractsService.listIncidents(bookingId!), enabled: !!bookingId, }); } /** Report a cargo exception with photos. */ export function useReportIncident(bookingId: string) { const qc = useQueryClient(); return useMutation({ mutationFn: (payload: { incidentType: Freight.IncidentType; description: string; photos: File[]; }) => contractsService.reportIncident(bookingId, payload), onSuccess: () => { toast.success("Incident reported"); void qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.bookingIncidents(bookingId), }); }, onError: (error) => toast.error(extractErrorMessage(error, "Failed to report incident")), }); }