mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 05:18:11 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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 ?? "")}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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} />}
|
||||
>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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: "",
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ?? "")}
|
||||
|
||||
@@ -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}` : ''}`,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user