add yard distances management to rule engine

- Introduced new yard distances resource with CRUD operations.
- Created migration for yard distances table with necessary constraints.
- Implemented service and repository for yard distances handling.
- Added controller for API endpoints to manage yard distances.
- Updated rule engine configuration to include yard distances.
- Enhanced rule engine resource page to support yard distance selection.
- Updated contracts and train builder pages to handle new yard distance logic.
- Added error handling utility for better error message extraction.
This commit is contained in:
Marshal
2026-07-21 08:50:50 +00:00
parent 1647681840
commit 603537a20b
45 changed files with 1275 additions and 227 deletions

View File

@@ -1,5 +1,4 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import toast from "react-hot-toast";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
@@ -9,16 +8,9 @@ import {
type BookingListFilter,
} from "@/services/bookings.service";
import { invalidateBookingDetail } from "@/utils/queryInvalidation";
import { extractErrorMessage } from "@/utils/errorExtractor";
const parseApiError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
const message = error.response?.data?.message;
if (Array.isArray(message)) return message.join(", ");
if (typeof message === "string") return message;
}
if (error instanceof Error && error.message) return error.message;
return fallback;
};
const parseApiError = extractErrorMessage;
export function useBookingList(filter?: BookingListFilter, enabled = true) {
return useQuery({
@@ -55,21 +47,21 @@ export function useBookingMutations(bookingId: string) {
mutationFn: (validityDays: number) =>
api.bookings.staffAccept.call({ id: bookingId, validityDays }),
onSuccess: (data) => onSuccess(data, "Booking accepted for approval"),
onError: () => toast.error("Failed to accept booking"),
onError: (error) => toast.error(parseApiError(error, "Failed to accept booking")),
});
const requestChanges = useMutation({
mutationFn: (note: string) =>
api.bookings.requestChanges.call({ id: bookingId, note }),
onSuccess: (data) => onSuccess(data, "Changes requested from customer"),
onError: () => toast.error("Failed to request changes"),
onError: (error) => toast.error(parseApiError(error, "Failed to request changes")),
});
const staffReject = useMutation({
mutationFn: (reason: string) =>
api.bookings.staffReject.call({ id: bookingId, reason }),
onSuccess: (data) => onSuccess(data, "Booking rejected"),
onError: () => toast.error("Failed to reject booking"),
onError: (error) => toast.error(parseApiError(error, "Failed to reject booking")),
});
const reviewOperation = useMutation({
@@ -90,7 +82,7 @@ export function useBookingMutations(bookingId: string) {
const generateContract = useMutation({
mutationFn: () => api.bookings.generateContract.call({ id: bookingId }),
onSuccess: (data) => onSuccess(data, "Contract generated"),
onError: () => toast.error("Failed to generate contract"),
onError: (error) => toast.error(parseApiError(error, "Failed to generate contract")),
});
const signContract = useMutation({
@@ -101,32 +93,32 @@ export function useBookingMutations(bookingId: string) {
consentText?: string;
}) => bookingsService.signContract(bookingId, payload),
onSuccess: (data) => onSuccess(data, "Contract signed"),
onError: () => toast.error("Failed to sign contract"),
onError: (error) => toast.error(parseApiError(error, "Failed to sign contract")),
});
const payBooking = useMutation({
mutationFn: () => api.bookings.payBooking.call({ id: bookingId }),
onSuccess: (data) => onSuccess(data, "Payment completed"),
onError: () => toast.error("Failed to complete payment"),
onError: (error) => toast.error(parseApiError(error, "Failed to complete payment")),
});
const startTransit = useMutation({
mutationFn: () => api.bookings.startTransit.call({ id: bookingId }),
onSuccess: (data) => onSuccess(data, "Marked in transit"),
onError: () => toast.error("Failed to start transit"),
onError: (error) => toast.error(parseApiError(error, "Failed to start transit")),
});
const complete = useMutation({
mutationFn: () => api.bookings.complete.call({ id: bookingId }),
onSuccess: (data) => onSuccess(data, "Booking completed"),
onError: () => toast.error("Failed to complete booking"),
onError: (error) => toast.error(parseApiError(error, "Failed to complete booking")),
});
const cancel = useMutation({
mutationFn: (reason: string) =>
api.bookings.cancel.call({ id: bookingId, reason }),
onSuccess: (data) => onSuccess(data, "Booking cancelled"),
onError: () => toast.error("Failed to cancel booking"),
onError: (error) => toast.error(parseApiError(error, "Failed to cancel booking")),
});
const isPending =

View File

@@ -9,6 +9,7 @@ import {
type ContractListFilter,
type SignContractPayload,
} from "@/services/contracts.service";
import { extractErrorMessage } from "@/utils/errorExtractor";
function invalidateContractDetail(qc: QueryClient, id: string): Promise<void> {
return Promise.all([
@@ -144,7 +145,7 @@ export function useContractMutations(contractId: string) {
payload.documentSnapshot,
),
onSuccess: (data) => onSuccess(data, "Contract accepted for approval"),
onError: () => toast.error("Failed to accept contract"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to accept contract")),
});
// Edit THIS contract's document articles (per-contract; never the templates).
@@ -152,20 +153,20 @@ export function useContractMutations(contractId: string) {
mutationFn: (snapshot: Freight.IContractDocumentSnapshot) =>
contractsService.updateContractDocument(contractId, snapshot),
onSuccess: (data) => onSuccess(data, "Contract document updated"),
onError: () => toast.error("Failed to update contract document"),
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: () => toast.error("Failed to request changes"),
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: () => toast.error("Failed to reject contract"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to reject contract")),
});
const approveStep = useMutation({
@@ -182,30 +183,46 @@ export function useContractMutations(contractId: string) {
: "Approval step completed";
onSuccess(data, message);
},
onError: () => toast.error("Failed to approve step"),
onError: (error) => toast.error(extractErrorMessage(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.
// 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 }: { stepId: string; reason: string }) =>
contractsService.rejectStep({ id: contractId, stepId, reason }),
onSuccess: (data) => onSuccess(data, "Contract rejected"),
onError: () => toast.error("Failed to reject step"),
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: () => toast.error("Failed to generate contract"),
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: () => toast.error("Failed to sign contract"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to sign contract")),
});
const createBooking = useMutation({
@@ -331,7 +348,7 @@ export function useContractClearanceMutations(
);
refresh();
},
onError: () => toast.error("Could not approve all documents"),
onError: (error) => toast.error(extractErrorMessage(error, "Could not approve all documents")),
});
const uploadOutputDocuments = useMutation({
@@ -341,7 +358,7 @@ export function useContractClearanceMutations(
toast.success("Output documents uploaded");
refresh();
},
onError: () => toast.error("Upload failed"),
onError: (error) => toast.error(extractErrorMessage(error, "Upload failed")),
});
const finalizeClearance = useMutation({
@@ -380,7 +397,7 @@ export function useCompleteMilestone(bookingId: string) {
queryKey: QUERY_KEYS.CONTRACTS.bookingMilestones(bookingId),
});
},
onError: () => toast.error("Failed to complete milestone"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to complete milestone")),
});
}
@@ -402,7 +419,7 @@ export function useAssignRisk(bookingId: string) {
toast.success("Customs risk assigned");
invalidateMilestones(qc, bookingId);
},
onError: () => toast.error("Failed to assign risk"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to assign risk")),
});
}
@@ -420,7 +437,7 @@ export function useAdviseDuty(bookingId: string) {
toast.success("Duty & tax advised to customer");
invalidateMilestones(qc, bookingId);
},
onError: () => toast.error("Failed to advise duty & tax"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to advise duty & tax")),
});
}
@@ -436,7 +453,7 @@ export function useAssignStation(bookingId: string) {
queryKey: QUERY_KEYS.BOOKINGS.byId(bookingId),
});
},
onError: () => toast.error("Failed to assign station"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to assign station")),
});
}
@@ -455,7 +472,7 @@ export function useUploadGlDocuments(bookingId: string) {
);
invalidateMilestones(qc, bookingId);
},
onError: () => toast.error("Failed to upload documents"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to upload documents")),
});
}
@@ -483,6 +500,6 @@ export function useReportIncident(bookingId: string) {
queryKey: QUERY_KEYS.CONTRACTS.bookingIncidents(bookingId),
});
},
onError: () => toast.error("Failed to report incident"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to report incident")),
});
}

View File

@@ -21,6 +21,7 @@ import {
invalidateRuleEngineList,
patchRuleEngineListRecord,
} from "@/utils/queryInvalidation";
import { extractErrorMessage } from "@/utils/errorExtractor";
export const useRuleEngineList = (
resource: RuleEngineResourceSlug,
@@ -58,7 +59,7 @@ export const useRuleEngineOrderMutations = (resource: RuleEngineResourceSlug) =>
await invalidateRuleEngineList(qc, resource);
await qc.invalidateQueries({ queryKey: QUERY_KEYS.RULE_ENGINE.orderList(resource) });
},
onError: () => toast.error("Failed to update order"),
onError: (err) => toast.error(extractErrorMessage(err, "Failed to update order")),
});
const moveOrder = useMutation({
@@ -273,7 +274,8 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => {
patchRuleEngineListRecord(qc, resource, created);
await invalidateRuleEngineList(qc, resource);
},
onError: () => toast.error("Failed to create record"),
onError: (err) =>
toast.error(extractErrorMessage(err, "Failed to create record")),
});
const update = useMutation({
@@ -289,7 +291,8 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => {
patchRuleEngineListRecord(qc, resource, updated);
await invalidateRuleEngineList(qc, resource);
},
onError: () => toast.error("Failed to update record"),
onError: (err) =>
toast.error(extractErrorMessage(err, "Failed to update record")),
});
const remove = useMutation({
@@ -299,7 +302,8 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => {
toast.success("Deleted successfully");
await invalidateRuleEngineList(qc, resource);
},
onError: () => toast.error("Failed to delete record"),
onError: (err) =>
toast.error(extractErrorMessage(err, "Failed to delete record")),
});
return { create, update, remove };
@@ -455,7 +459,7 @@ export const useRateWorkflow = () => {
patchRuleEngineListRecord(qc, "rates", updated);
await invalidateRuleEngineList(qc, "rates");
},
onError: () => toast.error("Failed to submit rate"),
onError: (err) => toast.error(extractErrorMessage(err, "Failed to submit rate")),
});
const approve = useMutation({
@@ -465,7 +469,7 @@ export const useRateWorkflow = () => {
patchRuleEngineListRecord(qc, "rates", updated);
await invalidateRuleEngineList(qc, "rates");
},
onError: () => toast.error("Failed to approve rate"),
onError: (err) => toast.error(extractErrorMessage(err, "Failed to approve rate")),
});
return { submit, approve };