Files
edr-platform/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts
2026-07-20 12:24:58 +00:00

489 lines
15 KiB
TypeScript

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";
function invalidateContractDetail(qc: QueryClient, id: string): Promise<void> {
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: () => toast.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: () => toast.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: () => toast.error("Failed to request changes"),
});
const reject = useMutation({
mutationFn: (reason: string) => contractsService.reject(contractId, reason),
onSuccess: (data) => onSuccess(data, "Contract rejected"),
onError: () => toast.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: () => toast.error("Failed to approve step"),
});
// Per-step rejection by an approver (line staff / director / CEO). Terminal:
// the contract goes to REJECTED and the customer must create a new one.
const rejectStep = useMutation({
mutationFn: ({ stepId, reason }: { stepId: string; reason: string }) =>
contractsService.rejectStep({ id: contractId, stepId, reason }),
onSuccess: (data) => onSuccess(data, "Contract rejected"),
onError: () => toast.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: () => toast.error("Failed to generate contract"),
});
const signContract = useMutation({
mutationFn: (payload: SignContractPayload) =>
contractsService.signContract(contractId, payload),
onSuccess: (data) => onSuccess(data, "Contract signed"),
onError: () => toast.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: () => toast.error("Could not approve all documents"),
});
const uploadOutputDocuments = useMutation({
mutationFn: (files: Record<string, File | null>) =>
contractsService.uploadClearanceOutput(contractId, files),
onSuccess: () => {
toast.success("Output documents uploaded");
refresh();
},
onError: () => toast.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: () => toast.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: () => toast.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: () => toast.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: () => toast.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<string, File | null>) =>
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: () => toast.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: () => toast.error("Failed to report incident"),
});
}