auto allocation and batch managemnt, tracking the train

This commit is contained in:
marshal
2026-06-12 11:42:46 +03:00
parent 8618ea2aa8
commit ef0abf1c41
61 changed files with 3541 additions and 378 deletions

View File

@@ -8,7 +8,6 @@ import {
Loader,
Paper,
Progress,
ScrollArea,
SimpleGrid,
Stack,
Text,
@@ -18,79 +17,30 @@ import {
} from "@mantine/core";
import {
ArrowRight,
CheckCircle2,
Clock,
Hourglass,
LayoutGrid,
RefreshCw,
Ruler,
Train,
Weight,
XCircle,
} from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { useBatchBoard } from "@/hooks/trainScheduling/useTrainScheduling";
import type {
BatchBoardBooking,
BatchBoardBookingState,
BatchBoardSchedule,
} from "@/types/trainScheduling";
const STATE_META: Record<
BatchBoardBookingState,
{ label: string; color: string; icon: typeof CheckCircle2 }
> = {
ALLOCATED: { label: "Allocated", color: "green", icon: CheckCircle2 },
AWAITING_PAYMENT: { label: "Awaiting payment", color: "orange", icon: Clock },
WAITING: { label: "Paid · waiting", color: "blue", icon: Hourglass },
PENDING_CONTRACT: { label: "Pending contract", color: "gray", icon: Hourglass },
EXPIRED: { label: "Expired", color: "red", icon: XCircle },
};
import type { BatchBoardSchedule } from "@/types/trainScheduling";
const fmtTons = (n: number) =>
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`;
function StateBadge({ state }: { state: BatchBoardBookingState }) {
const meta = STATE_META[state];
const Icon = meta.icon;
return (
<Badge variant="light" color={meta.color} radius="sm" leftSection={<Icon size={11} />}>
{meta.label}
</Badge>
);
}
function BookingRow({ booking }: { booking: BatchBoardBooking }) {
return (
<Group justify="space-between" wrap="nowrap" gap="sm" py={6} px="xs">
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{booking.reference}
</Text>
{booking.isGovernment ? (
<Badge size="xs" variant="light" color="grape" radius="sm">
Gov
</Badge>
) : null}
<Text size="xs" c="dimmed" truncate>
{booking.company}
</Text>
</Group>
<Group gap="sm" wrap="nowrap" style={{ flexShrink: 0 }}>
<Text size="xs" c="dimmed">
{booking.wagons}w · {fmtTons(booking.weightTons)}
</Text>
<StateBadge state={booking.state} />
</Group>
</Group>
);
}
const fmtMeters = (n: number) =>
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} m`;
function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
const navigate = useNavigate();
const { capacity, counts, locomotive } = schedule;
const wagonPct =
capacity.maxWagons > 0 ? (capacity.usedWagons / capacity.maxWagons) * 100 : 0;
const lengthPct =
capacity.maxLengthMeters && capacity.maxLengthMeters > 0
? (capacity.allocatedLengthMeters / capacity.maxLengthMeters) * 100
: 0;
const weightPct =
capacity.maxWeightTons && capacity.maxWeightTons > 0
? (capacity.usedWeightTons / capacity.maxWeightTons) * 100
@@ -103,9 +53,34 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
? "orange"
: "gray";
const totalBookings =
counts.allocated +
counts.selectedForBatch +
counts.ready +
counts.waiting +
counts.pendingContract +
counts.expired;
return (
<Paper radius="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)", overflow: "hidden" }}>
<Box style={{ height: 3, background: "linear-gradient(90deg, var(--mantine-color-green-5), var(--mantine-color-teal-7))" }} />
<Paper
radius="lg"
withBorder
style={{
borderColor: "var(--mantine-color-gray-2)",
overflow: "hidden",
cursor: "pointer",
}}
onClick={() =>
navigate(`/dashboard/operations/batch-board/${schedule.scheduleId}`)
}
>
<Box
style={{
height: 3,
background:
"linear-gradient(90deg, var(--mantine-color-green-5), var(--mantine-color-teal-7))",
}}
/>
<Stack gap="sm" p="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
@@ -147,18 +122,37 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
</Badge>
)}
{/* Capacity meters */}
<Box>
<Group justify="space-between" mb={2}>
<Text size="xs" c="dimmed">
Wagons
Allocated wagons
</Text>
<Text size="xs" fw={600}>
{capacity.usedWagons}/{capacity.maxWagons}
{capacity.allocatedWagons}
</Text>
</Group>
<Progress value={wagonPct} color={wagonPct >= 100 ? "orange" : "green"} radius="xl" size="sm" />
</Box>
{capacity.maxLengthMeters ? (
<Box>
<Group justify="space-between" mb={2}>
<Group gap={4}>
<Ruler size={12} />
<Text size="xs" c="dimmed">
Train length
</Text>
</Group>
<Text size="xs" fw={600}>
{fmtMeters(capacity.allocatedLengthMeters)}/{fmtMeters(capacity.maxLengthMeters)}
</Text>
</Group>
<Progress
value={lengthPct}
color={lengthPct >= 100 ? "orange" : "blue"}
radius="xl"
size="sm"
/>
</Box>
) : null}
{capacity.maxWeightTons ? (
<Box>
<Group justify="space-between" mb={2}>
@@ -172,20 +166,29 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
{fmtTons(capacity.usedWeightTons)}/{fmtTons(capacity.maxWeightTons)}
</Text>
</Group>
<Progress value={weightPct} color={weightPct >= 100 ? "red" : "teal"} radius="xl" size="sm" />
<Progress
value={weightPct}
color={weightPct >= 100 ? "red" : "teal"}
radius="xl"
size="sm"
/>
</Box>
) : null}
{/* Count chips */}
<Group gap={6}>
<Tooltip label="Allocated to the train">
<Badge variant="light" color="green" radius="sm">
{counts.allocated} allocated
</Badge>
</Tooltip>
<Tooltip label="Notified — 1h to pay">
<Tooltip label="Picked by batch — customer notified to pay">
<Badge variant="light" color="orange" radius="sm">
{counts.awaitingPayment} to pay
{counts.selectedForBatch} selected
</Badge>
</Tooltip>
<Tooltip label="Contract signed — waiting for batch pick">
<Badge variant="light" color="teal" radius="sm">
{counts.ready} ready
</Badge>
</Tooltip>
<Tooltip label="Paid, waiting for a slot">
@@ -200,20 +203,11 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
) : null}
</Group>
{/* Bookings */}
{schedule.bookings.length ? (
<ScrollArea.Autosize mah={220}>
<Stack gap={2}>
{schedule.bookings.map((b) => (
<BookingRow key={b.id} booking={b} />
))}
</Stack>
</ScrollArea.Autosize>
) : (
<Text size="xs" c="dimmed" ta="center" py="sm">
No bookings targeting this schedule yet.
</Text>
)}
<Text size="xs" c="dimmed" ta="center">
{totalBookings
? `${totalBookings} booking${totalBookings === 1 ? "" : "s"} · click for batch windows`
: "No bookings yet · click to open"}
</Text>
<Button
variant="light"
@@ -221,11 +215,12 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
radius="md"
size="compact-sm"
rightSection={<ArrowRight size={15} />}
onClick={() =>
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.scheduleId}`)
}
onClick={(e) => {
e.stopPropagation();
navigate(`/dashboard/operations/batch-board/${schedule.scheduleId}`);
}}
>
Open schedule
View batch windows
</Button>
</Stack>
</Paper>
@@ -238,9 +233,7 @@ export default function BatchBoardPage() {
return (
<Container size="xl" py="lg">
<Breadcrumbs
items={[{ label: "Operations" }, { label: "Batch board" }]}
/>
<Breadcrumbs items={[{ label: "Operations" }, { label: "Batch board" }]} />
<Paper
radius="xl"
@@ -265,8 +258,8 @@ export default function BatchBoardPage() {
Batch board
</Title>
<Text size="sm" c="dimmed" maw={560}>
Every active schedule with its bookings grouped by state allocated, awaiting
payment, paid-waiting and expired. Filling is automatic; this is the live view.
Active schedules click a card to see EAT 3-hour batch windows, bookings, and
wagon allocation status.
</Text>
</Stack>
</Group>

View File

@@ -0,0 +1,536 @@
import { useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Accordion,
Alert,
Badge,
Box,
Button,
Container,
Group,
Loader,
Paper,
Progress,
SimpleGrid,
Stack,
Table,
Text,
ThemeIcon,
Title,
Tooltip,
} from "@mantine/core";
import {
AlertTriangle,
ArrowLeft,
CheckCircle2,
Clock,
Hourglass,
Layers,
PlayCircle,
RefreshCw,
Train,
Weight,
Ruler,
XCircle,
} from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
import {
useBatchBoardDetail,
useRunAllocation,
useScheduleDetail,
} from "@/hooks/trainScheduling/useTrainScheduling";
import { useToast } from "@/hooks/use-toast";
import type {
BatchBoardBookingDetail,
BatchBoardBookingState,
BatchWindowGroup,
BookingAllocationStatus,
} from "@/types/trainScheduling";
const STATE_META: Record<
BatchBoardBookingState,
{ label: string; color: string; icon: typeof CheckCircle2 }
> = {
ALLOCATED: { label: "Allocated", color: "green", icon: CheckCircle2 },
SELECTED_FOR_BATCH: { label: "Selected for batch", color: "orange", icon: Clock },
READY: { label: "Ready for batch", color: "teal", icon: Hourglass },
WAITING: { label: "Paid · waiting", color: "blue", icon: Hourglass },
PENDING_CONTRACT: { label: "Pending contract", color: "gray", icon: Hourglass },
EXPIRED: { label: "Expired", color: "red", icon: XCircle },
};
const ALLOC_META: Record<
BookingAllocationStatus,
{ label: string; color: string }
> = {
ASSIGNED: { label: "Wagons assigned", color: "green" },
NOT_ATTEMPTED: { label: "Not allocated", color: "gray" },
DEFERRED: { label: "Deferred", color: "orange" },
FAILED: { label: "Allocation failed", color: "red" },
};
const fmtTons = (n: number) =>
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`;
const fmtMeters = (n: number) =>
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} m`;
const fmtDateTime = (iso: string | null) =>
iso
? new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: "Africa/Addis_Ababa",
}).format(new Date(iso))
: "—";
function StateBadge({ state }: { state: BatchBoardBookingState }) {
const meta = STATE_META[state];
const Icon = meta.icon;
return (
<Badge variant="light" color={meta.color} radius="sm" leftSection={<Icon size={11} />}>
{meta.label}
</Badge>
);
}
function AllocationBadge({
status,
issue,
}: {
status: BookingAllocationStatus;
issue: string | null;
}) {
const meta = ALLOC_META[status];
const badge = (
<Badge variant="light" color={meta.color} radius="sm">
{meta.label}
</Badge>
);
if (!issue) return badge;
return (
<Tooltip label={issue} multiline maw={320} withArrow>
<Group gap={4} wrap="nowrap">
{badge}
<AlertTriangle size={14} color="var(--mantine-color-red-6)" />
</Group>
</Tooltip>
);
}
function BookingTable({ bookings }: { bookings: BatchBoardBookingDetail[] }) {
if (!bookings.length) {
return (
<Text size="sm" c="dimmed" py="sm" ta="center">
No bookings in this batch window.
</Text>
);
}
return (
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Reference</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Contract signed</Table.Th>
<Table.Th>Selected for batch</Table.Th>
<Table.Th>Capacity</Table.Th>
<Table.Th>Batch state</Table.Th>
<Table.Th>Wagon allocation</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{bookings.map((b) => (
<Table.Tr key={b.id}>
<Table.Td>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600}>
{b.reference}
</Text>
{b.isGovernment ? (
<Badge size="xs" variant="light" color="grape">
Gov
</Badge>
) : null}
</Group>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{b.company}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{fmtDateTime(b.fullyExecutedAt)} EAT</Text>
</Table.Td>
<Table.Td>
{b.selectedForBatchAt ? (
<>
<Text size="sm">{fmtDateTime(b.selectedForBatchAt)} EAT</Text>
{b.paymentDeadline ? (
<Text size="xs" c="orange">
Pay by {fmtDateTime(b.paymentDeadline)} EAT
</Text>
) : null}
</>
) : (
<Text size="sm" c="dimmed">
</Text>
)}
</Table.Td>
<Table.Td>
<Text size="sm">
{b.wagons}w · {fmtTons(b.weightTons)}
</Text>
</Table.Td>
<Table.Td>
<StateBadge state={b.state} />
</Table.Td>
<Table.Td>
<AllocationBadge status={b.allocationStatus} issue={b.allocationIssue} />
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
);
}
function WindowAccordionItem({ window }: { window: BatchWindowGroup }) {
const total = window.bookings.length;
const hasIssues = window.bookings.some(
(b) => b.allocationStatus === "FAILED" || b.allocationStatus === "DEFERRED",
);
return (
<Accordion.Item value={window.key}>
<Accordion.Control>
<Group justify="space-between" wrap="nowrap" pr="md">
<Text fw={600} size="sm">
{window.label}
</Text>
<Group gap={6} wrap="nowrap">
{hasIssues ? (
<Badge variant="light" color="red" size="sm">
Issues
</Badge>
) : null}
<Badge variant="outline" color="gray" size="sm">
{total} booking{total === 1 ? "" : "s"}
</Badge>
</Group>
</Group>
</Accordion.Control>
<Accordion.Panel>
<BookingTable bookings={window.bookings} />
</Accordion.Panel>
</Accordion.Item>
);
}
export default function BatchScheduleDetailPage() {
const { scheduleId } = useParams<{ scheduleId: string }>();
const navigate = useNavigate();
const { toast } = useToast();
const { data, isLoading, isFetching, refetch } = useBatchBoardDetail(scheduleId);
const runAllocation = useRunAllocation(scheduleId ?? "");
const hasAssignedWagons = useMemo(
() =>
Boolean(
data?.windows.some((w) =>
w.bookings.some((b) => b.allocationStatus === "ASSIGNED"),
) ||
data?.pendingContract.bookings.some((b) => b.allocationStatus === "ASSIGNED"),
),
[data],
);
const scheduleDetailQuery = useScheduleDetail(
hasAssignedWagons ? scheduleId : undefined,
"CONTAINER",
);
const defaultOpen = useMemo(() => {
if (!data) return [];
const withBookings = data.windows.filter((w) => w.bookings.length > 0).map((w) => w.key);
if (data.pendingContract.bookings.length) withBookings.push("pending-contract");
return withBookings.length ? withBookings : [data.windows[0]?.key].filter(Boolean);
}, [data]);
const handleRunAllocation = () => {
runAllocation
.mutateAsync()
.then((result) => {
const failed = result.issues.filter((i) => i.status === "FAILED").length;
const deferred = result.deferred.length;
toast({
title: "Allocation run complete",
description:
failed || deferred
? `${result.assignedBookingIds.length} assigned · ${deferred} deferred · ${failed} failed`
: `${result.assignedBookingIds.length} booking(s) assigned to wagons`,
variant: failed ? "destructive" : "default",
});
void refetch();
})
.catch(() => {
toast({ title: "Allocation failed", variant: "destructive" });
});
};
if (isLoading || !data) {
return (
<Container size="xl" py="lg">
<Group justify="center" py="xl">
<Loader color="green" />
</Group>
</Container>
);
}
const lengthPct =
data.capacity.maxLengthMeters && data.capacity.maxLengthMeters > 0
? (data.capacity.allocatedLengthMeters / data.capacity.maxLengthMeters) * 100
: 0;
const weightPct =
data.capacity.maxWeightTons && data.capacity.maxWeightTons > 0
? (data.capacity.usedWeightTons / data.capacity.maxWeightTons) * 100
: 0;
return (
<Container size="xl" py="lg">
<Breadcrumbs
items={[
{ label: "Operations" },
{ label: "Batch board", href: "/dashboard/operations/batch-board" },
{ label: data.trainNumber ?? data.routeName ?? "Schedule" },
]}
/>
<Paper radius="xl" p="xl" mt="md" withBorder>
<Group justify="space-between" align="flex-start" wrap="wrap">
<Group gap="md" align="flex-start">
<Button
variant="subtle"
color="gray"
leftSection={<ArrowLeft size={16} />}
onClick={() => navigate("/dashboard/operations/batch-board")}
>
Back
</Button>
<Stack gap={4}>
<Group gap="sm">
<ThemeIcon size={44} radius="md" variant="light" color="green">
<Train size={22} />
</ThemeIcon>
<div>
<Title order={3}>
{data.trainNumber ?? data.routeName ?? "Schedule"}
</Title>
<Text size="sm" c="dimmed">
{data.origin ?? "—"} {data.destination ?? "—"} ·{" "}
{data.scheduleDate
? new Date(data.scheduleDate).toLocaleString()
: "No date"}
</Text>
</div>
</Group>
<Group gap={6}>
<Badge variant="light" color="green">
{data.bookingWindowStatus}
</Badge>
<Badge variant="outline" color="gray">
{data.status}
</Badge>
</Group>
</Stack>
</Group>
<Group gap="sm">
<Button
variant="default"
leftSection={<RefreshCw size={16} />}
loading={isFetching}
onClick={() => void refetch()}
>
Refresh
</Button>
<Button
color="green"
leftSection={<PlayCircle size={16} />}
loading={runAllocation.isPending}
onClick={handleRunAllocation}
>
Run allocation
</Button>
<Button
variant="light"
leftSection={<Layers size={16} />}
onClick={() =>
navigate(`/dashboard/operations/train-scheduling-v2/${data.scheduleId}`)
}
>
Open schedule
</Button>
</Group>
</Group>
{data.locomotive ? (
<Text size="sm" c="dimmed" mt="md">
Loco {data.locomotive.code} · max {fmtTons(data.locomotive.maxPullWeightTons)} ·{" "}
{data.locomotive.maxTrainLengthMeters} m
</Text>
) : (
<Alert color="red" mt="md" icon={<AlertTriangle size={16} />}>
No locomotive assigned wagon allocation cannot run.
</Alert>
)}
<SimpleGrid cols={{ base: 1, md: 3 }} spacing="md" mt="md">
<Box>
<Group justify="space-between" mb={4}>
<Text size="sm" c="dimmed">
Allocated wagons
</Text>
<Text size="sm" fw={600}>
{data.capacity.allocatedWagons}
</Text>
</Group>
</Box>
{data.capacity.maxLengthMeters ? (
<Box>
<Group justify="space-between" mb={4}>
<Group gap={4}>
<Ruler size={14} />
<Text size="sm" c="dimmed">
Train length
</Text>
</Group>
<Text size="sm" fw={600}>
{fmtMeters(data.capacity.allocatedLengthMeters)}/
{fmtMeters(data.capacity.maxLengthMeters)}
</Text>
</Group>
<Progress
value={lengthPct}
color={lengthPct >= 100 ? "orange" : "blue"}
radius="xl"
/>
</Box>
) : null}
{data.capacity.maxWeightTons ? (
<Box>
<Group justify="space-between" mb={4}>
<Group gap={4}>
<Weight size={14} />
<Text size="sm" c="dimmed">
Weight
</Text>
</Group>
<Text size="sm" fw={600}>
{fmtTons(data.capacity.usedWeightTons)}/{fmtTons(data.capacity.maxWeightTons)}
</Text>
</Group>
<Progress
value={weightPct}
color={weightPct >= 100 ? "red" : "teal"}
radius="xl"
/>
</Box>
) : null}
</SimpleGrid>
<Group gap={6} mt="md">
<Badge variant="light" color="green">
{data.counts.allocated} allocated
</Badge>
<Badge variant="light" color="orange">
{data.counts.selectedForBatch} selected
</Badge>
<Badge variant="light" color="teal">
{data.counts.ready} ready
</Badge>
<Badge variant="light" color="blue">
{data.counts.waiting} waiting
</Badge>
<Badge variant="light" color="gray">
{data.counts.pendingContract} pending contract
</Badge>
{data.counts.expired ? (
<Badge variant="light" color="red">
{data.counts.expired} expired
</Badge>
) : null}
</Group>
</Paper>
{data.allocationViolations.length ? (
<Alert color="red" mt="md" icon={<AlertTriangle size={16} />} title="Allocation constraints">
<Stack gap={4}>
{data.allocationViolations.map((v) => (
<Text key={v} size="sm">
{v}
</Text>
))}
</Stack>
</Alert>
) : null}
<Paper radius="lg" withBorder p="lg" mt="lg">
<Title order={4} mb="md">
Batch windows (EAT)
</Title>
<Text size="sm" c="dimmed" mb="md">
Bookings are grouped by contract signing time (<code>fullyExecutedAt</code>). Expand a
window to see bookings and wagon allocation issues.
</Text>
<Accordion multiple defaultValue={defaultOpen} variant="separated">
{data.windows.map((window) => (
<WindowAccordionItem key={window.key} window={window} />
))}
{data.pendingContract.bookings.length ? (
<Accordion.Item value="pending-contract">
<Accordion.Control>
<Group justify="space-between" wrap="nowrap" pr="md">
<Text fw={600} size="sm">
Pending contract
</Text>
<Badge variant="outline" color="gray" size="sm">
{data.pendingContract.bookings.length} booking
{data.pendingContract.bookings.length === 1 ? "" : "s"}
</Badge>
</Group>
</Accordion.Control>
<Accordion.Panel>
<BookingTable bookings={data.pendingContract.bookings} />
</Accordion.Panel>
</Accordion.Item>
) : null}
</Accordion>
</Paper>
{hasAssignedWagons && scheduleDetailQuery.data ? (
<Paper radius="lg" withBorder p="lg" mt="lg">
<Title order={4} mb="md">
Train composition
</Title>
<TrainCompositionDiagram
locomotive={scheduleDetailQuery.data.trainSet?.locomotive}
wagons={scheduleDetailQuery.data.trainSet?.wagons ?? []}
freightType="CONTAINER"
trainNumber={scheduleDetailQuery.data.trainNumber}
totalLengthMeters={scheduleDetailQuery.data.trainSet?.totalLengthMeters}
/>
</Paper>
) : null}
</Container>
);
}

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { isAxiosError } from "axios";
import type { ColumnDef } from "@edr/ui-common";
@@ -81,7 +81,7 @@ export default function TrainScheduleV2ListPage() {
const schedulesQuery = useScheduleList();
const routesQuery = useRoutes();
const locomotivesQuery = useAvailableLocomotives();
const locomotivesQuery = useAvailableLocomotives(routeId || undefined);
const { create, cancel } = useScheduleMutations();
const activeRoutes = useMemo(
@@ -89,6 +89,23 @@ export default function TrainScheduleV2ListPage() {
[routesQuery.data],
);
const selectedRoute = activeRoutes.find((r) => r.id === routeId);
const locomotiveReadinessHint = 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";
}, [selectedRoute]);
useEffect(() => {
setLocomotiveId("");
}, [routeId]);
const allSchedules = schedulesQuery.data ?? [];
const stats = useMemo(() => {
@@ -507,6 +524,11 @@ export default function TrainScheduleV2ListPage() {
onChange={(v) => setRouteId(v ?? "")}
searchable
/>
{routeId ? (
<Text size="xs" c="dimmed">
{locomotiveReadinessHint}
</Text>
) : null}
<TextInput
label="Departure date"
type="datetime-local"
@@ -518,7 +540,7 @@ export default function TrainScheduleV2ListPage() {
/>
<Select
label="Locomotive"
placeholder="Select locomotive"
placeholder={routeId ? "Select locomotive" : "Select a route first"}
data={(locomotivesQuery.data ?? []).map((l) => ({
value: l.id,
label: `${l.code}${l.name ? `${l.name}` : ""} · ${
@@ -528,6 +550,10 @@ export default function TrainScheduleV2ListPage() {
value={locomotiveId || null}
onChange={(v) => setLocomotiveId(v ?? "")}
searchable
disabled={!routeId}
nothingFoundMessage={
routeId ? "No available locomotives for this corridor" : "Select a route first"
}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setCreateOpen(false)}>