mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 22:58:17 +00:00
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:
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
Reference in New Issue
Block a user