mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 01:18:18 +00:00
auto allocation and batch managemnt, tracking the train
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}>
|
||||
|
||||
Reference in New Issue
Block a user