mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
489 lines
15 KiB
TypeScript
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"),
|
|
});
|
|
}
|