Files
edr-platform/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts
Marshal 4b7f6d2548 enhance contract and booking services with server-side search and validation improvements
- Added  parameter to  and  for server-side free-text search on contract reference, company name, and booking details.
- Introduced new validation errors in  for container clashes and space issues when creating bookings.
- Implemented paginated dropdown settings retrieval in .
- Updated  to fetch active yards using a new method that handles pagination.
- Enhanced  with a  method to fetch all records by walking through pages.
- Refactored  to support filtering and pagination in schedule listings.
- Improved  to return a paginated list of facilities.
- Updated UI components in  and  to utilize debounced search inputs for better performance.
- Added alerts in  to inform users about booking constraints related to splits and capacity.
- Enhanced  to display notifications for split bookings and capacity usage.
2026-07-12 10:51:31 +00:00

481 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);
};
const staffAccept = useMutation({
mutationFn: (validityDays: number) =>
contractsService.staffAccept(contractId, validityDays),
onSuccess: (data) => onSuccess(data, "Contract accepted for approval"),
onError: () => toast.error("Failed to accept contract"),
});
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"),
});
// Statuses that mean every approval step is done and the contract is ready to
// be generated. Once the final approval lands we generate the PDF
// automatically — staff no longer click a separate "Generate" button.
const READY_TO_GENERATE = ["APPROVED", "APPROVED_PENDING_SIGNATURE"];
const approveStep = useMutation({
mutationFn: ({
stepId,
requiredRole,
}: {
stepId: string;
requiredRole: string;
}) =>
contractsService.approveStep({ id: contractId, stepId, requiredRole }),
onSuccess: async (data) => {
// If this was the LAST approval, auto-generate the contract so it goes
// straight to CONTRACT_READY without a manual step.
const alreadyGenerated = Boolean(
(data as Freight.IContract).contractGeneratedAt,
);
if (READY_TO_GENERATE.includes(data.status) && !alreadyGenerated) {
toast.success("Final approval complete — generating contract…");
try {
const generated = await contractsService.generateContract(data.id);
onSuccess(generated, "Contract generated and ready to sign");
return;
} catch {
toast.error("Approved, but contract generation failed. Retry below.");
void invalidateContractDetail(qc, data.id);
return;
}
}
onSuccess(data, "Approval step completed");
},
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 ||
requestChanges.isPending ||
reject.isPending ||
approveStep.isPending ||
rejectStep.isPending ||
generateContract.isPending ||
signContract.isPending ||
createBooking.isPending;
return {
staffAccept,
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
? "Clearance approved — customer can now book"
: "Clearance finalized — ready for booking",
);
refresh();
},
onError: (e) =>
toast.error(
e instanceof Error ? e.message : "Could not finalize clearance",
),
});
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"),
});
}