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

@@ -115,13 +115,16 @@ api.interceptors.response.use(
// Surface the server's actual error message in the global error modal
// (401s are handled by the session-refresh flow, so skip them). A request
// may opt out via `suppressErrorModal` when it handles the failure itself.
if (
error.response &&
error.response.status !== 401 &&
!originalRequest?.suppressErrorModal
) {
if (error.response && error.response.status !== 401) {
const payload = extractApiErrorPayload(error);
if (payload) emitApiError(payload);
// Normalize the error's own `message` to the SERVER's actual message so
// every downstream `toast.error(err.message)` / MutationCache handler
// shows the real cause instead of "Request failed with status code NNN".
// Applies even on suppressErrorModal paths — only the modal is opted out.
if (payload?.messages.length) {
(error as { message?: string }).message = payload.messages.join("\n");
}
if (payload && !originalRequest?.suppressErrorModal) emitApiError(payload);
}
return Promise.reject(error);
}

View File

@@ -8,6 +8,7 @@ import {
Button,
Box,
Modal,
Select,
Textarea,
} from "@mantine/core";
import type { Freight } from "@edr/types";
@@ -35,6 +36,10 @@ export function ContractApprovalStepsCard({
const [rejectStepRow, setRejectStepRow] =
useState<Freight.IContractApprovalStep | null>(null);
const [rejectReason, setRejectReason] = useState("");
// Where the rejection lands: "CUSTOMER" (terminal, resubmit) or the id of an
// earlier APPROVED step to send the chain back to. First approver has no
// choice — customer only.
const [rejectTarget, setRejectTarget] = useState<string>("CUSTOMER");
const steps = useMemo(
() =>
@@ -70,6 +75,7 @@ export function ContractApprovalStepsCard({
const openReject = (step: Freight.IContractApprovalStep) => {
setRejectStepRow(step);
setRejectReason("");
setRejectTarget("CUSTOMER");
setRejectOpen(true);
};
@@ -77,14 +83,34 @@ export function ContractApprovalStepsCard({
setRejectOpen(false);
setRejectStepRow(null);
setRejectReason("");
setRejectTarget("CUSTOMER");
};
const trimmedReason = rejectReason.trim();
// Earlier stages this rejection can be returned to — only stages that have
// already approved. Empty for the first approver, whose only target is the
// customer.
const returnableSteps = rejectStepRow
? steps.filter(
(s) =>
s.stepOrder < rejectStepRow.stepOrder && s.status === "APPROVED",
)
: [];
const sendBack = rejectTarget !== "CUSTOMER";
const targetStep = sendBack
? returnableSteps.find((s) => s.id === rejectTarget)
: undefined;
const runReject = () => {
if (!rejectStepRow || !trimmedReason) return;
mutations.rejectStep.mutate(
{ stepId: rejectStepRow.id, reason: trimmedReason },
{
stepId: rejectStepRow.id,
reason: trimmedReason,
returnToStepId: sendBack ? rejectTarget : undefined,
},
{ onSuccess: () => closeReject() },
);
};
@@ -192,21 +218,56 @@ export function ContractApprovalStepsCard({
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Rejecting the{" "}
<Text span fw={600} c="dark">
{rejectStepRow?.requiredRole}
</Text>{" "}
step rejects contract{" "}
<Text span fw={600} c="dark">
{contract.reference}
</Text>{" "}
outright. The customer must create a new contract this cannot be
undone.
</Text>
{returnableSteps.length > 0 && (
<Select
label="Send rejection to"
description="Return the contract to an earlier approver to fix and re-approve, or reject it to the customer."
allowDeselect={false}
value={rejectTarget}
onChange={(v) => setRejectTarget(v ?? "CUSTOMER")}
data={[
{ value: "CUSTOMER", label: "Customer — must resubmit" },
...returnableSteps.map((s) => ({
value: s.id,
label: `${s.requiredRole} — step ${s.stepOrder} re-approves`,
})),
]}
/>
)}
{sendBack ? (
<Text size="sm" c="dimmed">
Contract{" "}
<Text span fw={600} c="dark">
{contract.reference}
</Text>{" "}
will go back to the{" "}
<Text span fw={600} c="dark">
{targetStep?.requiredRole}
</Text>{" "}
step. That approver fixes the contract and approves again, and
every later step re-approves in order. The customer is not
notified.
</Text>
) : (
<Text size="sm" c="dimmed">
Rejecting the{" "}
<Text span fw={600} c="dark">
{rejectStepRow?.requiredRole}
</Text>{" "}
step rejects contract{" "}
<Text span fw={600} c="dark">
{contract.reference}
</Text>{" "}
outright. The customer must resubmit this cannot be undone.
</Text>
)}
<Textarea
label="Reason for rejection"
description="Shared with the customer and the approval chain."
description={
sendBack
? "Shared with the approval chain (not the customer)."
: "Shared with the customer and the approval chain."
}
placeholder="Explain why this contract is rejected…"
minRows={3}
autosize
@@ -219,14 +280,16 @@ export function ContractApprovalStepsCard({
Cancel
</Button>
<Button
color="red"
color={sendBack ? "orange" : "red"}
radius="md"
leftSection={<X size={16} />}
loading={mutations.rejectStep.isPending}
disabled={!trimmedReason}
onClick={runReject}
>
Reject contract
{sendBack
? `Send back to ${targetStep?.requiredRole ?? "step"}`
: "Reject contract"}
</Button>
</Group>
</Stack>

View File

@@ -46,10 +46,18 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
// Admin-managed run list (dropdown settings); numbers already on a train
// come back disabled so they cannot be picked twice.
const importNumbers = useImportTrainNumberOptions();
// Only serviceable locomotives standing in the selected yard can be coupled.
// Only serviceable locomotives standing in the selected yard, and not already
// coupled to another built train, can be picked. A new train owns none yet, so
// no train to keep-exclude.
const locomotivesQuery = useQuery(
api.locomotives.listFiltered.queryOptions({
input: { filters: { status: "AVAILABLE", currentYardId: yardId } },
input: {
filters: {
status: "AVAILABLE",
currentYardId: yardId,
excludeCoupled: true,
},
},
enabled: Boolean(yardId),
}),
);

View File

@@ -26,9 +26,19 @@ export default function ChangeLocomotivesModal({
const [locomotiveIds, setLocomotiveIds] = useState<string[]>([]);
const yardId = composition?.currentYard?.id ?? "";
// A locomotive already coupled to ANOTHER built train is not a valid pick —
// the API rejects it on save. Exclude those here (keeping this train's own
// ones, which are re-listed below as "(coupled)").
const availableQuery = useQuery(
api.locomotives.listFiltered.queryOptions({
input: { filters: { status: "AVAILABLE", currentYardId: yardId } },
input: {
filters: {
status: "AVAILABLE",
currentYardId: yardId,
excludeCoupled: true,
excludeTrainId: composition?.id,
},
},
enabled: opened && Boolean(yardId),
}),
);

View File

@@ -28,6 +28,34 @@ export const trainStatusLabel = (status: BuiltTrainStatus | string): string =>
.replace(/_/g, " ")
.replace(/^\w/, (c) => c.toUpperCase());
/** Statuses that block a deactivated train from reactivating (mirrors the API gate). */
export const UNFIT_LOCOMOTIVE_STATUSES = new Set(["MAINTENANCE", "OUT_OF_SERVICE", "UNAVAILABLE"]);
/** Badge color per locomotive status (Mantine palette keys). */
export const locomotiveStatusColor = (status: string): string => {
switch (status) {
case "AVAILABLE":
case "IMPORT_READY":
case "EXPORT_READY":
return "edr-green";
case "ASSIGNED":
return "blue";
case "MAINTENANCE":
return "yellow";
case "OUT_OF_SERVICE":
case "UNAVAILABLE":
return "red";
default:
return "gray";
}
};
export const locomotiveStatusLabel = (status: string): string =>
String(status)
.toLowerCase()
.replace(/_/g, " ")
.replace(/^\w/, (c) => c.toUpperCase());
/** Badge color per trade direction (Mantine palette keys). */
export const directionColor = (direction?: string | null): string =>
direction === "IMPORT" ? "blue" : direction === "EXPORT" ? "orange" : "gray";

View File

@@ -423,6 +423,9 @@ export const URL_CONSTANTS = {
YARDS: "/yards",
YARD_BY_ID: (id: string) => `/yards/${id}`,
YARD_DISTANCES: "/yard-distances",
YARD_DISTANCE_BY_ID: (id: string) => `/yard-distances/${id}`,
SHIPPING_LINES: "/shipping-lines",
SHIPPING_LINE_BY_ID: (id: string) => `/shipping-lines/${id}`,

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 };

View File

@@ -1,5 +1,10 @@
import { MutationCache, QueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import {
extractApiErrorPayload,
isGlobalErrorModalSuppressed,
} from "@/components/errors/ApiErrorModal";
import type { InvalidatesMeta } from "@/utils/endpoint";
/**
@@ -23,6 +28,19 @@ export const queryClient = new QueryClient({
void queryClient.invalidateQueries({ queryKey });
}
},
// Global mutation-failure surface: show the SERVER's actual message instead
// of a page's hardcoded "Failed to …". On pages where the global error
// modal shows (non-suppressed), it already carries the message, so a toast
// here would double up — fire the toast only on modal-suppressed paths
// (warehouse / first-last mile / onboarding). Opt a single mutation out with
// meta.skipGlobalErrorToast when it handles the error inline (e.g. a modal).
onError: (error, _variables, _context, mutation) => {
if (mutation.meta?.skipGlobalErrorToast) return;
if (!isGlobalErrorModalSuppressed(window.location.pathname)) return;
const payload = extractApiErrorPayload(error);
if (!payload?.messages.length) return;
toast.error(payload.messages.join("\n"));
},
}),
defaultOptions: {
queries: {

View File

@@ -19,7 +19,6 @@ import {
Divider,
Group,
Modal,
NumberInput,
Select,
SimpleGrid,
Stack,
@@ -36,6 +35,7 @@ import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { api } from "@/services/api";
import { ruleEngineService } from "@/services/ruleEngine/ruleEngine.service";
import { useToast } from "@/hooks/use-toast";
import {
formatRouteLabel,
@@ -47,7 +47,7 @@ import {
} from "@/services/routes.service";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
type MilestoneFormRow = { yardId: string; distanceKm: string };
type MilestoneFormRow = { yardId: string };
type RouteFormState = {
status: RouteStatus;
@@ -56,12 +56,12 @@ type RouteFormState = {
const emptyForm = (): RouteFormState => ({
status: "AVAILABLE",
milestones: [
{ yardId: "", distanceKm: "0" },
{ yardId: "", distanceKm: "" },
],
milestones: [{ yardId: "" }, { yardId: "" }],
});
/** Order-insensitive pair key — yard distances are symmetric. */
const pairKey = (a: string, b: string) => (a < b ? `${a}|${b}` : `${b}|${a}`);
const yardLabel = (yard?: YardRef | null) =>
yard ? `${yard.label} (${yard.code})` : "—";
@@ -163,6 +163,15 @@ export default function RoutesPage() {
const routesQuery = useQuery(api.routes.list.queryOptions());
const yardsQuery = useQuery(api.routes.yards.queryOptions());
// Segment km are configured in Configuration → Yard Distances and resolved
// by the API on save; this fetch is only to preview them in the form.
const yardDistancesQuery = useQuery({
queryKey: ["yard-distances", "all"],
queryFn: () =>
ruleEngineService.listAll<{ id: string; fromYardId: string; toYardId: string; distanceKm: string }>(
"yard-distances",
),
});
const createMutation = useMutation(api.routes.create.mutationOptions());
const updateMutation = useMutation(api.routes.update.mutationOptions());
const deactivateMutation = useMutation(api.routes.deactivate.mutationOptions());
@@ -206,15 +215,33 @@ export default function RoutesPage() {
[yardsQuery.data],
);
const formTotalKm = useMemo(
() =>
form.milestones.reduce(
(sum, row, index) =>
index === 0 ? sum : sum + Number(row.distanceKm || 0),
0,
),
[form.milestones],
);
const distanceByPair = useMemo(() => {
const map = new Map<string, number>();
for (const row of yardDistancesQuery.data ?? []) {
map.set(pairKey(row.fromYardId, row.toYardId), Number(row.distanceKm));
}
return map;
}, [yardDistancesQuery.data]);
/** Configured km for the segment ending at `index` (undefined = pair not configured yet). */
const segmentKm = (index: number): number | undefined => {
if (index === 0) return 0;
const from = form.milestones[index - 1]?.yardId;
const to = form.milestones[index]?.yardId;
if (!from || !to) return undefined;
return distanceByPair.get(pairKey(from, to));
};
const formTotalKm = useMemo(() => {
let total = 0;
for (let i = 1; i < form.milestones.length; i++) {
const from = form.milestones[i - 1]?.yardId;
const to = form.milestones[i]?.yardId;
if (!from || !to) continue;
total += distanceByPair.get(pairKey(from, to)) ?? 0;
}
return total;
}, [form.milestones, distanceByPair]);
const resetForm = () => {
setFormOpen(false);
@@ -234,10 +261,7 @@ export default function RoutesPage() {
status: route.status,
milestones: [...(route.milestones ?? [])]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((m, index) => ({
yardId: m.yardId,
distanceKm: String(index === 0 ? 0 : (m.distanceKm ?? "")),
})),
.map((m) => ({ yardId: m.yardId })),
});
setFormOpen(true);
};
@@ -254,7 +278,7 @@ export default function RoutesPage() {
const addMilestone = () => {
setForm((current) => ({
...current,
milestones: [...current.milestones, { yardId: "", distanceKm: "" }],
milestones: [...current.milestones, { yardId: "" }],
}));
};
@@ -267,11 +291,7 @@ export default function RoutesPage() {
const buildPayload = () => ({
status: form.status,
milestones: form.milestones.map((row, index) => ({
yardId: row.yardId,
distanceKm:
index === 0 ? 0 : row.distanceKm ? Number(row.distanceKm) : undefined,
})),
milestones: form.milestones.map((row) => ({ yardId: row.yardId })),
});
const handleSubmit = async (event: FormEvent) => {
@@ -284,12 +304,23 @@ export default function RoutesPage() {
});
return;
}
for (let i = 1; i < form.milestones.length; i++) {
const km = Number(form.milestones[i].distanceKm);
if (!form.milestones[i].distanceKm || Number.isNaN(km) || km < 0) {
// Pre-empt the API's missing-pair rejection with a readable message; if the
// distance list failed to load, skip and let the API validate.
if (yardDistancesQuery.data) {
const missing: string[] = [];
for (let i = 1; i < form.milestones.length; i++) {
const from = form.milestones[i - 1].yardId;
const to = form.milestones[i].yardId;
if (!distanceByPair.has(pairKey(from, to))) {
const label = (id: string) =>
yardOptions.find((o) => o.value === id)?.label ?? id;
missing.push(`${label(from)}${label(to)}`);
}
}
if (missing.length > 0) {
toast({
title: "Save failed",
description: `Enter segment KM for stop ${i + 1}`,
description: `No distance configured for: ${missing.join(", ")}. Add it under Configuration → Yard Distances first.`,
variant: "destructive",
});
return;
@@ -580,8 +611,11 @@ export default function RoutesPage() {
: index === form.milestones.length - 1
? "Destination"
: "Milestone";
const km = segmentKm(index);
const bothSelected =
index > 0 && Boolean(row.yardId && form.milestones[index - 1]?.yardId);
return (
<Group key={`${role}-${index}`} align="flex-end" wrap="nowrap" gap="sm">
<Group key={`${role}-${index}`} align="center" wrap="nowrap" gap="sm">
<Text w={90} size="sm" fw={500}>
{role}
</Text>
@@ -594,16 +628,25 @@ export default function RoutesPage() {
searchable
/>
{index > 0 ? (
<NumberInput
w={120}
label="KM"
min={0}
decimalScale={2}
value={row.distanceKm ? Number(row.distanceKm) : ""}
onChange={(value) =>
setMilestone(index, { distanceKm: String(value ?? "") })
}
/>
<Box w={120}>
{bothSelected ? (
km != null ? (
<Text size="sm" fw={600} ta="right">
{km} km
</Text>
) : (
<Tooltip label="No distance configured for this yard pair — add it under Configuration → Yard Distances">
<Text size="xs" c="red.7" fw={600} ta="right">
Not configured
</Text>
</Tooltip>
)
) : (
<Text size="xs" c="dimmed" ta="right">
km
</Text>
)}
</Box>
) : (
<Box w={120} />
)}
@@ -619,7 +662,8 @@ export default function RoutesPage() {
);
})}
<Text size="sm" c="dimmed">
Total route distance: <strong>{formTotalKm} km</strong>
Total route distance: <strong>{formTotalKm} km</strong> segment
distances come from Configuration Yard Distances
</Text>
<Group justify="flex-end">
<Button variant="default" type="button" onClick={resetForm}>

View File

@@ -244,7 +244,12 @@ const RuleEngineResourcePage = () => {
const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } =
useWagonTypeOptions(usesWagonTypeField);
const usesYardField = Boolean(
config?.formFields.some((f) => f.name === "originYardId"),
config?.formFields.some(
(f) =>
f.name === "originYardId" ||
f.name === "fromYardId" ||
f.name === "toYardId",
),
);
const { data: yardOptions, isLoading: yardOptionsLoading } =
useYardOptions(usesYardField);
@@ -347,6 +352,19 @@ const RuleEngineResourcePage = () => {
// trade actually sits in, so an import can't be configured as if it
// started inland. Resolved per keystroke because the legal set changes
// with the direction the admin picks.
// Yard-distance endpoints have no country restriction — any yard can pair
// with any other; the other end is just excluded so A↔A can't be entered.
if (field.name === "fromYardId" || field.name === "toYardId") {
const otherEnd = field.name === "fromYardId" ? "toYardId" : "fromYardId";
return {
...field,
type: "select" as const,
optionsFromValues: (values: Record<string, unknown>) =>
(yardOptions ?? [])
.filter(({ value }) => value !== String(values[otherEnd] ?? ""))
.map(({ label, value }) => ({ label, value })),
};
}
if (field.name === "originYardId" || field.name === "destinationYardId") {
const end = field.name === "originYardId" ? "origin" : "destination";
return {
@@ -604,7 +622,7 @@ const RuleEngineResourcePage = () => {
title={config.label}
subtitle={config.subtitle}
action={
canManage ? (
canManage && config.slug !== "container-types" ? (
<Button leftSection={<Plus size={18} />} onClick={openCreate}>
{addLabel}
</Button>

View File

@@ -348,6 +348,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
activeColumn,
],
formFields: [
{ name: "code", label: "Code", type: "text", required: true },
{ name: "name", label: "Name", type: "text", required: true },
{ name: "capacityTons", label: "Capacity (tons)", type: "number", required: true },
{ name: "lengthMeters", label: "Length (meters)", type: "number", required: true },
@@ -370,6 +371,34 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ name: "isActive", label: "Active", type: "boolean" },
],
},
{
slug: "yard-distances",
label: "Yard Distances",
category: "configuration",
subtitle: "Rail distance between yard pairs — routes read their segment km from here",
searchPlaceholder: "Search by yard name or code...",
supportsSearch: true,
cardTitleKey: "fromYardLabel",
cardSubtitleKey: "toYardLabel",
columns: [
{ id: "fromYardLabel", header: "From yard", accessorKey: "fromYardLabel" },
{ id: "toYardLabel", header: "To yard", accessorKey: "toYardLabel" },
{ id: "distanceKm", header: "Distance (km)", accessorKey: "distanceKm", format: "number" },
],
formFields: [
// Options injected at render from useYardOptions (RuleEngineResourcePage).
{ name: "fromYardId", label: "From yard", type: "select", required: true, placeholder: "Select yard" },
{ name: "toYardId", label: "To yard", type: "select", required: true, placeholder: "Select yard" },
{
name: "distanceKm",
label: "Distance (km)",
type: "number",
required: true,
description:
"Symmetric — one entry covers both directions. Route segments between these yards use this value.",
},
],
},
{
slug: "priority-configs",
label: "Priority Rules",

View File

@@ -36,8 +36,11 @@ import ChangeYardModal from "@/components/trainBuilder/ChangeYardModal";
import ConsistWagonList from "@/components/trainBuilder/ConsistWagonList";
import {
directionColor,
locomotiveStatusColor,
locomotiveStatusLabel,
trainStatusColor,
trainStatusLabel,
UNFIT_LOCOMOTIVE_STATUSES,
} from "@/components/trainBuilder/trainStatus";
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
@@ -131,6 +134,9 @@ export default function TrainBuilderDetailPage() {
const { totals } = composition;
const yard = composition.currentYard;
const blockingLocomotives = composition.locomotives.filter((loco) =>
UNFIT_LOCOMOTIVE_STATUSES.has(loco.status),
);
return (
<PageContainer>
@@ -180,6 +186,7 @@ export default function TrainBuilderDetailPage() {
{composition.status === "DEACTIVATED" ? (
<Menu.Item
leftSection={<Power size={15} />}
disabled={blockingLocomotives.length > 0}
onClick={() =>
void withToast(async () => {
await activate.mutateAsync(composition.id);
@@ -234,6 +241,59 @@ export default function TrainBuilderDetailPage() {
</Alert>
) : null}
{composition.status === "DEACTIVATED" && blockingLocomotives.length > 0 ? (
<Alert color="red" icon={<AlertTriangle size={16} />}>
<Stack gap="xs">
<Text size="sm">
Cannot reactivate {blockingLocomotives.length > 1 ? "these locomotives are" : "this locomotive is"}{" "}
not fit for service:{" "}
{blockingLocomotives.map((loco, i) => (
<span key={loco.id}>
{i > 0 ? ", " : ""}
<Text span fw={600} ff="monospace">
{loco.code}
</Text>{" "}
({locomotiveStatusLabel(loco.status)})
</span>
))}
.
</Text>
<Group gap="xs">
<Button
size="compact-sm"
variant="light"
color="red"
leftSection={<Replace size={14} />}
disabled={!composition.editable}
onClick={() => setLocoModalOpen(true)}
>
Detach & replace locomotives
</Button>
<Button
size="compact-sm"
variant="subtle"
onClick={() => navigate(`/dashboard/locomotives`)}
>
Go to locomotives
</Button>
</Group>
</Stack>
</Alert>
) : null}
<Group gap="xs">
{composition.locomotives.map((loco) => (
<Badge
key={loco.id}
variant="light"
color={locomotiveStatusColor(loco.status)}
leftSection={<TrainFront size={12} />}
>
{loco.code} · {locomotiveStatusLabel(loco.status)}
</Badge>
))}
</Group>
<Stack gap="sm">
<TrainCompositionDiagram
locomotives={composition.locomotives.map((loco) => ({

View File

@@ -229,15 +229,26 @@ export const contractsService = {
approveStep: ({ id, stepId }: { id: string; stepId: string }) =>
postContract<Freight.IContract>(C.APPROVE_STEP(id, stepId)),
/**
* Reject the current step. Without `returnToStepId` the contract is rejected
* to the customer (terminal). With it, the contract is sent back to that
* earlier approved step and the chain re-runs from there.
*/
rejectStep: ({
id,
stepId,
reason,
returnToStepId,
}: {
id: string;
stepId: string;
reason: string;
}) => postContract<Freight.IContract>(C.REJECT_STEP(id, stepId), { reason }),
returnToStepId?: string;
}) =>
postContract<Freight.IContract>(C.REJECT_STEP(id, stepId), {
reason,
...(returnToStepId ? { returnToStepId } : {}),
}),
// ── Contract document ──
generateContract: (id: string) =>

View File

@@ -15,6 +15,10 @@ export type LocomotiveStatus =
export interface LocomotiveListFilters {
status?: LocomotiveStatus;
currentYardId?: string;
/** Drop locos already coupled to a built train (train-builder picker). */
excludeCoupled?: boolean;
/** With excludeCoupled: keep THIS train's own coupled locos in the list. */
excludeTrainId?: string;
}
export interface Locomotive {
@@ -48,6 +52,8 @@ export const locomotivesService = {
const params = new URLSearchParams();
if (filters.status) params.set('status', filters.status);
if (filters.currentYardId) params.set('currentYardId', filters.currentYardId);
if (filters.excludeCoupled) params.set('excludeCoupled', 'true');
if (filters.excludeTrainId) params.set('excludeTrainId', filters.excludeTrainId);
const qs = params.toString();
return apiClient.get<Locomotive[]>(
`${URL_CONSTANTS.LOCOMOTIVES.BASE}${qs ? `?${qs}` : ''}`,

View File

@@ -35,8 +35,9 @@ export interface RouteRecord {
milestones?: RouteMilestone[];
}
/** Segment km are resolved server-side from configured yard distances. */
export interface SaveRoutePayload {
milestones: Array<{ yardId: string; distanceKm?: number }>;
milestones: Array<{ yardId: string }>;
status?: RouteStatus;
}

View File

@@ -86,6 +86,7 @@ const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
"service-types": URL_CONSTANTS.RULE_ENGINE.SERVICE_TYPES,
"weight-limit-rules": URL_CONSTANTS.RULE_ENGINE.WEIGHT_LIMIT_RULES,
yards: URL_CONSTANTS.RULE_ENGINE.YARDS,
"yard-distances": URL_CONSTANTS.RULE_ENGINE.YARD_DISTANCES,
"shipping-lines": URL_CONSTANTS.RULE_ENGINE.SHIPPING_LINES,
rates: URL_CONSTANTS.RULE_ENGINE.RATES,
"approval-rules": URL_CONSTANTS.RULE_ENGINE.APPROVAL_RULES,
@@ -107,6 +108,8 @@ const byIdPath = (resource: RuleEngineResourceSlug, id: string): string => {
return URL_CONSTANTS.RULE_ENGINE.WEIGHT_LIMIT_RULE_BY_ID(id);
case "yards":
return URL_CONSTANTS.RULE_ENGINE.YARD_BY_ID(id);
case "yard-distances":
return URL_CONSTANTS.RULE_ENGINE.YARD_DISTANCE_BY_ID(id);
case "shipping-lines":
return URL_CONSTANTS.RULE_ENGINE.SHIPPING_LINE_BY_ID(id);
case "rates":

View File

@@ -6,6 +6,7 @@ export type RuleEngineResourceSlug =
| "service-types"
| "weight-limit-rules"
| "yards"
| "yard-distances"
| "shipping-lines"
| "rates"
| "approval-rules";

View File

@@ -0,0 +1,17 @@
/**
* Pull the SERVER's actual error message out of a failed request.
*
* NestJS returns `{ message: string | string[] }`; a class-validator failure is
* the array form (joined here). Falls back to the error's own `.message` — the
* axios response interceptor (see `auth/http.ts`) already rewrites that to the
* server message, so even code paths that never see the raw response body get
* the real cause — then to the caller's fallback string.
*/
export const extractErrorMessage = (err: unknown, fallback: string): string => {
const msg = (err as { response?: { data?: { message?: string | string[] } } })
?.response?.data?.message;
if (Array.isArray(msg)) return msg.filter(Boolean).join(", ");
if (typeof msg === "string" && msg) return msg;
if (err instanceof Error && err.message) return err.message;
return fallback;
};