Replace wagon/locomotive readiness with yard tracking, assign unassigned bookings from origin-yard fleet, standardize rates on USD with CBE ETB conversion, and update fleet/scheduling UI

This commit is contained in:
marshal
2026-06-14 01:32:39 +03:00
parent b73bf2154e
commit 87b0ce6339
45 changed files with 1249 additions and 520 deletions

View File

@@ -111,7 +111,10 @@ const FleetCardGrid = ({
</Text>
{subtitle != null && subtitle !== "" ? (
<Text size="xs" c="dimmed" lineClamp={1}>
{String(subtitle)}
{presentation.subtitleKey === "currentYard" ||
presentation.subtitleKey === "currentYardId"
? formatFleetCell(subtitle, "entityLabel", presentation.subtitleKey)
: String(subtitle)}
</Text>
) : null}
</Stack>

View File

@@ -522,9 +522,7 @@ export function AllocateBookingWizard({
placeholder={routeId ? "Select locomotive" : "Select a route first"}
data={(locomotivesQuery.data ?? []).map((l) => ({
value: l.id,
label: `${l.code} · ${
l.readiness === "EXPORT_READY" ? "Export-ready" : "Import-ready"
}`,
label: `${l.code}${l.name ? ` · ${l.name}` : ""}`,
}))}
value={locomotiveId || null}
onChange={(v) => setLocomotiveId(v ?? "")}

View File

@@ -18,7 +18,7 @@ import { Freight } from "@edr/types";
import type { PinWagonAssignment, TrainScheduleDetail } from "@/types/trainScheduling";
import type { Wagon } from "@/services/wagon.service";
import { wagonMatchesScheduleDirection } from "@/utils/wagonAvailability";
import { wagonMatchesScheduleOrigin } from "@/utils/wagonAvailability";
import { autoFillWagonAssignments, countFilledSlots } from "./pinWagons.util";
@@ -35,6 +35,7 @@ export function PinWagonsForm({
onSubmit: (assignments: PinWagonAssignment[]) => void;
autoFillOnMount?: boolean;
}) {
const originYardId = schedule.originStation?.id;
const slots = schedule.trainSet?.wagons ?? [];
const [assignments, setAssignments] = useState<Record<string, string>>({});
@@ -43,7 +44,7 @@ export function PinWagonsForm({
for (const wagon of availableWagons) {
const isPinnedOnSlot = slots.some((s) => s.physicalWagonId === wagon.id);
if (
!wagonMatchesScheduleDirection(wagon, schedule.direction, {
!wagonMatchesScheduleOrigin(wagon, originYardId, {
allowPinned: isPinnedOnSlot,
})
) {
@@ -58,7 +59,7 @@ export function PinWagonsForm({
map.set(typeId, list);
}
return map;
}, [availableWagons, schedule.direction, slots]);
}, [availableWagons, originYardId, slots]);
const runAutoFill = useCallback(
(preserveManual = false) => {

View File

@@ -50,30 +50,20 @@ export const CompositionBookingTabs = ({
const unassignedQuery = useUnassignedBookings(scheduleId);
const removalsQuery = useCompositionRemovals(scheduleId);
const { assignedCount, freeWagons, freeWeightTons } = useMemo(() => {
const { assignedCount } = useMemo(() => {
const wagons = scheduleDetail.trainSet?.wagons ?? [];
const ids = new Set<string>();
let usedWeight = 0;
let empty = 0;
for (const w of wagons) {
const allocs = w.allocations ?? [];
if (allocs.length === 0) empty += 1;
for (const a of allocs) {
for (const a of w.allocations ?? []) {
ids.add(a.bookingId);
usedWeight += a.allocatedWeightTons ?? 0;
}
}
const maxWeight = scheduleDetail.trainSet?.locomotive?.maxPullWeightTons ?? null;
return {
assignedCount: ids.size,
freeWagons: empty,
freeWeightTons: maxWeight != null ? Math.max(0, maxWeight - usedWeight) : null,
};
}, [scheduleDetail.trainSet?.wagons, scheduleDetail.trainSet?.locomotive?.maxPullWeightTons]);
return { assignedCount: ids.size };
}, [scheduleDetail.trainSet?.wagons]);
const counts: Record<TabKey, number> = {
assigned: assignedCount,
unassigned: unassignedQuery.data?.length ?? 0,
unassigned: unassignedQuery.data?.bookings?.length ?? 0,
payment: awaitingPayment.length,
expired: expired.length,
removed: removalsQuery.data?.length ?? 0,
@@ -188,8 +178,6 @@ export const CompositionBookingTabs = ({
scheduleId={scheduleId}
selectedBookingId={selectedBookingId}
onSelect={handleSelect}
freeWagons={freeWagons}
freeWeightTons={freeWeightTons}
/>
</Tabs.Panel>

View File

@@ -1,25 +1,26 @@
import { Badge, Box, Button, Card, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
import { AlertTriangle, Container as ContainerIcon, Plus, TrainFront, Weight } from "lucide-react";
import { AlertTriangle, Container as ContainerIcon, MapPin, Plus, TrainFront } from "lucide-react";
import {
useUnassignedBookings,
useScheduleMutations,
} from "@/hooks/trainScheduling/useTrainScheduling";
import { useToast } from "@/hooks/use-toast";
import type { FleetAvailabilityRow } from "@/types/trainScheduling";
import type { BookingDetailData } from "./BookingDetailModal";
interface UnassignedBookingsPanelProps {
scheduleId: string;
selectedBookingId?: string | null;
onSelect: (booking: BookingDetailData) => void;
/** Empty wagon slots currently available on the train. */
freeWagons: number;
/** Remaining pull-weight headroom in tons, or null when no locomotive limit. */
freeWeightTons: number | null;
}
const parseError = (error: unknown): string | null => {
if (error && typeof error === "object" && "response" in error) {
const resp = (error as { response?: { data?: { message?: unknown } } }).response;
const resp = (error as {
response?: { data?: { message?: unknown; violations?: string[] } };
}).response;
const violations = resp?.data?.violations;
if (Array.isArray(violations) && violations.length) return violations.join("; ");
const msg = resp?.data?.message;
if (Array.isArray(msg)) return msg.join(", ");
if (typeof msg === "string") return msg;
@@ -27,29 +28,65 @@ const parseError = (error: unknown): string | null => {
return null;
};
const YardFleetBanner = ({ fleetAtOrigin }: { fleetAtOrigin: FleetAvailabilityRow[] }) => {
if (!fleetAtOrigin.length) {
return (
<Group gap={5} wrap="nowrap" px="sm" py={6} style={{ borderRadius: 8, background: "var(--mantine-color-red-0)", border: "1px solid var(--mantine-color-red-2)" }}>
<MapPin size={13} color="var(--mantine-color-red-6)" />
<Text size="xs" fw={700} c="red.7">
No wagons at origin yard
</Text>
</Group>
);
}
return (
<Group
gap="xs"
wrap="wrap"
px="sm"
py={6}
style={{
borderRadius: 8,
background: "var(--mantine-color-green-0)",
border: "1px solid var(--mantine-color-green-1)",
}}
>
<Group gap={5} wrap="nowrap">
<MapPin size={13} color="var(--mantine-color-green-7)" />
<Text size="xs" fw={700} c="green.8">
Origin yard
</Text>
</Group>
{fleetAtOrigin.map((row) => (
<Badge key={row.wagonTypeId} size="sm" variant="light" color="green">
{row.wagonTypeCode}: {row.available}
</Badge>
))}
</Group>
);
};
export const UnassignedBookingsPanel = ({
scheduleId,
selectedBookingId,
onSelect,
freeWagons,
freeWeightTons,
}: UnassignedBookingsPanelProps) => {
const { toast } = useToast();
const unassignedQuery = useUnassignedBookings(scheduleId);
const assignMutation = useScheduleMutations(scheduleId).assign;
const assignMutation = useScheduleMutations(scheduleId).assignUnassigned;
const handleAssign = async (bookingId: string, reference: string | null) => {
try {
await assignMutation.mutateAsync({
id: scheduleId,
payload: { bookingIds: [bookingId] },
bookingId,
});
toast({ title: `Assigned ${reference ?? "booking"} to the train` });
} catch (err) {
toast({
title: "Could not assign booking",
description:
parseError(err) ?? "No free wagon or not enough space for this booking.",
description: parseError(err) ?? "Assignment failed — check yard fleet and train limits.",
variant: "destructive",
});
}
@@ -63,7 +100,8 @@ export const UnassignedBookingsPanel = ({
);
}
const bookings = unassignedQuery.data ?? [];
const bookings = unassignedQuery.data?.bookings ?? [];
const fleetAtOrigin = unassignedQuery.data?.fleetAtOrigin ?? [];
if (bookings.length === 0) {
return (
@@ -81,53 +119,15 @@ export const UnassignedBookingsPanel = ({
);
}
const noFreeWagon = freeWagons <= 0;
return (
<Stack gap="xs">
{/* Capacity availability banner */}
<Group
justify="space-between"
wrap="nowrap"
px="sm"
py={6}
style={{
borderRadius: 8,
background: noFreeWagon ? "var(--mantine-color-red-0)" : "var(--mantine-color-green-0)",
border: `1px solid ${
noFreeWagon ? "var(--mantine-color-red-2)" : "var(--mantine-color-green-1)"
}`,
}}
>
<Group gap={5} wrap="nowrap">
<TrainFront
size={13}
color={noFreeWagon ? "var(--mantine-color-red-6)" : "var(--mantine-color-green-7)"}
/>
<Text size="xs" fw={700} c={noFreeWagon ? "red.7" : "green.8"}>
{freeWagons} free wagon{freeWagons === 1 ? "" : "s"}
</Text>
</Group>
{freeWeightTons != null ? (
<Group gap={5} wrap="nowrap">
<Weight size={13} color="var(--mantine-color-gray-6)" />
<Text size="xs" c="dimmed">
{freeWeightTons.toFixed(1)} T headroom
</Text>
</Group>
) : null}
</Group>
<YardFleetBanner fleetAtOrigin={fleetAtOrigin} />
{bookings.map((booking) => {
const isActive = selectedBookingId === booking.id;
const weight = booking.cargoTotalWeightVgm ?? 0;
const overWeight = freeWeightTons != null && weight > freeWeightTons;
const fits = !noFreeWagon && !overWeight;
const blockReason = noFreeWagon
? "No free wagon on this train"
: overWeight
? "Exceeds remaining weight headroom"
: null;
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
const fits = booking.canAssign;
const blockReason = booking.blockReason;
return (
<Card
@@ -141,7 +141,7 @@ export const UnassignedBookingsPanel = ({
reference: booking.reference,
company: null,
freightType: booking.freightType,
weightTons: booking.cargoTotalWeightVgm ?? null,
weightTons: Number.isFinite(weight) ? weight : null,
status: booking.status,
priorityScore: booking.priorityScore,
})
@@ -172,9 +172,9 @@ export const UnassignedBookingsPanel = ({
{booking.freightType}
</Badge>
<Group gap={3} wrap="nowrap">
<Weight size={11} color="var(--mantine-color-gray-6)" />
<TrainFront size={11} color="var(--mantine-color-gray-6)" />
<Text size="11px" c="dimmed">
{weight.toFixed(1)} T
{booking.wagonsRequired}× {booking.requiredWagonTypeCode}
</Text>
</Group>
</Group>
@@ -202,7 +202,7 @@ export const UnassignedBookingsPanel = ({
}}
loading={
assignMutation.isPending &&
assignMutation.variables?.payload.bookingIds?.[0] === booking.id
assignMutation.variables?.bookingId === booking.id
}
leftSection={<Plus size={12} />}
>

View File

@@ -1,10 +1,11 @@
import { useState } from "react";
import { useState, useMemo } from "react";
import { Plus } from "lucide-react";
import { Button, Group, Modal, NumberInput, Select, Stack, Text } from "@mantine/core";
import { Freight } from "@edr/types";
import { useToast } from "@/hooks/use-toast";
import { useRouteYards } from "@/hooks/useRoutes";
import { useAssignWagonToTrain, useWagons } from "@/hooks/useWagons";
export function AssignWagonDialog({ trainId }: { trainId: string }) {
@@ -12,6 +13,7 @@ export function AssignWagonDialog({ trainId }: { trainId: string }) {
const [wagonId, setWagonId] = useState<string | null>(null);
const [sequence, setSequence] = useState<number | "">("");
const { data: wagons } = useWagons();
const { data: yards = [] } = useRouteYards();
const assign = useAssignWagonToTrain();
const { toast } = useToast();
@@ -19,9 +21,19 @@ export function AssignWagonDialog({ trainId }: { trainId: string }) {
(w) => w.status === Freight.WagonStatus.Available || !w.trainId,
);
const yardLabelById = useMemo(
() =>
new Map(
yards.map((y) => [y.id, y.label ?? y.code ?? y.id]),
),
[yards],
);
const wagonOptions = available.map((w) => ({
value: w.id,
label: `${w.wagonNumber} (${w.readiness.replace("_", " ").toLowerCase()})`,
label: w.currentYardId
? `${w.wagonNumber} (${yardLabelById.get(w.currentYardId) ?? "yard"})`
: `${w.wagonNumber} (no yard)`,
}));
const handleAssign = async () => {

View File

@@ -144,6 +144,8 @@ export const URL_CONSTANTS = {
`/train-scheduling/batch-board/${scheduleId}`,
RUN_BATCH: (id: string) => `/train-scheduling/schedules/${id}/run-batch`,
RUN_ALLOCATION: (id: string) => `/train-scheduling/schedules/${id}/run-allocation`,
ASSIGN_UNASSIGNED_BOOKING: (id: string) =>
`/train-scheduling/schedules/${id}/assign-unassigned-booking`,
BOOKING_WINDOW: (id: string) => `/train-scheduling/schedules/${id}/booking-window`,
MARK_BOOKING_PAID: (bookingId: string) =>
`/train-scheduling/bookings/${bookingId}/mark-paid`,

View File

@@ -197,6 +197,12 @@ export const useScheduleMutations = (scheduleId?: string) => {
onSuccess: invalidate,
});
const assignUnassigned = useMutation({
mutationFn: ({ id, bookingId }: { id: string; bookingId: string }) =>
trainSchedulingService.assignUnassignedBooking(id, bookingId),
onSuccess: invalidate,
});
const unassign = useMutation({
mutationFn: ({ id, bookingId }: { id: string; bookingId: string }) =>
trainSchedulingService.unassignBooking(id, bookingId),
@@ -240,6 +246,7 @@ export const useScheduleMutations = (scheduleId?: string) => {
create,
preview,
assign,
assignUnassigned,
unassign,
pin,
finalize,

View File

@@ -16,6 +16,7 @@ import { useWagonTypes } from "@/hooks/use-wagon-types";
import { useFleetList, useFleetMutations } from "@/hooks/fleet/useFleet";
import { useContainers } from "@/hooks/useContainers";
import { useToast } from "@/hooks/use-toast";
import { useRouteYards } from "@/hooks/useRoutes";
import { useWagons } from "@/hooks/useWagons";
import type { FleetListFilters } from "@/services/fleet/fleet.service";
import {
@@ -49,12 +50,12 @@ const FleetResourcePage = () => {
if (slug !== "wagons" && slug !== "locomotives") return undefined;
const filters: FleetListFilters = {};
const status = listFilterValues.status;
const readiness = listFilterValues.readiness;
const currentYardId = listFilterValues.currentYardId;
if (status && status !== "ALL") {
filters.status = status as FleetListFilters["status"];
(filters as { status?: string }).status = status;
}
if (readiness && readiness !== "ALL") {
filters.readiness = readiness as FleetListFilters["readiness"];
if (currentYardId && currentYardId !== "ALL") {
filters.currentYardId = currentYardId;
}
if (slug === "wagons" && search.trim()) {
filters.search = search.trim();
@@ -70,6 +71,7 @@ const FleetResourcePage = () => {
const { data: cargoTypes = [], isLoading: cargoTypesLoading } = useCargoTypes();
const { data: wagons = [], isLoading: wagonsLoading } = useWagons();
const { data: containers = [], isLoading: containersLoading } = useContainers();
const { data: yards = [], isLoading: yardsLoading } = useRouteYards();
useEffect(() => {
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
@@ -98,18 +100,6 @@ const FleetResourcePage = () => {
];
}, [allRows, hasStatusColumn, usesServerListFilters]);
const listFilterSelects = useMemo(() => {
if (!config?.listFilters?.length) return null;
return config.listFilters.map((filter) => ({
...filter,
value: listFilterValues[filter.key] ?? "ALL",
data: [
{ value: "ALL", label: filter.allLabel ?? `All ${filter.label.toLowerCase()}` },
...filter.options.map((opt) => ({ value: opt.value, label: opt.label })),
],
}));
}, [config?.listFilters, listFilterValues]);
const dynamicOptions = useMemo(() => {
const wagonTypeOpts = (wagonTypes as Array<{ id: string; code: string; name?: string }>).map(
(t) => ({ value: t.id, label: `${t.code}${t.name ? ` - ${t.name}` : ""}` }),
@@ -128,14 +118,41 @@ const FleetResourcePage = () => {
(c) => ({ value: c.id, label: c.containerNumber }),
);
const yardOpts = (yards as Array<{ id: string; label?: string; code?: string }>).map(
(y) => ({ value: y.id, label: y.label ?? y.code ?? y.id }),
);
registerFleetOptionLabels("currentYardId", yardOpts);
return {
wagonTypes: wagonTypeOpts,
containerTypes: containerTypeOpts,
cargoTypes: [{ label: "None", value: FLEET_SELECT_NONE }, ...cargoTypeOpts],
wagons: [{ label: "Unassigned", value: FLEET_SELECT_NONE }, ...wagonOpts],
containers: containerOpts,
yards: yardOpts,
};
}, [wagonTypes, containerTypes, cargoTypes, wagons, containers]);
}, [wagonTypes, containerTypes, cargoTypes, wagons, containers, yards]);
const listFilterSelects = useMemo(() => {
if (!config?.listFilters?.length) return null;
return config.listFilters.map((filter) => {
const dynamicOpts = filter.dynamicOptions
? (dynamicOptions[filter.dynamicOptions] ?? [])
: [];
const staticOpts =
filter.options?.map((opt) => ({ value: opt.value, label: opt.label })) ?? [];
const opts = filter.dynamicOptions ? dynamicOpts : staticOpts;
return {
...filter,
value: listFilterValues[filter.key] ?? "ALL",
data: [
{ value: "ALL", label: filter.allLabel ?? `All ${filter.label.toLowerCase()}` },
...opts,
],
};
});
}, [config?.listFilters, listFilterValues, dynamicOptions]);
useEffect(() => {
registerFleetOptionLabels("wagonTypeId", dynamicOptions.wagonTypes);
@@ -146,6 +163,7 @@ const FleetResourcePage = () => {
);
registerFleetOptionLabels("wagonId", dynamicOptions.wagons);
registerFleetOptionLabels("containerId", dynamicOptions.containers);
registerFleetOptionLabels("currentYardId", dynamicOptions.yards);
}, [dynamicOptions]);
const formFields = useMemo((): FleetFormFieldDef[] => {
@@ -158,7 +176,12 @@ const FleetResourcePage = () => {
}, [config, dynamicOptions]);
const selectOptionsLoading =
wagonTypesLoading || containerTypesLoading || cargoTypesLoading || wagonsLoading || containersLoading;
wagonTypesLoading ||
containerTypesLoading ||
cargoTypesLoading ||
wagonsLoading ||
containersLoading ||
yardsLoading;
const filteredRows = useMemo(() => {
if (!config) return allRows;
@@ -222,7 +245,7 @@ const FleetResourcePage = () => {
});
return base;
}, [config]);
}, [config, dynamicOptions.yards]);
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";

View File

@@ -1,5 +1,4 @@
import { Freight } from "@edr/types";
import type { ColumnFormat, FormFieldDef } from "@/pages/ruleEngine/config/resources";
export type FleetResourceSlug =
@@ -20,7 +19,8 @@ export type FleetDynamicOptions =
| "containerTypes"
| "cargoTypes"
| "wagons"
| "containers";
| "containers"
| "yards";
export interface FleetResourceColumn {
id: string;
@@ -35,10 +35,11 @@ export interface FleetFormFieldDef extends FormFieldDef {
}
export interface FleetListFilterDef {
key: "status" | "readiness" | "wagonTypeId" | "trainId";
key: "status" | "currentYardId" | "wagonTypeId" | "trainId";
label: string;
options: Array<{ value: string; label: string }>;
options?: Array<{ value: string; label: string }>;
allLabel?: string;
dynamicOptions?: FleetDynamicOptions;
}
export interface FleetResourceConfig {
@@ -93,10 +94,6 @@ const WAGON_STATUS_OPTIONS = [
{ label: "Retired", value: Freight.WagonStatus.Retired },
];
const WAGON_READINESS_OPTIONS = [
{ label: "Import ready", value: Freight.WagonReadiness.ImportReady },
{ label: "Export ready", value: Freight.WagonReadiness.ExportReady },
];
export const FLEET_RESOURCES: FleetResourceConfig[] = [
{
@@ -122,19 +119,19 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
options: LOCOMOTIVE_STATUS_OPTIONS,
},
{
key: "readiness",
label: "Readiness",
allLabel: "All readiness",
options: WAGON_READINESS_OPTIONS,
key: "currentYardId",
label: "Current Yard",
allLabel: "All yards",
dynamicOptions: "yards",
},
],
cardSubtitleKey: "readiness",
searchKeys: ["code", "name", "locomotiveType", "status", "readiness"],
cardSubtitleKey: "currentYard",
searchKeys: ["code", "name", "locomotiveType", "status", "currentYardId"],
columns: [
{ id: "code", header: "Code", accessorKey: "code", format: "code" },
{ id: "name", header: "Name", accessorKey: "name" },
{ id: "locomotiveType", header: "Type", accessorKey: "locomotiveType" },
{ id: "readiness", header: "Readiness", accessorKey: "readiness", format: "statusBadge" },
{ id: "currentYard", header: "Current Yard", accessorKey: "currentYard", format: "entityLabel" },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
{ id: "maxPullWeightTons", header: "Max pull (tons)", accessorKey: "maxPullWeightTons", format: "number" },
{ id: "maxTrainLengthMeters", header: "Max length (m)", accessorKey: "maxTrainLengthMeters", format: "number" },
@@ -144,7 +141,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
{ name: "name", label: "Name", type: "text" },
{ name: "locomotiveType", label: "Locomotive type", type: "select", required: true, options: LOCOMOTIVE_TYPE_OPTIONS },
{ name: "status", label: "Status", type: "select", required: true, options: LOCOMOTIVE_STATUS_OPTIONS },
{ name: "readiness", label: "Readiness", type: "select", required: true, options: WAGON_READINESS_OPTIONS },
{ name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" },
{ name: "maxPullWeightTons", label: "Max pulling weight (tons)", type: "number", required: true },
{ name: "maxTrainLengthMeters", label: "Max train length (meters)", type: "number", required: true },
{ name: "powerKw", label: "Power (kW)", type: "number" },
@@ -156,7 +153,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
name: "",
locomotiveType: "DIESEL",
status: "AVAILABLE",
readiness: Freight.WagonReadiness.ImportReady,
currentYardId: "",
maxPullWeightTons: 0,
maxTrainLengthMeters: 760,
powerKw: "",
@@ -225,20 +222,20 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
options: WAGON_STATUS_OPTIONS,
},
{
key: "readiness",
label: "Readiness",
allLabel: "All readiness",
options: WAGON_READINESS_OPTIONS,
key: "currentYardId",
label: "Current Yard",
allLabel: "All yards",
dynamicOptions: "yards",
},
],
cardTitleKey: "wagonNumber",
cardSubtitleKey: "readiness",
searchKeys: ["wagonNumber", "wagonTypeId", "trainId", "status", "readiness"],
cardSubtitleKey: "currentYard",
searchKeys: ["wagonNumber", "wagonTypeId", "trainId", "status", "currentYardId"],
columns: [
{ id: "wagonNumber", header: "Number", accessorKey: "wagonNumber", format: "code" },
{ id: "wagonTypeId", header: "Type", accessorKey: "wagonTypeId", format: "entityLabel" },
{ id: "maxPayloadWeight", header: "Max payload", accessorKey: "maxPayloadWeight", format: "number" },
{ id: "readiness", header: "Readiness", accessorKey: "readiness", format: "statusBadge" },
{ id: "currentYard", header: "Current Yard", accessorKey: "currentYard", format: "entityLabel" },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
],
formFields: [
@@ -246,7 +243,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
{ name: "wagonTypeId", label: "Wagon type", type: "select", required: true, dynamicOptions: "wagonTypes" },
{ name: "tareWeight", label: "Tare weight", type: "number", required: true },
{ name: "maxPayloadWeight", label: "Max payload weight", type: "number", required: true },
{ name: "readiness", label: "Readiness", type: "select", required: true, options: WAGON_READINESS_OPTIONS },
{ name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" },
{ name: "status", label: "Status", type: "select", required: true, options: WAGON_STATUS_OPTIONS },
{ name: "notes", label: "Notes", type: "textarea" },
],
@@ -255,7 +252,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
wagonTypeId: "",
tareWeight: 0,
maxPayloadWeight: 0,
readiness: Freight.WagonReadiness.ImportReady,
currentYardId: "",
status: Freight.WagonStatus.Available,
notes: "",
},

View File

@@ -296,9 +296,12 @@ const RuleEngineResourcePage = () => {
};
const handleFormSubmit = (values: Record<string, unknown>) => {
const payload =
config.slug === "rates" ? { ...values, currency: "USD" } : values;
if (editing?.id) {
update.mutate(
{ id: editing.id, payload: values },
{ id: editing.id, payload },
{
onSuccess: () => {
setFormOpen(false);
@@ -307,7 +310,7 @@ const RuleEngineResourcePage = () => {
},
);
} else {
create.mutate(values, {
create.mutate(payload, {
onSuccess: () => {
setFormOpen(false);
setEditing(null);

View File

@@ -110,10 +110,7 @@ const RATE_UNITS = ["PER_WAGON", "PER_TON", "PER_CONTAINER", "PER_KM", "FLAT"].m
value: v,
}));
const CURRENCIES = [
{ label: "ETB", value: "ETB" },
{ label: "USD", value: "USD" },
];
const CURRENCIES = [{ label: "USD", value: "USD" }];
const codeColumn = (key: string, header = "Code"): ResourceColumn => ({
id: key,
@@ -430,7 +427,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
type: "select",
options: TRADE_DIRECTIONS,
},
{ name: "currency", label: "Currency", type: "select", required: true, options: CURRENCIES },
{ name: "rateValue", label: "Rate value", type: "number", required: true },
{ name: "rateUnit", label: "Rate unit", type: "select", required: true, options: RATE_UNITS },
{ name: "effectiveFrom", label: "Effective from", type: "date", required: true },

View File

@@ -90,7 +90,7 @@ export default function TrainScheduleTrackPage() {
onSuccess: () => {
toast({
title: isFinal
? "Train arrived — assets freed, readiness flipped"
? "Train arrived — assets freed, moved to destination yard"
: "Checkpoint logged",
});
},

View File

@@ -785,11 +785,11 @@ export default function TrainScheduleV2DetailPage() {
label="Locomotive"
value={schedule.trainSet?.locomotive?.code ?? "—"}
hint={
schedule.trainSet?.locomotive?.readiness === "EXPORT_READY"
? "Export-ready"
: schedule.trainSet?.locomotive?.readiness === "IMPORT_READY"
? "Import-ready"
: undefined
schedule.trainSet?.locomotive?.currentYardId
? schedule.trainSet.locomotive.currentYardId === schedule.originStation?.id
? `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? "origin yard"}`
: "Not at schedule origin yard"
: "No current yard set"
}
accent="#F2A516"
graph="area"

View File

@@ -89,15 +89,13 @@ export default function TrainScheduleV2ListPage() {
const selectedRoute = activeRoutes.find((r) => r.id === routeId);
const locomotiveReadinessHint = useMemo(() => {
const locomotiveYardHint = useMemo(() => {
if (!selectedRoute) return "Select a route first";
const origin = selectedRoute.originYard?.country?.trim();
const dest = selectedRoute.destinationYard?.country?.trim();
if (origin === "Djibouti") return "Import corridor — import-ready locomotives only";
if (dest === "Djibouti" && origin !== "Djibouti") {
return "Export corridor — export-ready locomotives only";
}
return "Domestic corridor — any readiness";
const originLabel =
selectedRoute.originYard?.label ??
selectedRoute.originYard?.code ??
"the route origin yard";
return `Only locomotives currently at ${originLabel} are shown`;
}, [selectedRoute]);
useEffect(() => {
@@ -525,7 +523,7 @@ export default function TrainScheduleV2ListPage() {
/>
{routeId ? (
<Text size="xs" c="dimmed">
{locomotiveReadinessHint}
{locomotiveYardHint}
</Text>
) : null}
<TextInput
@@ -542,9 +540,7 @@ export default function TrainScheduleV2ListPage() {
placeholder={routeId ? "Select locomotive" : "Select a route first"}
data={(locomotivesQuery.data ?? []).map((l) => ({
value: l.id,
label: `${l.code}${l.name ? `${l.name}` : ""} · ${
l.readiness === "EXPORT_READY" ? "Export-ready" : "Import-ready"
}`,
label: `${l.code}${l.name ? `${l.name}` : ""}`,
}))}
value={locomotiveId || null}
onChange={(v) => setLocomotiveId(v ?? "")}

View File

@@ -1,5 +1,3 @@
import type { Freight } from '@edr/types';
import { api as apiClient } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS';
@@ -13,7 +11,7 @@ export type LocomotiveStatus =
export interface LocomotiveListFilters {
status?: LocomotiveStatus;
readiness?: Freight.WagonReadiness;
currentYardId?: string;
}
export interface Locomotive {
@@ -22,7 +20,8 @@ export interface Locomotive {
name?: string | null;
locomotiveType: LocomotiveType;
status: LocomotiveStatus;
readiness: Freight.WagonReadiness;
currentYardId: string | null;
currentYard?: { id: string; label?: string; code?: string } | null;
maxPullWeightTons: number;
maxTrainLengthMeters: number;
powerKw?: number | null;
@@ -41,7 +40,7 @@ export const locomotivesService = {
getAll: (filters: LocomotiveListFilters = {}) => {
const params = new URLSearchParams();
if (filters.status) params.set('status', filters.status);
if (filters.readiness) params.set('readiness', filters.readiness);
if (filters.currentYardId) params.set('currentYardId', filters.currentYardId);
const qs = params.toString();
return apiClient.get<Locomotive[]>(
`${URL_CONSTANTS.LOCOMOTIVES.BASE}${qs ? `?${qs}` : ''}`,

View File

@@ -8,6 +8,7 @@ import type {
AssignBookingsPayload,
CompositionRemovalEntry,
CompositionUnassignedBooking,
UnassignedBookingsResponse,
CreateTrainSchedulePayload,
EligibleContainerBookingsResponse,
FreightType,
@@ -162,6 +163,17 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
assignUnassignedBooking: async (
scheduleId: string,
bookingId: string,
): Promise<TrainScheduleDetail> => {
const response = await client.post<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.ASSIGN_UNASSIGNED_BOOKING(scheduleId),
{ bookingId },
);
return unwrap(response.data);
},
assignBookings: async (
scheduleId: string,
payload: AssignBookingsPayload,
@@ -374,8 +386,8 @@ export const trainSchedulingService = {
getUnassignedBookings: async (
scheduleId: string,
): Promise<CompositionUnassignedBooking[]> => {
const response = await client.get<CompositionUnassignedBooking[]>(
): Promise<UnassignedBookingsResponse> => {
const response = await client.get<UnassignedBookingsResponse>(
URL_CONSTANTS.TRAIN_SCHEDULING.UNASSIGNED_BOOKINGS(scheduleId),
);
return unwrap(response.data);

View File

@@ -11,14 +11,15 @@ export interface Wagon {
tareWeight: number;
maxPayloadWeight: number;
status: Freight.WagonStatus;
readiness: Freight.WagonReadiness;
currentYardId: string | null;
currentYard?: { id: string; label?: string; code?: string } | null;
notes?: string;
}
export interface WagonListFilters {
search?: string;
status?: Freight.WagonStatus;
readiness?: Freight.WagonReadiness;
currentYardId?: string;
wagonTypeId?: string;
trainId?: string;
}
@@ -28,7 +29,7 @@ export const wagonService = {
const params = new URLSearchParams();
if (filters.search?.trim()) params.set('search', filters.search.trim());
if (filters.status) params.set('status', filters.status);
if (filters.readiness) params.set('readiness', filters.readiness);
if (filters.currentYardId) params.set('currentYardId', filters.currentYardId);
if (filters.wagonTypeId) params.set('wagonTypeId', filters.wagonTypeId);
if (filters.trainId) params.set('trainId', filters.trainId);
const qs = params.toString();

View File

@@ -127,8 +127,6 @@ export interface TrainSchedulePreviewResponse {
containerSlotSequenceNos?: number[];
}
export type Readiness = "IMPORT_READY" | "EXPORT_READY";
export interface LocomotiveRecord {
id: string;
code: string;
@@ -136,7 +134,7 @@ export interface LocomotiveRecord {
maxPullWeightTons: number;
maxTrainLengthMeters: number;
status: "AVAILABLE" | "ASSIGNED" | "MAINTENANCE" | "OUT_OF_SERVICE";
readiness?: Readiness | null;
currentYardId?: string | null;
locomotiveType?: "DIESEL" | "ELECTRIC";
}
@@ -153,7 +151,7 @@ export interface TrainScheduleListItem {
id: string;
code: string;
name?: string | null;
readiness?: Readiness | null;
currentYardId?: string | null;
}
| null;
wagonCount: number;
@@ -352,7 +350,7 @@ export interface TrainScheduleDetail {
code: string;
name?: string | null;
status: string;
readiness?: Readiness | null;
currentYardId?: string | null;
maxPullWeightTons: number;
maxTrainLengthMeters?: number;
} | null;
@@ -496,6 +494,16 @@ export interface CompositionUnassignedBooking {
cargoTotalWeightVgm: number;
status: string | null;
schedulingStatus: SchedulingStatus | null;
wagonsRequired: number;
requiredWagonTypeCode: string;
yardWagonsAvailable: number;
canAssign: boolean;
blockReason: string | null;
}
export interface UnassignedBookingsResponse {
fleetAtOrigin: FleetAvailabilityRow[];
bookings: CompositionUnassignedBooking[];
}
export interface CompositionRemovalEntry {

View File

@@ -2,31 +2,30 @@ import { Freight } from "@edr/types";
import type { Wagon } from "@/services/wagon.service";
export function wagonMatchesScheduleDirection(
wagon: Pick<Wagon, "status" | "readiness">,
scheduleDirection?: string | null,
/** Check if a wagon is available for a schedule based on its current yard.
* A wagon is eligible if:
* 1. It is available (or pinned to this schedule)
* 2. It is physically located at the schedule's origin yard
*/
export function wagonMatchesScheduleOrigin(
wagon: Pick<Wagon, "id" | "status" | "currentYardId">,
originYardId?: string | null,
options?: { allowPinned?: boolean },
): boolean {
if (options?.allowPinned) return true;
if (wagon.status !== Freight.WagonStatus.Available) return false;
if (!scheduleDirection || scheduleDirection === "DOMESTIC") return true;
if (scheduleDirection === "IMPORT") {
return wagon.readiness === Freight.WagonReadiness.ImportReady;
}
if (scheduleDirection === "EXPORT") {
return wagon.readiness === Freight.WagonReadiness.ExportReady;
}
return true;
if (!originYardId) return true;
return wagon.currentYardId === originYardId;
}
export function filterWagonsForSchedule(
wagons: Wagon[],
scheduleDirection?: string | null,
originYardId?: string | null,
pinnedWagonIds?: Set<string>,
): Wagon[] {
return wagons.filter((wagon) => {
const isPinned = pinnedWagonIds?.has(wagon.id) ?? false;
return wagonMatchesScheduleDirection(wagon, scheduleDirection, {
return wagonMatchesScheduleOrigin(wagon, originYardId, {
allowPinned: isPinned,
});
});