mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
ui design for schedule
This commit is contained in:
@@ -1201,6 +1201,15 @@ export class TrainSchedulingService {
|
||||
const lineEntry = lineById.get(placement.bookingContainerId);
|
||||
if (!lineEntry) continue;
|
||||
|
||||
// Durably persist the container number on the booking container line first, so it
|
||||
// survives a refresh regardless of whether a wagon allocation slot can be matched
|
||||
// below. booking_container is the source of truth re-read into the preview units.
|
||||
if (placement.containerNumber && placement.containerNumber.trim()) {
|
||||
await manager.getRepository(BookingContainer).update(placement.bookingContainerId, {
|
||||
containerNumber: placement.containerNumber.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
const allocationId = allocationBySlotBooking.get(
|
||||
`${placement.sequenceNo}:${lineEntry.bookingId}`,
|
||||
);
|
||||
@@ -1226,13 +1235,6 @@ export class TrainSchedulingService {
|
||||
bookingContainerId: placement.bookingContainerId,
|
||||
});
|
||||
}
|
||||
|
||||
// Save container number to booking_container when staff enters a new container number
|
||||
if (placement.containerNumber && placement.containerNumber.trim()) {
|
||||
await manager.getRepository(BookingContainer).update(placement.bookingContainerId, {
|
||||
containerNumber: placement.containerNumber.trim(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (containerItems.length) {
|
||||
|
||||
@@ -53,6 +53,7 @@ export type ContainerUnitRow = {
|
||||
wagonsPerUnit?: number;
|
||||
containersPerWagon?: number;
|
||||
teuSlots?: number;
|
||||
containerNumber?: string | null;
|
||||
};
|
||||
|
||||
export type ContainerPlacementInput = {
|
||||
@@ -121,7 +122,7 @@ export function buildContainerWagonPlan(
|
||||
allocations: [],
|
||||
}));
|
||||
|
||||
return allocateBookingsToSlots(bookings, basePlan, AllocationLoadType.Container).map((slot) => ({
|
||||
return allocateContainersToSlots(bookings, basePlan).map((slot) => ({
|
||||
...slot,
|
||||
slotLoadType: 'CONTAINER' as SlotLoadType,
|
||||
}));
|
||||
@@ -223,6 +224,7 @@ export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitR
|
||||
wagonsPerUnit,
|
||||
containersPerWagon: perWagon,
|
||||
teuSlots,
|
||||
containerNumber: line.containerNumber ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -290,6 +292,65 @@ function allocateBookingsToSlots(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate container bookings across wagon slots by TEU capacity. A wagon holds at most
|
||||
* 2 TEU, so it carries either one 40ft container (2 TEU) or two 20ft containers (1 TEU
|
||||
* each) — a 40ft is NEVER mixed onto the same wagon as a 20ft. Every physical container
|
||||
* maps to a real wagon allocation, and this mirrors the frontend auto-fill packing
|
||||
* exactly so a placement's sequenceNo always lands on a slot that holds an allocation
|
||||
* for its booking.
|
||||
*
|
||||
* Weight-based packing (allocateBookingsToSlots) is wrong for containers: it collapses
|
||||
* several light containers into the first wagons by tonnage and leaves later container
|
||||
* units without an allocation slot, which silently drops their container items on assign.
|
||||
*/
|
||||
function allocateContainersToSlots(
|
||||
bookings: Booking[],
|
||||
basePlan: WagonPlanSlot[],
|
||||
): WagonPlanSlot[] {
|
||||
const slots = basePlan.map((slot) => ({
|
||||
...slot,
|
||||
assignedWeightTons: 0,
|
||||
allocations: [] as WagonAllocationRecord[],
|
||||
}));
|
||||
if (!slots.length) return slots;
|
||||
|
||||
const units = expandBookingContainerUnits(bookings);
|
||||
let currentSlotIndex = 0;
|
||||
let teuInCurrentSlot = 0;
|
||||
|
||||
for (const unit of units) {
|
||||
const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20);
|
||||
|
||||
// Move to the next wagon once this one can't fit the container's TEU. This keeps a
|
||||
// 40ft (2 TEU) alone on its wagon and never pairs it with a 20ft.
|
||||
if (teuInCurrentSlot > 0 && teuInCurrentSlot + teu > MAX_TEU_SLOTS_PER_WAGON) {
|
||||
currentSlotIndex += 1;
|
||||
teuInCurrentSlot = 0;
|
||||
}
|
||||
|
||||
const slot = slots[Math.min(currentSlotIndex, slots.length - 1)]!;
|
||||
|
||||
let allocation = slot.allocations.find((a) => a.bookingId === unit.bookingId);
|
||||
if (!allocation) {
|
||||
allocation = {
|
||||
bookingId: unit.bookingId,
|
||||
bookingReference: unit.bookingReference,
|
||||
allocatedWeightTons: 0,
|
||||
loadType: AllocationLoadType.Container,
|
||||
};
|
||||
slot.allocations.push(allocation);
|
||||
}
|
||||
allocation.allocatedWeightTons = roundTons(
|
||||
allocation.allocatedWeightTons + unit.grossWeightTons,
|
||||
);
|
||||
slot.assignedWeightTons = roundTons(slot.assignedWeightTons + unit.grossWeightTons);
|
||||
teuInCurrentSlot += teu;
|
||||
}
|
||||
|
||||
return slots;
|
||||
}
|
||||
|
||||
export function expandContainerItems(
|
||||
booking: Booking,
|
||||
allocationId: string,
|
||||
|
||||
@@ -120,7 +120,7 @@ export function ContainerPlacementGrid({
|
||||
value={progress}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
color={issues.length ? "yellow" : "teal"}
|
||||
color={issues.length ? "yellow" : "green"}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
@@ -135,7 +135,7 @@ export function ContainerPlacementGrid({
|
||||
</Stack>
|
||||
) : (
|
||||
<Badge
|
||||
color="teal"
|
||||
color="green"
|
||||
variant="light"
|
||||
size="sm"
|
||||
w="fit-content"
|
||||
@@ -163,7 +163,7 @@ export function ContainerPlacementGrid({
|
||||
{unit.label} · {unit.containerTypeCode} · {unit.grossWeightTons}T
|
||||
</Text>
|
||||
</Stack>
|
||||
<Badge size="sm" variant="light" color={isComplete ? "teal" : "gray"}>
|
||||
<Badge size="sm" variant="light" color={isComplete ? "green" : "gray"}>
|
||||
{isComplete ? "Ready" : "Pending"}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { ArrowRight, Package } from "lucide-react";
|
||||
import {
|
||||
Accordion,
|
||||
@@ -36,12 +36,15 @@ function EligibleBookingRow({
|
||||
wrap="nowrap"
|
||||
p="sm"
|
||||
style={{
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
borderRadius: 10,
|
||||
background: selected ? "var(--mantine-color-teal-0)" : undefined,
|
||||
border: `1px solid ${
|
||||
selected ? "var(--mantine-color-green-3)" : "var(--mantine-color-gray-2)"
|
||||
}`,
|
||||
borderRadius: 12,
|
||||
background: selected ? "var(--mantine-color-green-0)" : "white",
|
||||
transition: "border-color 120ms ease, background 120ms ease",
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={selected} onChange={onToggle} mt={4} />
|
||||
<Checkbox checked={selected} onChange={onToggle} mt={4} color="green" />
|
||||
<Stack gap={4} style={{ flex: 1 }}>
|
||||
<Group gap="xs" wrap="wrap">
|
||||
<Package size={14} />
|
||||
@@ -202,7 +205,7 @@ export function EligibleBookingsPanel({
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap="xs" onClick={(e) => e.stopPropagation()}>
|
||||
<Badge variant="light" color="teal">
|
||||
<Badge variant="light" color="green">
|
||||
{selectedInBucket.length} selected
|
||||
</Badge>
|
||||
<Button
|
||||
|
||||
@@ -43,7 +43,7 @@ export function FleetAvailabilitySummary({
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Badge variant="light" color={totalShortfall > 0 ? "yellow" : "teal"}>
|
||||
<Badge variant="light" color={totalShortfall > 0 ? "yellow" : "green"}>
|
||||
{fillRate}% fleet coverage
|
||||
</Badge>
|
||||
</Group>
|
||||
@@ -59,7 +59,7 @@ export function FleetAvailabilitySummary({
|
||||
value={fillRate}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
color={totalShortfall > 0 ? "yellow" : "teal"}
|
||||
color={totalShortfall > 0 ? "yellow" : "green"}
|
||||
/>
|
||||
</Stack>
|
||||
) : null}
|
||||
@@ -86,7 +86,7 @@ export function FleetAvailabilitySummary({
|
||||
{row.shortfall}
|
||||
</Badge>
|
||||
) : (
|
||||
<Text size="sm" c="teal">
|
||||
<Text size="sm" c="green">
|
||||
0
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -41,15 +41,20 @@ export function ScheduleBookingsStep({
|
||||
onRemove?: (bookingId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Paper p="md" radius="xl" withBorder>
|
||||
<Tabs defaultValue={assignedBookings.length ? "on-train" : "add"} radius="lg" variant="pills">
|
||||
<Paper p="md" radius="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
||||
<Tabs
|
||||
defaultValue={assignedBookings.length ? "on-train" : "add"}
|
||||
radius="md"
|
||||
variant="pills"
|
||||
color="green"
|
||||
>
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab
|
||||
value="on-train"
|
||||
leftSection={<Train size={14} />}
|
||||
rightSection={
|
||||
assignedBookings.length ? (
|
||||
<Badge size="xs" variant="light" color="teal" circle>
|
||||
<Badge size="xs" variant="light" color="green" circle>
|
||||
{assignedBookings.length}
|
||||
</Badge>
|
||||
) : undefined
|
||||
@@ -71,9 +76,9 @@ export function ScheduleBookingsStep({
|
||||
justify="space-between"
|
||||
p="sm"
|
||||
style={{
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
borderRadius: 10,
|
||||
background: "var(--mantine-color-teal-0)",
|
||||
border: "1px solid var(--mantine-color-green-2)",
|
||||
borderRadius: 12,
|
||||
background: "var(--mantine-color-green-0)",
|
||||
}}
|
||||
>
|
||||
<Stack gap={4}>
|
||||
@@ -82,7 +87,7 @@ export function ScheduleBookingsStep({
|
||||
{booking.reference}
|
||||
</Text>
|
||||
{booking.weightTons != null ? (
|
||||
<Badge variant="outline" size="xs">
|
||||
<Badge variant="outline" size="xs" color="green">
|
||||
{booking.weightTons}T
|
||||
</Badge>
|
||||
) : null}
|
||||
@@ -92,7 +97,7 @@ export function ScheduleBookingsStep({
|
||||
Assigned to this consist
|
||||
</Text>
|
||||
<ArrowRight size={12} />
|
||||
<Text size="xs" c="teal">
|
||||
<Text size="xs" c="green.7" fw={500}>
|
||||
Ready for wagon plan
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
@@ -54,7 +54,7 @@ export function PreviewSummary({
|
||||
{ label: "Train length", value: `${summary.totalLengthMeters}m` },
|
||||
];
|
||||
return (
|
||||
<Paper p="md" radius="xl" withBorder bg="teal.0">
|
||||
<Paper p="md" radius="xl" withBorder bg="green.0">
|
||||
<Text size="sm" fw={600} mb="sm">
|
||||
Plan summary
|
||||
</Text>
|
||||
|
||||
@@ -49,7 +49,7 @@ export function SchedulingWorkflowHeader({
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<Group gap="md" align="flex-start">
|
||||
<ThemeIcon size={42} radius="xl" variant="gradient" gradient={{ from: "teal", to: "green", deg: 135 }}>
|
||||
<ThemeIcon size={42} radius="xl" variant="gradient" gradient={{ from: "green", to: "teal", deg: 135 }}>
|
||||
<Icon size={20} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={4}>
|
||||
@@ -63,7 +63,7 @@ export function SchedulingWorkflowHeader({
|
||||
) : null}
|
||||
</Stack>
|
||||
</Group>
|
||||
<Badge size="lg" variant="light" color="teal">
|
||||
<Badge size="lg" variant="light" color="green">
|
||||
Step {activeStep + 1} of {totalSteps}
|
||||
</Badge>
|
||||
</Group>
|
||||
@@ -83,7 +83,7 @@ export function SchedulingWorkflowHeader({
|
||||
{progress}%
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress value={progress} size="sm" radius="xl" color="teal" />
|
||||
<Progress value={progress} size="sm" radius="xl" color="green" />
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,580 @@
|
||||
import { useMemo } from "react";
|
||||
import { Box, Group, Paper, Progress, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { Box as BoxIcon, Container as ContainerIcon, Fuel, Gauge, TrainFront } from "lucide-react";
|
||||
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
/**
|
||||
* Visual train composition: a locomotive coupled to its wagons, drawn like a real
|
||||
* consist. Long trains wrap into a serpentine (zig-zag) so the whole train stays on
|
||||
* screen. Each wagon shows its load (container blocks or a bulk fill gauge), physical
|
||||
* wagon number and tonnage.
|
||||
*/
|
||||
|
||||
type DiagramWagonInput = {
|
||||
sequenceNo: number;
|
||||
capacityTons: number;
|
||||
assignedWeightTons: number;
|
||||
slotLoadType?: string | null;
|
||||
wagonType?: { code?: string | null } | null;
|
||||
wagonTypeCode?: string | null;
|
||||
physicalWagonNumber?: string | null;
|
||||
allocations?: Array<{
|
||||
bookingReference?: string | null;
|
||||
bookingId?: string;
|
||||
loadType?: string | null;
|
||||
containerItems?: Array<{ containerNumber?: string | null }> | null;
|
||||
bulkLoad?: { weightTons?: number | null; cargoDescription?: string | null } | null;
|
||||
}> | null;
|
||||
};
|
||||
|
||||
type NormalizedWagon = {
|
||||
sequenceNo: number;
|
||||
capacityTons: number;
|
||||
assignedWeightTons: number;
|
||||
wagonTypeCode: string | null;
|
||||
physicalWagonNumber: string | null;
|
||||
isEmpty: boolean;
|
||||
isBulk: boolean;
|
||||
containerNumbers: string[];
|
||||
bookingRefs: string[];
|
||||
cargoDescription: string | null;
|
||||
};
|
||||
|
||||
const CAR_WIDTH = 150; // car body + coupler footprint
|
||||
|
||||
function normalizeWagon(w: DiagramWagonInput, freightType?: string | null): NormalizedWagon {
|
||||
const allocations = w.allocations ?? [];
|
||||
const firstLoad = (
|
||||
w.slotLoadType ??
|
||||
allocations[0]?.loadType ??
|
||||
freightType ??
|
||||
""
|
||||
)
|
||||
.toString()
|
||||
.toUpperCase();
|
||||
const isBulk = firstLoad.includes("BULK");
|
||||
const containerNumbers: string[] = [];
|
||||
const bookingRefs: string[] = [];
|
||||
let cargoDescription: string | null = null;
|
||||
for (const alloc of allocations) {
|
||||
if (alloc.bookingReference) bookingRefs.push(alloc.bookingReference);
|
||||
for (const item of alloc.containerItems ?? []) {
|
||||
containerNumbers.push(item.containerNumber?.trim() || "—");
|
||||
}
|
||||
if (alloc.bulkLoad?.cargoDescription) cargoDescription = alloc.bulkLoad.cargoDescription;
|
||||
}
|
||||
return {
|
||||
sequenceNo: w.sequenceNo,
|
||||
capacityTons: Number(w.capacityTons) || 0,
|
||||
assignedWeightTons: Number(w.assignedWeightTons) || 0,
|
||||
wagonTypeCode: w.wagonType?.code ?? w.wagonTypeCode ?? null,
|
||||
physicalWagonNumber: w.physicalWagonNumber ?? null,
|
||||
isEmpty: allocations.length === 0,
|
||||
isBulk,
|
||||
containerNumbers,
|
||||
bookingRefs,
|
||||
cargoDescription,
|
||||
};
|
||||
}
|
||||
|
||||
function chunk<T>(items: T[], size: number): T[][] {
|
||||
if (size <= 0) return [items];
|
||||
const rows: T[][] = [];
|
||||
for (let i = 0; i < items.length; i += size) rows.push(items.slice(i, i + size));
|
||||
return rows;
|
||||
}
|
||||
|
||||
function Wheels({ count = 2, dark = false }: { count?: number; dark?: boolean }) {
|
||||
return (
|
||||
<Group gap={count > 2 ? 10 : 18} justify="center" wrap="nowrap" mt={2}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: "50%",
|
||||
background: dark ? "#0f291b" : "var(--mantine-color-gray-7)",
|
||||
border: "2px solid var(--mantine-color-gray-4)",
|
||||
boxShadow: "inset 0 0 0 2px rgba(255,255,255,0.25)",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function Coupler() {
|
||||
return (
|
||||
<Box style={{ width: 16, height: 74, display: "flex", alignItems: "center", flexShrink: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
background:
|
||||
"linear-gradient(90deg, var(--mantine-color-gray-4), var(--mantine-color-gray-6), var(--mantine-color-gray-4))",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function LocomotiveCar({
|
||||
code,
|
||||
name,
|
||||
maxPullWeightTons,
|
||||
}: {
|
||||
code: string;
|
||||
name?: string | null;
|
||||
maxPullWeightTons?: number | null;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip
|
||||
label={`Locomotive ${code}${name ? ` · ${name}` : ""}${
|
||||
maxPullWeightTons ? ` · pulls up to ${maxPullWeightTons}T` : ""
|
||||
}`}
|
||||
withArrow
|
||||
>
|
||||
<Box style={{ width: 134, flexShrink: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
position: "relative",
|
||||
height: 74,
|
||||
borderRadius: "16px 22px 10px 10px",
|
||||
background: freightBrand.gradient,
|
||||
boxShadow: freightBrand.shadowSm,
|
||||
border: "1px solid rgba(0,0,0,0.08)",
|
||||
overflow: "hidden",
|
||||
padding: "8px 10px",
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
{/* cab windows */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 10,
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<Box style={{ width: 14, height: 12, borderRadius: 3, background: "rgba(255,255,255,0.85)" }} />
|
||||
<Box style={{ width: 14, height: 12, borderRadius: 3, background: "rgba(255,255,255,0.6)" }} />
|
||||
</Box>
|
||||
{/* headlight */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 10,
|
||||
right: 6,
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: "50%",
|
||||
background: "#fde68a",
|
||||
boxShadow: "0 0 8px 2px rgba(253,230,138,0.8)",
|
||||
}}
|
||||
/>
|
||||
<Group gap={6} wrap="nowrap" align="center">
|
||||
<TrainFront size={18} />
|
||||
<Text size="sm" fw={800} style={{ letterSpacing: 0.3 }}>
|
||||
{code}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="9px" mt={2} style={{ opacity: 0.85 }} lineClamp={1}>
|
||||
{name ?? "Locomotive"}
|
||||
</Text>
|
||||
{maxPullWeightTons ? (
|
||||
<Group gap={3} wrap="nowrap" mt={4} style={{ opacity: 0.95 }}>
|
||||
<Gauge size={10} />
|
||||
<Text size="9px" fw={600}>
|
||||
{maxPullWeightTons}T pull
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
</Box>
|
||||
<Wheels count={3} dark />
|
||||
<Text size="9px" ta="center" c="dimmed" mt={2} fw={700}>
|
||||
HEAD
|
||||
</Text>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
||||
const utilization =
|
||||
wagon.capacityTons > 0
|
||||
? Math.min(100, Math.round((wagon.assignedWeightTons / wagon.capacityTons) * 100))
|
||||
: 0;
|
||||
const accent = wagon.isEmpty ? "gray" : wagon.isBulk ? "orange" : "cyan";
|
||||
const accentVar = `var(--mantine-color-${accent}-6)`;
|
||||
|
||||
const tooltipLabel = wagon.isEmpty
|
||||
? `Wagon #${wagon.sequenceNo} · empty / available`
|
||||
: `Wagon #${wagon.sequenceNo}${wagon.physicalWagonNumber ? ` · ${wagon.physicalWagonNumber}` : ""}\n${
|
||||
wagon.bookingRefs.length ? wagon.bookingRefs.join(", ") : ""
|
||||
}${
|
||||
wagon.containerNumbers.length ? `\nContainers: ${wagon.containerNumbers.join(", ")}` : ""
|
||||
}${wagon.cargoDescription ? `\n${wagon.cargoDescription}` : ""}`;
|
||||
|
||||
// container blocks: one per container number (cap visual at 2 = TEU per wagon)
|
||||
const blocks = wagon.containerNumbers.slice(0, 2);
|
||||
|
||||
return (
|
||||
<Tooltip label={tooltipLabel} withArrow multiline maw={240} style={{ whiteSpace: "pre-line" }}>
|
||||
<Box style={{ width: 134, flexShrink: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
position: "relative",
|
||||
height: 74,
|
||||
borderRadius: 12,
|
||||
background: wagon.isEmpty ? "var(--mantine-color-gray-0)" : "white",
|
||||
border: wagon.isEmpty
|
||||
? "1.5px dashed var(--mantine-color-gray-4)"
|
||||
: "1px solid var(--mantine-color-gray-3)",
|
||||
boxShadow: wagon.isEmpty ? "none" : "0 2px 8px rgba(15,41,27,0.06)",
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{/* top accent strip */}
|
||||
<Box
|
||||
style={{
|
||||
height: 4,
|
||||
background: wagon.isEmpty
|
||||
? "var(--mantine-color-gray-3)"
|
||||
: `linear-gradient(90deg, ${accentVar}, var(--mantine-color-${accent}-4))`,
|
||||
}}
|
||||
/>
|
||||
{/* header */}
|
||||
<Group justify="space-between" px={8} pt={4} wrap="nowrap">
|
||||
<Text size="10px" fw={800} c="gray.7">
|
||||
#{wagon.sequenceNo}
|
||||
</Text>
|
||||
{wagon.isEmpty ? (
|
||||
<Text size="9px" c="dimmed" fw={600}>
|
||||
EMPTY
|
||||
</Text>
|
||||
) : (
|
||||
<Group gap={3} wrap="nowrap">
|
||||
{wagon.isBulk ? <Fuel size={11} color={accentVar} /> : <ContainerIcon size={11} color={accentVar} />}
|
||||
<Text size="9px" fw={700} c={`${accent}.7`}>
|
||||
{wagon.isBulk ? "BULK" : "CONT"}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* body */}
|
||||
<Box style={{ flex: 1, padding: "4px 8px 6px", display: "flex", alignItems: "center" }}>
|
||||
{wagon.isEmpty ? (
|
||||
<Group gap={4} justify="center" style={{ width: "100%" }}>
|
||||
<BoxIcon size={14} color="var(--mantine-color-gray-4)" />
|
||||
<Text size="9px" c="dimmed">
|
||||
Available
|
||||
</Text>
|
||||
</Group>
|
||||
) : wagon.isBulk ? (
|
||||
<Stack gap={3} style={{ width: "100%" }}>
|
||||
<Box
|
||||
style={{
|
||||
height: 18,
|
||||
borderRadius: 6,
|
||||
background: "var(--mantine-color-orange-0)",
|
||||
border: "1px solid var(--mantine-color-orange-2)",
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: `${utilization}%`,
|
||||
background: "linear-gradient(90deg, var(--mantine-color-orange-5), var(--mantine-color-orange-3))",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Text size="9px" c="dimmed" ta="center">
|
||||
{wagon.assignedWeightTons}/{wagon.capacityTons}T
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Group gap={4} justify="center" wrap="nowrap" style={{ width: "100%" }}>
|
||||
{(blocks.length ? blocks : ["—"]).map((cn, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
height: 26,
|
||||
borderRadius: 5,
|
||||
background: "linear-gradient(180deg, var(--mantine-color-cyan-5), var(--mantine-color-cyan-7))",
|
||||
border: "1px solid var(--mantine-color-cyan-8)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "0 3px",
|
||||
}}
|
||||
>
|
||||
<Text size="8px" fw={700} c="white" truncate>
|
||||
{cn}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* footer */}
|
||||
<Box
|
||||
style={{
|
||||
borderTop: "1px solid var(--mantine-color-gray-1)",
|
||||
padding: "2px 8px",
|
||||
background: wagon.isEmpty ? "transparent" : "var(--mantine-color-gray-0)",
|
||||
}}
|
||||
>
|
||||
<Text size="8px" c="dimmed" truncate>
|
||||
{wagon.physicalWagonNumber ?? wagon.wagonTypeCode ?? "Wagon"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Wheels count={2} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrainCompositionDiagram({
|
||||
locomotive,
|
||||
wagons,
|
||||
freightType,
|
||||
trainNumber,
|
||||
totalLengthMeters,
|
||||
}: {
|
||||
locomotive?: { code?: string | null; name?: string | null; maxPullWeightTons?: number | null } | null;
|
||||
wagons: DiagramWagonInput[];
|
||||
freightType?: string | null;
|
||||
trainNumber?: string | null;
|
||||
totalLengthMeters?: number | null;
|
||||
}) {
|
||||
const { ref, width } = useElementSize();
|
||||
|
||||
const normalized = useMemo(
|
||||
() => wagons.map((w) => normalizeWagon(w, freightType)),
|
||||
[wagons, freightType],
|
||||
);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const assigned = normalized.filter((w) => !w.isEmpty).length;
|
||||
const totalWeight = normalized.reduce((s, w) => s + w.assignedWeightTons, 0);
|
||||
const totalCapacity = normalized.reduce((s, w) => s + w.capacityTons, 0);
|
||||
return {
|
||||
total: normalized.length,
|
||||
assigned,
|
||||
empty: normalized.length - assigned,
|
||||
totalWeight: Math.round(totalWeight * 100) / 100,
|
||||
totalCapacity,
|
||||
pullUtil:
|
||||
locomotive?.maxPullWeightTons && locomotive.maxPullWeightTons > 0
|
||||
? Math.min(100, Math.round((totalWeight / locomotive.maxPullWeightTons) * 100))
|
||||
: null,
|
||||
};
|
||||
}, [normalized, locomotive]);
|
||||
|
||||
// cars-per-row from measured width; locomotive counts as one car
|
||||
const perRow = Math.max(1, Math.floor((width || CAR_WIDTH) / CAR_WIDTH));
|
||||
const cars = useMemo(
|
||||
() => [{ kind: "loco" as const }, ...normalized.map((w) => ({ kind: "wagon" as const, w }))],
|
||||
[normalized],
|
||||
);
|
||||
const rows = useMemo(() => chunk(cars, perRow), [cars, perRow]);
|
||||
|
||||
if (!locomotive && !wagons.length) return null;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
radius="lg"
|
||||
p="lg"
|
||||
withBorder
|
||||
style={{
|
||||
borderColor: "var(--mantine-color-gray-2)",
|
||||
background:
|
||||
"linear-gradient(180deg, var(--mantine-color-gray-0) 0%, white 40%)",
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 10,
|
||||
background: freightBrand.gradient,
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
<TrainFront size={20} />
|
||||
</Box>
|
||||
<Stack gap={0}>
|
||||
<Text fw={700}>Train composition</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{trainNumber ? `${trainNumber} · ` : ""}
|
||||
{stats.total} wagons · {stats.assigned} loaded · {stats.empty} empty
|
||||
{totalLengthMeters ? ` · ${totalLengthMeters}m` : ""}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs">
|
||||
<LegendDot color="cyan" label="Container" />
|
||||
<LegendDot color="orange" label="Bulk" />
|
||||
<LegendDot color="gray" label="Empty" />
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Locomotive pull gauge */}
|
||||
{stats.pullUtil != null ? (
|
||||
<Box
|
||||
p="xs"
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
background: "var(--mantine-color-green-0)",
|
||||
border: "1px solid var(--mantine-color-green-1)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text size="xs" fw={600} c="green.8">
|
||||
Locomotive load · {stats.totalWeight}T of {locomotive?.maxPullWeightTons}T
|
||||
</Text>
|
||||
<Text size="xs" fw={700} c={stats.pullUtil > 95 ? "red.7" : "green.7"}>
|
||||
{stats.pullUtil}%
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={stats.pullUtil}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
color={stats.pullUtil > 95 ? "red" : stats.pullUtil > 80 ? "yellow" : "green"}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{/* The train */}
|
||||
<Box ref={ref} style={{ width: "100%" }}>
|
||||
<Stack gap={0}>
|
||||
{rows.map((row, rowIndex) => {
|
||||
const reversed = rowIndex % 2 === 1;
|
||||
const isLast = rowIndex === rows.length - 1;
|
||||
// side where this row's track ends / turns down to the next row
|
||||
const turnSide: "left" | "right" = rowIndex % 2 === 0 ? "right" : "left";
|
||||
return (
|
||||
<Box key={rowIndex}>
|
||||
<Box style={{ position: "relative", paddingBottom: 6 }}>
|
||||
{/* rail under the row */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 4,
|
||||
right: 4,
|
||||
bottom: 8,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
background:
|
||||
"repeating-linear-gradient(90deg, var(--mantine-color-gray-5) 0 10px, var(--mantine-color-gray-3) 10px 16px)",
|
||||
}}
|
||||
/>
|
||||
<Group
|
||||
gap={0}
|
||||
wrap="nowrap"
|
||||
justify="flex-start"
|
||||
style={{
|
||||
flexDirection: reversed ? "row-reverse" : "row",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{row.map((car, carIndex) => (
|
||||
<Group key={carIndex} gap={0} wrap="nowrap" style={{ flexDirection: reversed ? "row-reverse" : "row" }}>
|
||||
{carIndex > 0 ? <Coupler /> : null}
|
||||
{car.kind === "loco" ? (
|
||||
locomotive ? (
|
||||
<LocomotiveCar
|
||||
code={locomotive.code ?? "LOCO"}
|
||||
name={locomotive.name}
|
||||
maxPullWeightTons={locomotive.maxPullWeightTons}
|
||||
/>
|
||||
) : null
|
||||
) : (
|
||||
<WagonCar wagon={car.w} />
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
{/* serpentine turn connector to the next row */}
|
||||
{!isLast ? (
|
||||
<Box style={{ position: "relative", height: 16 }}>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -10,
|
||||
height: 26,
|
||||
width: 22,
|
||||
borderBottom: "4px solid var(--mantine-color-gray-4)",
|
||||
...(turnSide === "right"
|
||||
? {
|
||||
right: 4,
|
||||
borderRight: "4px solid var(--mantine-color-gray-4)",
|
||||
borderBottomRightRadius: 16,
|
||||
}
|
||||
: {
|
||||
left: 4,
|
||||
borderLeft: "4px solid var(--mantine-color-gray-4)",
|
||||
borderBottomLeftRadius: 16,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function LegendDot({ color, label }: { color: string; label: string }) {
|
||||
return (
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 3,
|
||||
background:
|
||||
color === "gray"
|
||||
? "var(--mantine-color-gray-2)"
|
||||
: `var(--mantine-color-${color}-5)`,
|
||||
border: color === "gray" ? "1.5px dashed var(--mantine-color-gray-4)" : "none",
|
||||
}}
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { Badge, Card, Group, Progress, SimpleGrid, Stack, Text, ThemeIcon } from
|
||||
import { Box, Package } from "lucide-react";
|
||||
import type { TrainScheduleWagonAllocation, WagonPlanRow } from "@/types/trainScheduling";
|
||||
|
||||
type WagonSlot = WagonPlanRow | {
|
||||
type WagonSlot = (WagonPlanRow & { physicalWagonNumber?: string | null }) | {
|
||||
sequenceNo: number;
|
||||
capacityTons: number;
|
||||
assignedWeightTons: number;
|
||||
@@ -107,14 +107,11 @@ export function WagonPlanGrid({
|
||||
</ThemeIcon>
|
||||
<Stack gap={0}>
|
||||
<Text fw={600} size="sm">
|
||||
Wagon #{seq}
|
||||
{wagon.physicalWagonNumber ?? `Wagon #${seq}`}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{[typeCode, `Pos ${seq}`].filter(Boolean).join(" · ")}
|
||||
</Text>
|
||||
{typeCode ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{typeCode}
|
||||
{wagon.physicalWagonNumber ? ` · ${wagon.physicalWagonNumber}` : ""}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Group>
|
||||
<Badge variant="light" size="sm" color={loadTypeColor(label, freightType)}>
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Badge, Box, Collapse, Group, Stack, Text, UnstyledButton } from "@mantine/core";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Check, ChevronDown, Lock } from "lucide-react";
|
||||
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
export type WorkflowStepState = "complete" | "active" | "upcoming";
|
||||
|
||||
/**
|
||||
* A single collapsible step in the scheduling workflow. Steps are stacked
|
||||
* inside <WorkflowRail> which paints the continuous connector behind the
|
||||
* status circles.
|
||||
*/
|
||||
export function WorkflowStep({
|
||||
index,
|
||||
icon: Icon,
|
||||
title,
|
||||
subtitle,
|
||||
state,
|
||||
open,
|
||||
onToggle,
|
||||
rightSlot,
|
||||
locked = false,
|
||||
children,
|
||||
}: {
|
||||
index: number;
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
state: WorkflowStepState;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
rightSlot?: ReactNode;
|
||||
locked?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const isComplete = state === "complete";
|
||||
const isActive = state === "active";
|
||||
|
||||
const circle = (() => {
|
||||
if (isComplete) {
|
||||
return {
|
||||
bg: freightBrand.primary,
|
||||
color: "white",
|
||||
border: freightBrand.primary,
|
||||
shadow: `0 4px 10px ${freightBrand.ring}`,
|
||||
};
|
||||
}
|
||||
if (isActive) {
|
||||
return {
|
||||
bg: "white",
|
||||
color: freightBrand.primary,
|
||||
border: freightBrand.primary,
|
||||
shadow: `0 0 0 4px ${freightBrand.ring}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
bg: "var(--mantine-color-gray-1)",
|
||||
color: "var(--mantine-color-gray-5)",
|
||||
border: "var(--mantine-color-gray-3)",
|
||||
shadow: "none",
|
||||
};
|
||||
})();
|
||||
|
||||
return (
|
||||
<Group gap="md" align="stretch" wrap="nowrap">
|
||||
{/* Status circle (sits above the rail) */}
|
||||
<Box
|
||||
style={{
|
||||
width: 40,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: circle.bg,
|
||||
color: circle.color,
|
||||
border: `2px solid ${circle.border}`,
|
||||
boxShadow: circle.shadow,
|
||||
fontWeight: 700,
|
||||
fontSize: 15,
|
||||
transition: "all 150ms ease",
|
||||
position: "relative",
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{isComplete ? <Check size={20} /> : <Icon size={18} />}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Step card */}
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
marginBottom: 4,
|
||||
borderRadius: 16,
|
||||
border: `1px solid ${
|
||||
isActive ? freightBrand.mutedBorder : "var(--mantine-color-gray-2)"
|
||||
}`,
|
||||
background: isActive ? freightBrand.mutedBg : "white",
|
||||
boxShadow: isActive ? `0 6px 20px ${freightBrand.ring}` : "none",
|
||||
overflow: "hidden",
|
||||
transition: "all 150ms ease",
|
||||
}}
|
||||
>
|
||||
<UnstyledButton
|
||||
onClick={onToggle}
|
||||
style={{ display: "block", width: "100%", padding: "14px 18px" }}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" gap="sm">
|
||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text
|
||||
size="xs"
|
||||
fw={700}
|
||||
c={isComplete || isActive ? "green.7" : "dimmed"}
|
||||
style={{ letterSpacing: 0.6 }}
|
||||
>
|
||||
STEP {index + 1}
|
||||
</Text>
|
||||
{isComplete ? (
|
||||
<Badge size="xs" variant="light" color="green" radius="sm">
|
||||
Done
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text fw={700} size="md" lh={1.2} truncate>
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle ? (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
<Group gap="sm" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
{rightSlot}
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 8,
|
||||
background: open
|
||||
? "var(--mantine-color-green-0)"
|
||||
: "var(--mantine-color-gray-1)",
|
||||
color: open
|
||||
? "var(--mantine-color-green-7)"
|
||||
: "var(--mantine-color-gray-6)",
|
||||
}}
|
||||
>
|
||||
{locked ? (
|
||||
<Lock size={14} />
|
||||
) : (
|
||||
<ChevronDown
|
||||
size={16}
|
||||
style={{
|
||||
transform: open ? "rotate(180deg)" : "none",
|
||||
transition: "transform 150ms ease",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
|
||||
<Collapse expanded={open}>
|
||||
<Box
|
||||
px="lg"
|
||||
pb="lg"
|
||||
pt={4}
|
||||
style={{ borderTop: "1px solid var(--mantine-color-gray-1)" }}
|
||||
>
|
||||
<Box pt="md">{children}</Box>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a list of <WorkflowStep> and paints the continuous vertical rail that
|
||||
* connects the status circles.
|
||||
*/
|
||||
export function WorkflowRail({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Box style={{ position: "relative" }}>
|
||||
{/* connector rail behind the circles (circle is 40px → center at 20) */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 19,
|
||||
top: 24,
|
||||
bottom: 24,
|
||||
width: 2,
|
||||
background:
|
||||
"linear-gradient(180deg, var(--mantine-color-green-3) 0%, var(--mantine-color-gray-3) 100%)",
|
||||
borderRadius: 2,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
<Stack gap="md">{children}</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -105,6 +105,22 @@ describe('containerPlacement.util', () => {
|
||||
expect(placements[2]?.sequenceNo).toBe(2);
|
||||
});
|
||||
|
||||
it('never shares a wagon between a 40ft and a 20ft when the 40ft comes first', () => {
|
||||
// Regression: a 40ft (2 TEU) must occupy its own wagon and never pair with a 20ft.
|
||||
const units40 = makeUnits('40GP', 40, 1);
|
||||
const units20 = makeUnits('20GP', 20, 2);
|
||||
const units = [...units40, ...units20];
|
||||
const slots = [1, 2, 3, 4];
|
||||
const placements = autoFillPlacements(units, slots);
|
||||
|
||||
expect(placements).toHaveLength(3);
|
||||
// 40ft alone on slot 1
|
||||
expect(placements[0]?.sequenceNo).toBe(1);
|
||||
// both 20ft together on slot 2 — NOT on slot 1 with the 40ft
|
||||
expect(placements[1]?.sequenceNo).toBe(2);
|
||||
expect(placements[2]?.sequenceNo).toBe(2);
|
||||
});
|
||||
|
||||
it('falls back to last slot when running out of slots', () => {
|
||||
const units = makeUnits('20GP', 20, 6);
|
||||
const slots = [1, 2]; // Only 2 slots available
|
||||
|
||||
@@ -68,15 +68,19 @@ export function autoFillPlacements(
|
||||
if (!units.length || !containerSlots.length) return [];
|
||||
|
||||
const placements: ContainerPlacement[] = [];
|
||||
// Pack by TEU: a wagon holds 2 TEU (one 40ft, or two 20ft). This MUST mirror the
|
||||
// backend allocation (allocateContainersToSlots) so a placement's sequenceNo lands on
|
||||
// the same wagon the booking is allocated to — and a 40ft never shares with a 20ft.
|
||||
const MAX_TEU_PER_WAGON = 2;
|
||||
let currentSlotIndex = 0;
|
||||
let unitsInCurrentSlot = 0;
|
||||
let teuInCurrentSlot = 0;
|
||||
|
||||
for (const unit of units) {
|
||||
const perWagon = unit.containersPerWagon ?? (unit.sizeFt && unit.sizeFt >= 40 ? 1 : 2);
|
||||
const teu = unit.teuSlots ?? (unit.sizeFt && unit.sizeFt >= 40 ? 2 : 1);
|
||||
|
||||
if (unitsInCurrentSlot >= perWagon) {
|
||||
if (teuInCurrentSlot > 0 && teuInCurrentSlot + teu > MAX_TEU_PER_WAGON) {
|
||||
currentSlotIndex += 1;
|
||||
unitsInCurrentSlot = 0;
|
||||
teuInCurrentSlot = 0;
|
||||
}
|
||||
|
||||
const sequenceNo =
|
||||
@@ -88,9 +92,10 @@ export function autoFillPlacements(
|
||||
bookingContainerId: unit.bookingContainerId,
|
||||
unitIndex: unit.unitIndex,
|
||||
sequenceNo,
|
||||
containerNumber: unit.containerNumber ?? undefined,
|
||||
});
|
||||
|
||||
unitsInCurrentSlot += 1;
|
||||
teuInCurrentSlot += teu;
|
||||
}
|
||||
|
||||
return placements;
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Box, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { MapPin } from "lucide-react";
|
||||
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
/**
|
||||
* Shared visual building blocks for the Train Scheduling V2 surfaces.
|
||||
* Everything keys off the freight brand green so the list + detail pages
|
||||
* read as one cohesive, premium product.
|
||||
*/
|
||||
|
||||
export const scheduleBrand = {
|
||||
/** Deep green → emerald hero wash used across scheduling surfaces. */
|
||||
heroGradient: `linear-gradient(135deg, ${freightBrand.primaryDark} 0%, ${freightBrand.primary} 48%, ${freightBrand.primaryLight} 120%)`,
|
||||
/** Soft tinted surface for cards on white backgrounds. */
|
||||
softSurface: `linear-gradient(135deg, ${freightBrand.mutedBg} 0%, #ffffff 60%, var(--mantine-color-gray-0) 100%)`,
|
||||
ring: freightBrand.ring,
|
||||
shadow: freightBrand.shadow,
|
||||
shadowSm: freightBrand.shadowSm,
|
||||
mutedBorder: freightBrand.mutedBorder,
|
||||
} as const;
|
||||
|
||||
const STATUS_META: Record<
|
||||
string,
|
||||
{ color: string; dot: string; label?: string }
|
||||
> = {
|
||||
DRAFT: { color: "gray", dot: "var(--mantine-color-gray-5)" },
|
||||
SCHEDULED: { color: "green", dot: freightBrand.primary },
|
||||
DISPATCHED: { color: "teal", dot: "var(--mantine-color-teal-6)" },
|
||||
ARRIVED: { color: "blue", dot: "var(--mantine-color-blue-6)" },
|
||||
CANCELLED: { color: "red", dot: "var(--mantine-color-red-6)" },
|
||||
};
|
||||
|
||||
export function statusMeta(status: string) {
|
||||
return STATUS_META[status] ?? STATUS_META.DRAFT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status pill with a leading status dot — clearer at a glance than a plain
|
||||
* badge and consistent everywhere a schedule status appears.
|
||||
*/
|
||||
export function StatusPill({
|
||||
status,
|
||||
size = "sm",
|
||||
}: {
|
||||
status: string;
|
||||
size?: "sm" | "md";
|
||||
}) {
|
||||
const meta = statusMeta(status);
|
||||
const isMd = size === "md";
|
||||
return (
|
||||
<Group
|
||||
gap={isMd ? 8 : 6}
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
padding: isMd ? "5px 12px" : "3px 10px",
|
||||
borderRadius: 999,
|
||||
background: `var(--mantine-color-${meta.color}-0)`,
|
||||
border: `1px solid var(--mantine-color-${meta.color}-2)`,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
w={isMd ? 8 : 7}
|
||||
h={isMd ? 8 : 7}
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
background: meta.dot,
|
||||
boxShadow: `0 0 0 3px var(--mantine-color-${meta.color}-1)`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
size={isMd ? "sm" : "xs"}
|
||||
fw={600}
|
||||
c={`${meta.color}.8`}
|
||||
style={{ letterSpacing: 0.2, lineHeight: 1 }}
|
||||
>
|
||||
{meta.label ?? status}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact metric tile used in hero strips. `onDark` flips colors for use on
|
||||
* the green hero gradient.
|
||||
*/
|
||||
export function StatTile({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
onDark = false,
|
||||
accent = freightBrand.primary,
|
||||
}: {
|
||||
icon?: LucideIcon;
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
hint?: ReactNode;
|
||||
onDark?: boolean;
|
||||
accent?: string;
|
||||
}) {
|
||||
return (
|
||||
<Paper
|
||||
p="md"
|
||||
radius="lg"
|
||||
style={
|
||||
onDark
|
||||
? {
|
||||
background: "rgba(255,255,255,0.12)",
|
||||
border: "1px solid rgba(255,255,255,0.18)",
|
||||
backdropFilter: "blur(6px)",
|
||||
}
|
||||
: {
|
||||
background: "white",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}
|
||||
}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
{Icon ? (
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 10,
|
||||
flexShrink: 0,
|
||||
background: onDark ? "rgba(255,255,255,0.16)" : `${accent}1a`,
|
||||
color: onDark ? "white" : accent,
|
||||
}}
|
||||
>
|
||||
<Icon size={18} />
|
||||
</Box>
|
||||
) : null}
|
||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={600}
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: 0.4 }}
|
||||
c={onDark ? "rgba(255,255,255,0.75)" : "dimmed"}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<Text
|
||||
fw={700}
|
||||
size="lg"
|
||||
lh={1.1}
|
||||
c={onDark ? "white" : undefined}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
{hint ? (
|
||||
<Text size="xs" c={onDark ? "rgba(255,255,255,0.7)" : "dimmed"}>
|
||||
{hint}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Origin → destination corridor visual: two anchored stops joined by a rail
|
||||
* line. `variant="compact"` is for dense table rows; `default` for cards.
|
||||
*/
|
||||
export function RouteCorridor({
|
||||
origin,
|
||||
destination,
|
||||
variant = "default",
|
||||
onDark = false,
|
||||
}: {
|
||||
origin?: string | null;
|
||||
destination?: string | null;
|
||||
variant?: "default" | "compact";
|
||||
onDark?: boolean;
|
||||
}) {
|
||||
const compact = variant === "compact";
|
||||
const dim = onDark ? "rgba(255,255,255,0.7)" : "var(--mantine-color-gray-5)";
|
||||
const strong = onDark ? "white" : "var(--mantine-color-gray-8)";
|
||||
const lineColor = onDark ? "rgba(255,255,255,0.4)" : "var(--mantine-color-gray-3)";
|
||||
const accent = onDark ? "white" : freightBrand.primary;
|
||||
|
||||
return (
|
||||
<Group gap={compact ? 6 : 8} wrap="nowrap" align="center" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
w={compact ? 7 : 9}
|
||||
h={compact ? 7 : 9}
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
flexShrink: 0,
|
||||
border: `2px solid ${accent}`,
|
||||
background: onDark ? "transparent" : "white",
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
size={compact ? "sm" : "sm"}
|
||||
fw={600}
|
||||
c={strong}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{origin ?? "—"}
|
||||
</Text>
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: compact ? 16 : 24,
|
||||
height: 0,
|
||||
borderTop: `2px dashed ${lineColor}`,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<MapPin
|
||||
size={compact ? 11 : 13}
|
||||
color={dim}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
background: onDark ? "transparent" : "white",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Text
|
||||
size={compact ? "sm" : "sm"}
|
||||
fw={600}
|
||||
c={strong}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{destination ?? "—"}
|
||||
</Text>
|
||||
<Box
|
||||
w={compact ? 7 : 9}
|
||||
h={compact ? 7 : 9}
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
flexShrink: 0,
|
||||
background: accent,
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import type { MantineTheme } from "@mantine/core";
|
||||
|
||||
export const schedulingWorkflow = {
|
||||
stepper: {
|
||||
color: "teal" as const,
|
||||
color: "green" as const,
|
||||
iconSize: 32,
|
||||
size: "sm" as const,
|
||||
},
|
||||
@@ -12,11 +12,11 @@ export const schedulingWorkflow = {
|
||||
withBorder: true,
|
||||
},
|
||||
heroGradient: (theme: MantineTheme) =>
|
||||
`linear-gradient(135deg, ${theme.colors.teal[0]} 0%, ${theme.white} 55%, ${theme.colors.gray[0]} 100%)`,
|
||||
`linear-gradient(135deg, ${theme.colors.green[0]} 0%, ${theme.white} 55%, ${theme.colors.gray[0]} 100%)`,
|
||||
workflowGradient: (theme: MantineTheme) =>
|
||||
`linear-gradient(180deg, ${theme.white} 0%, ${theme.colors.gray[0]} 100%)`,
|
||||
accentColor: "teal" as const,
|
||||
successColor: "teal" as const,
|
||||
accentColor: "green" as const,
|
||||
successColor: "green" as const,
|
||||
warningColor: "yellow" as const,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { isAxiosError } from "axios";
|
||||
import { ArrowLeft, Train } from "lucide-react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CalendarClock,
|
||||
CheckCircle2,
|
||||
Container as ContainerIcon,
|
||||
Eye,
|
||||
LayoutGrid,
|
||||
Package,
|
||||
Route as RouteIcon,
|
||||
Send,
|
||||
Train,
|
||||
Weight,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
RingProgress,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Stepper,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
|
||||
@@ -30,15 +43,18 @@ import {
|
||||
PreviewSummary,
|
||||
ScheduleWarningsAlert,
|
||||
} from "@/components/trainScheduling/ScheduleWarningsAlert";
|
||||
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||
import {
|
||||
FreightTypeBadge,
|
||||
ScheduleStatusBadge,
|
||||
} from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||
RouteCorridor,
|
||||
StatTile,
|
||||
StatusPill,
|
||||
scheduleBrand,
|
||||
} from "@/components/trainScheduling/scheduleVisuals";
|
||||
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
||||
import { SchedulingWorkflowHeader } from "@/components/trainScheduling/SchedulingWorkflowHeader";
|
||||
import { schedulingWorkflow } from "@/components/trainScheduling/schedulingWorkflow.styles";
|
||||
import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep";
|
||||
import { shouldShowContainerPlacementStep } from "@/components/trainScheduling/schedulingContainerStep.util";
|
||||
import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid";
|
||||
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
||||
import {
|
||||
useEligibleBookings,
|
||||
useScheduleDetail,
|
||||
@@ -133,8 +149,19 @@ export default function TrainScheduleV2DetailPage() {
|
||||
);
|
||||
|
||||
const displayWagonPlan = useMemo(() => {
|
||||
if (previewResult?.wagonPlan?.length) return previewResult.wagonPlan;
|
||||
if (schedule?.trainSet?.wagons?.length) return schedule.trainSet.wagons;
|
||||
const savedWagons = schedule?.trainSet?.wagons ?? [];
|
||||
// Map each slot to its reserved physical wagon number (from the wagon table) so the
|
||||
// plan shows real wagon ids (e.g. WGN-DEMO-001) instead of generic "Wagon #1".
|
||||
const physicalBySeq = new Map(
|
||||
savedWagons.map((w) => [w.sequenceNo, w.physicalWagonNumber ?? null]),
|
||||
);
|
||||
if (previewResult?.wagonPlan?.length) {
|
||||
return previewResult.wagonPlan.map((slot) => ({
|
||||
...slot,
|
||||
physicalWagonNumber: physicalBySeq.get(slot.sequenceNo) ?? null,
|
||||
}));
|
||||
}
|
||||
if (savedWagons.length) return savedWagons;
|
||||
return [];
|
||||
}, [previewResult?.wagonPlan, schedule?.trainSet?.wagons]);
|
||||
|
||||
@@ -322,12 +349,346 @@ export default function TrainScheduleV2DetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const stepLabels = [
|
||||
"Bookings",
|
||||
"Wagon plan",
|
||||
...(hasContainerStep ? ["Containers"] : []),
|
||||
"Finalize",
|
||||
const containerComplete =
|
||||
hasContainerStep &&
|
||||
containerUnits.length > 0 &&
|
||||
validateLocalPlacements(containerUnits, containerPlacements).length === 0;
|
||||
const finalizeComplete = ["SCHEDULED", "DISPATCHED", "ARRIVED"].includes(
|
||||
schedule.status,
|
||||
);
|
||||
|
||||
const stepsMeta = [
|
||||
{
|
||||
key: "bookings",
|
||||
icon: Package,
|
||||
title: "Bookings",
|
||||
subtitle: "Select cargo & preview the plan",
|
||||
complete: Boolean(previewResult) || assignedIds.length > 0,
|
||||
},
|
||||
{
|
||||
key: "wagon",
|
||||
icon: LayoutGrid,
|
||||
title: "Wagon plan",
|
||||
subtitle: "Review generated allocations",
|
||||
complete: displayWagonPlan.length > 0,
|
||||
},
|
||||
...(hasContainerStep
|
||||
? [
|
||||
{
|
||||
key: "container",
|
||||
icon: ContainerIcon,
|
||||
title: "Containers",
|
||||
subtitle: "Map units to wagon slots",
|
||||
complete: containerComplete,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: "finalize",
|
||||
icon: CheckCircle2,
|
||||
title: "Finalize",
|
||||
subtitle: "Lock the plan & dispatch",
|
||||
complete: finalizeComplete,
|
||||
},
|
||||
];
|
||||
const completedCount = stepsMeta.filter((s) => s.complete).length;
|
||||
const progressPct = Math.round((completedCount / stepsMeta.length) * 100);
|
||||
const toggleStep = (i: number) => setActiveStep((cur) => (cur === i ? -1 : i));
|
||||
|
||||
const renderStepRightSlot = (key: string) => {
|
||||
if (key === "bookings") {
|
||||
if (previewResult) {
|
||||
return (
|
||||
<Badge
|
||||
variant="light"
|
||||
color={previewResult.valid ? "green" : "red"}
|
||||
radius="sm"
|
||||
>
|
||||
{previewResult.valid ? "Plan valid" : "Has issues"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return allSelectedIds.length ? (
|
||||
<Badge variant="light" color="green" radius="sm">
|
||||
{allSelectedIds.length} selected
|
||||
</Badge>
|
||||
) : null;
|
||||
}
|
||||
if (key === "wagon" && displayWagonPlan.length) {
|
||||
return (
|
||||
<Badge variant="light" color="green" radius="sm">
|
||||
{displayWagonPlan.length} wagons
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (key === "container" && containerUnits.length) {
|
||||
return (
|
||||
<Badge
|
||||
variant="light"
|
||||
color={containerComplete ? "green" : "yellow"}
|
||||
radius="sm"
|
||||
>
|
||||
{containerUnits.length} units
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (key === "finalize") {
|
||||
return <StatusPill status={schedule.status} />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderStepBody = (key: string) => {
|
||||
if (key === "bookings") {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<ScheduleBookingsStep
|
||||
assignedBookings={(schedule.bookings ?? []).map((b) => ({
|
||||
id: b.id,
|
||||
reference: b.reference ?? b.id.slice(0, 8),
|
||||
weightTons: b.weightTons,
|
||||
}))}
|
||||
eligibleItems={eligibleQuery.data?.items ?? []}
|
||||
eligibleLoading={eligibleQuery.isLoading}
|
||||
selectedIds={allSelectedIds}
|
||||
onSelectionChange={(ids) => {
|
||||
const assigned = new Set(assignedIds);
|
||||
setSelectedBookingIds(ids.filter((id) => !assigned.has(id)));
|
||||
}}
|
||||
assignedIds={assignedIds}
|
||||
freightType={freightType}
|
||||
canRemove={canModifyBookings}
|
||||
onRemove={handleUnassign}
|
||||
/>
|
||||
|
||||
{canEditBookings ? (
|
||||
<Group
|
||||
align="center"
|
||||
justify="space-between"
|
||||
wrap="wrap"
|
||||
gap="md"
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
label="Force assign (bypass hold / overweight warnings)"
|
||||
checked={forceAssign}
|
||||
onChange={(e) => setForceAssign(e.currentTarget.checked)}
|
||||
size="sm"
|
||||
/>
|
||||
<Button
|
||||
color="green"
|
||||
radius="md"
|
||||
leftSection={<Eye size={16} />}
|
||||
loading={preview.isPending}
|
||||
onClick={() => void runPreview()}
|
||||
>
|
||||
Preview plan
|
||||
</Button>
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
{previewResult ? (
|
||||
<Stack gap="sm">
|
||||
<ScheduleWarningsAlert
|
||||
violations={previewResult.violations}
|
||||
warnings={previewResult.warnings}
|
||||
/>
|
||||
<FleetAvailabilitySummary
|
||||
fleetAvailability={previewResult.fleetAvailability}
|
||||
deferredBookings={previewResult.deferredBookings}
|
||||
/>
|
||||
<PreviewSummary summary={previewResult.summary} />
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (key === "wagon") {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{!displayWagonPlan.length && !previewResult ? (
|
||||
<Paper p="md" radius="lg" withBorder bg="gray.0">
|
||||
<Text size="sm" c="dimmed">
|
||||
Run a preview from the Bookings step to generate the wagon plan.
|
||||
</Text>
|
||||
</Paper>
|
||||
) : null}
|
||||
<FleetAvailabilitySummary
|
||||
fleetAvailability={previewResult?.fleetAvailability}
|
||||
deferredBookings={previewResult?.deferredBookings}
|
||||
/>
|
||||
<WagonPlanGrid wagonPlan={displayWagonPlan} freightType={freightType} />
|
||||
{canEditBookings && (previewResult || displayWagonPlan.length) ? (
|
||||
<Group>
|
||||
{!hasContainerStep ? (
|
||||
<Button
|
||||
color="green"
|
||||
radius="md"
|
||||
loading={assign.isPending}
|
||||
onClick={handleAssign}
|
||||
>
|
||||
{assignedIds.length ? "Save assignments" : "Assign bookings"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
color="green"
|
||||
radius="md"
|
||||
rightSection={<ContainerIcon size={16} />}
|
||||
onClick={() => setActiveStep(2)}
|
||||
>
|
||||
Continue to containers
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="default" radius="md" onClick={() => void runPreview()}>
|
||||
Refresh preview
|
||||
</Button>
|
||||
</Group>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (key === "container") {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{!containerUnits.length ? (
|
||||
<Paper p="md" radius="lg" withBorder bg="gray.0">
|
||||
<Text size="sm" c="dimmed">
|
||||
Run preview from the Bookings step to load container units for numbering.
|
||||
</Text>
|
||||
</Paper>
|
||||
) : (
|
||||
<ContainerPlacementGrid
|
||||
units={containerUnits}
|
||||
containerSlots={containerSlots}
|
||||
placements={containerPlacements}
|
||||
onChange={setContainerPlacements}
|
||||
/>
|
||||
)}
|
||||
{canEditBookings ? (
|
||||
<Group>
|
||||
<Button
|
||||
color="green"
|
||||
radius="md"
|
||||
loading={assign.isPending}
|
||||
onClick={handleAssign}
|
||||
>
|
||||
{assignedIds.length ? "Save assignments" : "Assign bookings"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
onClick={() => setActiveStep(finalizeStep)}
|
||||
>
|
||||
Skip to finalize
|
||||
</Button>
|
||||
</Group>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// finalize
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<TrainCompositionDiagram
|
||||
locomotive={schedule.trainSet?.locomotive}
|
||||
wagons={
|
||||
schedule.trainSet?.wagons?.length
|
||||
? schedule.trainSet.wagons
|
||||
: displayWagonPlan
|
||||
}
|
||||
freightType={freightType}
|
||||
trainNumber={schedule.trainNumber}
|
||||
totalLengthMeters={schedule.trainSet?.totalLengthMeters}
|
||||
/>
|
||||
<Paper
|
||||
p="lg"
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{
|
||||
background: scheduleBrand.softSurface,
|
||||
borderColor: scheduleBrand.mutedBorder,
|
||||
}}
|
||||
>
|
||||
<Group gap="md" align="flex-start" wrap="nowrap">
|
||||
<ThemeIcon size={44} radius="md" variant="light" color="green">
|
||||
<CheckCircle2 size={22} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2}>
|
||||
<Text fw={600}>Ready to depart</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Finalizing locks the plan and moves the schedule to{" "}
|
||||
<Text span fw={600} c="green.7">
|
||||
SCHEDULED
|
||||
</Text>
|
||||
. Dispatch then begins rail movement and notifies the yard.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
<Group>
|
||||
{canFinalize ? (
|
||||
<Button
|
||||
color="green"
|
||||
size="md"
|
||||
radius="md"
|
||||
leftSection={<CheckCircle2 size={18} />}
|
||||
loading={finalize.isPending}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await finalize.mutateAsync(scheduleId);
|
||||
toast({ title: "Schedule finalized" });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Finalize failed",
|
||||
description: parseError(err, "Could not finalize"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Finalize schedule
|
||||
</Button>
|
||||
) : null}
|
||||
{canDispatch ? (
|
||||
<Button
|
||||
color="green"
|
||||
size="md"
|
||||
radius="md"
|
||||
leftSection={<Send size={18} />}
|
||||
loading={dispatch.isPending}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await dispatch.mutateAsync(scheduleId);
|
||||
toast({ title: "Train dispatched" });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Dispatch failed",
|
||||
description: parseError(err, "Could not dispatch"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Dispatch train
|
||||
</Button>
|
||||
) : null}
|
||||
{!canFinalize && !canDispatch ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No actions available for this schedule status.
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
@@ -343,282 +704,208 @@ export default function TrainScheduleV2DetailPage() {
|
||||
Back to schedules
|
||||
</Button>
|
||||
|
||||
<Card
|
||||
radius={schedulingWorkflow.card.radius}
|
||||
padding={schedulingWorkflow.card.padding}
|
||||
withBorder
|
||||
<Paper
|
||||
radius="xl"
|
||||
p="xl"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, var(--mantine-color-teal-0) 0%, white 55%, var(--mantine-color-gray-0) 100%)",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
background: scheduleBrand.heroGradient,
|
||||
boxShadow: scheduleBrand.shadow,
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Group gap="md" align="flex-start">
|
||||
<Paper p="sm" radius="xl" bg="teal.1">
|
||||
<Train size={24} color="var(--mantine-color-teal-7)" />
|
||||
</Paper>
|
||||
<Stack gap={4}>
|
||||
<Title order={3}>{schedule.route?.name ?? "Train schedule"}</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{schedule.originStation?.label ?? schedule.originStation?.code} →{" "}
|
||||
{schedule.destinationStation?.label ?? schedule.destinationStation?.code}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Departure {new Date(schedule.scheduledDepartureDate).toLocaleString()}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -90,
|
||||
right: -50,
|
||||
width: 280,
|
||||
height: 280,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(255,255,255,0.10)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
<Stack gap="lg" style={{ position: "relative" }}>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Group gap="md" align="flex-start" wrap="nowrap">
|
||||
<ThemeIcon
|
||||
size={56}
|
||||
radius="lg"
|
||||
variant="white"
|
||||
style={{ color: "var(--mantine-color-green-7)" }}
|
||||
>
|
||||
<Train size={28} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={6}>
|
||||
<Group gap="sm" align="center" wrap="wrap">
|
||||
<Title order={2} c="white" fw={700}>
|
||||
{schedule.route?.name ?? "Train schedule"}
|
||||
</Title>
|
||||
{schedule.trainNumber ? (
|
||||
<Badge
|
||||
variant="white"
|
||||
c="green.8"
|
||||
radius="sm"
|
||||
style={{ fontWeight: 600 }}
|
||||
>
|
||||
{schedule.trainNumber}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Box maw={340}>
|
||||
<RouteCorridor
|
||||
onDark
|
||||
origin={
|
||||
schedule.originStation?.label ?? schedule.originStation?.code
|
||||
}
|
||||
destination={
|
||||
schedule.destinationStation?.label ??
|
||||
schedule.destinationStation?.code
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Group gap="sm" align="center">
|
||||
<FreightTypeBadge freightType={schedule.freightType} />
|
||||
<StatusPill status={schedule.status} />
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
{schedule.status !== "DISPATCHED" ? (
|
||||
<Button variant="light" size="compact-sm" onClick={() => setMaintenanceOpen(true)}>
|
||||
<Button
|
||||
variant="white"
|
||||
c="green.8"
|
||||
radius="lg"
|
||||
size="sm"
|
||||
onClick={() => setMaintenanceOpen(true)}
|
||||
>
|
||||
Reschedule train
|
||||
</Button>
|
||||
) : null}
|
||||
<FreightTypeBadge freightType={schedule.freightType} />
|
||||
<ScheduleStatusBadge status={schedule.status} />
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Divider my="md" />
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
||||
<StatTile
|
||||
onDark
|
||||
icon={Train}
|
||||
label="Locomotive"
|
||||
value={schedule.trainSet?.locomotive?.code ?? "—"}
|
||||
/>
|
||||
<StatTile
|
||||
onDark
|
||||
icon={Package}
|
||||
label="Bookings"
|
||||
value={schedule.bookings?.length ?? 0}
|
||||
/>
|
||||
<StatTile
|
||||
onDark
|
||||
icon={Weight}
|
||||
label="Wagons / load"
|
||||
value={`${schedule.trainSet?.wagonCount ?? displayWagonPlan.length} · ${
|
||||
schedule.trainSet?.totalWeightTons ?? 0
|
||||
}T`}
|
||||
/>
|
||||
<StatTile
|
||||
onDark
|
||||
icon={CalendarClock}
|
||||
label="Departure"
|
||||
value={new Date(schedule.scheduledDepartureDate).toLocaleDateString(
|
||||
"en",
|
||||
{ month: "short", day: "2-digit" },
|
||||
)}
|
||||
hint={new Date(schedule.scheduledDepartureDate).toLocaleTimeString("en", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Group gap="xl">
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" c="dimmed" tt="uppercase">
|
||||
Locomotive
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{schedule.trainSet?.locomotive?.code ?? "—"}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" c="dimmed" tt="uppercase">
|
||||
Bookings
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{schedule.bookings?.length ?? 0}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" c="dimmed" tt="uppercase">
|
||||
Wagons
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{schedule.trainSet?.wagonCount ?? displayWagonPlan.length} ·{" "}
|
||||
{schedule.trainSet?.totalWeightTons ?? 0}T
|
||||
</Text>
|
||||
</Stack>
|
||||
{previewResult ? (
|
||||
<Badge variant="light" color={previewResult.valid ? "green" : "red"}>
|
||||
<Badge
|
||||
size="lg"
|
||||
radius="sm"
|
||||
variant="white"
|
||||
c={previewResult.valid ? "green.8" : "red.7"}
|
||||
leftSection={
|
||||
<Box
|
||||
w={8}
|
||||
h={8}
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
background: previewResult.valid
|
||||
? "var(--mantine-color-green-6)"
|
||||
: "var(--mantine-color-red-6)",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Preview {previewResult.valid ? "valid" : "has issues"}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
</Card>
|
||||
|
||||
<Card radius={schedulingWorkflow.card.radius} padding={schedulingWorkflow.card.padding} withBorder>
|
||||
<Stack gap="lg">
|
||||
<SchedulingWorkflowHeader
|
||||
title="Scheduling workflow"
|
||||
subtitle={`${schedule.route?.name ?? "Train schedule"} · ${schedule.originStation?.code ?? ""} → ${schedule.destinationStation?.code ?? ""}`}
|
||||
activeStep={activeStep}
|
||||
totalSteps={stepLabels.length}
|
||||
stepLabel={stepLabels[activeStep] ?? ""}
|
||||
stepDescription={
|
||||
activeStep === 0
|
||||
? "Select & preview"
|
||||
: activeStep === 1
|
||||
? "Allocations"
|
||||
: hasContainerStep && activeStep === 2
|
||||
? "Map units"
|
||||
: "Depart"
|
||||
}
|
||||
stepIcon={
|
||||
activeStep === 0
|
||||
? "package"
|
||||
: activeStep === 1
|
||||
? "layout"
|
||||
: hasContainerStep && activeStep === 2
|
||||
? "container"
|
||||
: "check"
|
||||
}
|
||||
/>
|
||||
|
||||
<Stepper
|
||||
active={activeStep}
|
||||
onStepClick={setActiveStep}
|
||||
color={schedulingWorkflow.stepper.color}
|
||||
iconSize={schedulingWorkflow.stepper.iconSize}
|
||||
size={schedulingWorkflow.stepper.size}
|
||||
>
|
||||
<Stepper.Step label="Bookings" description="Select & preview">
|
||||
<Stack gap="md" mt="lg">
|
||||
<ScheduleBookingsStep
|
||||
assignedBookings={(schedule.bookings ?? []).map((b) => ({
|
||||
id: b.id,
|
||||
reference: b.reference ?? b.id.slice(0, 8),
|
||||
weightTons: b.weightTons,
|
||||
}))}
|
||||
eligibleItems={eligibleQuery.data?.items ?? []}
|
||||
eligibleLoading={eligibleQuery.isLoading}
|
||||
selectedIds={allSelectedIds}
|
||||
onSelectionChange={(ids) => {
|
||||
const assigned = new Set(assignedIds);
|
||||
setSelectedBookingIds(ids.filter((id) => !assigned.has(id)));
|
||||
}}
|
||||
assignedIds={assignedIds}
|
||||
freightType={freightType}
|
||||
canRemove={canModifyBookings}
|
||||
onRemove={handleUnassign}
|
||||
/>
|
||||
|
||||
{canEditBookings ? (
|
||||
<Group align="center" wrap="wrap">
|
||||
<Button
|
||||
variant="filled"
|
||||
loading={preview.isPending}
|
||||
onClick={() => void runPreview()}
|
||||
>
|
||||
Preview plan
|
||||
</Button>
|
||||
<Checkbox
|
||||
label="Force assign (bypass hold/overweight warnings)"
|
||||
checked={forceAssign}
|
||||
onChange={(e) => setForceAssign(e.currentTarget.checked)}
|
||||
/>
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
{previewResult ? (
|
||||
<Stack gap="sm">
|
||||
<ScheduleWarningsAlert
|
||||
violations={previewResult.violations}
|
||||
warnings={previewResult.warnings}
|
||||
/>
|
||||
<FleetAvailabilitySummary
|
||||
fleetAvailability={previewResult.fleetAvailability}
|
||||
deferredBookings={previewResult.deferredBookings}
|
||||
/>
|
||||
<PreviewSummary summary={previewResult.summary} />
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step label="Wagon plan" description="Allocations">
|
||||
<Stack gap="md" mt="lg">
|
||||
{!displayWagonPlan.length && !previewResult ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
Run a preview from the Bookings step to generate the wagon plan.
|
||||
</Text>
|
||||
) : null}
|
||||
<FleetAvailabilitySummary
|
||||
fleetAvailability={previewResult?.fleetAvailability}
|
||||
deferredBookings={previewResult?.deferredBookings}
|
||||
/>
|
||||
<WagonPlanGrid wagonPlan={displayWagonPlan} freightType={freightType} />
|
||||
{canEditBookings && (previewResult || displayWagonPlan.length) ? (
|
||||
<Group>
|
||||
{!hasContainerStep ? (
|
||||
<Button color="green" loading={assign.isPending} onClick={handleAssign}>
|
||||
{assignedIds.length ? "Save assignments" : "Assign bookings"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="light" onClick={() => setActiveStep(2)}>
|
||||
Continue to containers
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="default" onClick={() => void runPreview()}>
|
||||
Refresh preview
|
||||
</Button>
|
||||
</Group>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
|
||||
{hasContainerStep ? (
|
||||
<Stepper.Step label="Containers" description="Map units">
|
||||
<Stack gap="md" mt="lg">
|
||||
{!containerUnits.length ? (
|
||||
<Paper p="md" radius="xl" withBorder bg="gray.0">
|
||||
<Text size="sm" c="dimmed">
|
||||
Run preview from the Bookings step to load container units for numbering.
|
||||
</Text>
|
||||
</Paper>
|
||||
) : (
|
||||
<ContainerPlacementGrid
|
||||
units={containerUnits}
|
||||
containerSlots={containerSlots}
|
||||
placements={containerPlacements}
|
||||
onChange={setContainerPlacements}
|
||||
/>
|
||||
)}
|
||||
{canEditBookings ? (
|
||||
<Group>
|
||||
<Button color="green" loading={assign.isPending} onClick={handleAssign}>
|
||||
{assignedIds.length ? "Save assignments" : "Assign bookings"}
|
||||
</Button>
|
||||
<Button variant="light" onClick={() => setActiveStep(finalizeStep)}>
|
||||
Skip to finalize
|
||||
</Button>
|
||||
</Group>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
) : null}
|
||||
|
||||
<Stepper.Step label="Finalize" description="Depart">
|
||||
<Stack gap="md" mt="lg">
|
||||
<Paper p="md" radius="lg" withBorder bg="gray.0">
|
||||
<Text size="sm" c="dimmed">
|
||||
Finalize moves the schedule to SCHEDULED. Dispatch begins rail movement.
|
||||
</Text>
|
||||
</Paper>
|
||||
<Group>
|
||||
{canFinalize ? (
|
||||
<Button
|
||||
color="green"
|
||||
loading={finalize.isPending}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await finalize.mutateAsync(scheduleId);
|
||||
toast({ title: "Schedule finalized" });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Finalize failed",
|
||||
description: parseError(err, "Could not finalize"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Finalize schedule
|
||||
</Button>
|
||||
) : null}
|
||||
{canDispatch ? (
|
||||
<Button
|
||||
color="blue"
|
||||
loading={dispatch.isPending}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await dispatch.mutateAsync(scheduleId);
|
||||
toast({ title: "Train dispatched" });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Dispatch failed",
|
||||
description: parseError(err, "Could not dispatch"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Dispatch train
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
</Stepper>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Paper>
|
||||
|
||||
<Paper radius="xl" p="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
||||
<Stack gap="lg">
|
||||
{/* Workflow header with ring progress */}
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="md" align="center" wrap="nowrap">
|
||||
<ThemeIcon
|
||||
size={44}
|
||||
radius="md"
|
||||
variant="gradient"
|
||||
gradient={{ from: "green", to: "teal", deg: 135 }}
|
||||
>
|
||||
<RouteIcon size={22} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2}>
|
||||
<Title order={4} fw={700}>
|
||||
Scheduling workflow
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{completedCount} of {stepsMeta.length} steps complete · expand any
|
||||
step to edit
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<RingProgress
|
||||
size={64}
|
||||
thickness={6}
|
||||
roundCaps
|
||||
sections={[{ value: progressPct, color: "green" }]}
|
||||
label={
|
||||
<Text ta="center" size="xs" fw={700} c="green.7">
|
||||
{progressPct}%
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<WorkflowRail>
|
||||
{stepsMeta.map((step, index) => (
|
||||
<WorkflowStep
|
||||
key={step.key}
|
||||
index={index}
|
||||
icon={step.icon}
|
||||
title={step.title}
|
||||
subtitle={step.subtitle}
|
||||
state={
|
||||
activeStep === index
|
||||
? "active"
|
||||
: step.complete
|
||||
? "complete"
|
||||
: "upcoming"
|
||||
}
|
||||
open={activeStep === index}
|
||||
onToggle={() => toggleStep(index)}
|
||||
rightSlot={renderStepRightSlot(step.key)}
|
||||
>
|
||||
{renderStepBody(step.key)}
|
||||
</WorkflowStep>
|
||||
))}
|
||||
</WorkflowRail>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{scheduleId ? (
|
||||
<RescheduleTrainDialog
|
||||
|
||||
@@ -17,14 +17,17 @@ import {
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { Train } from "lucide-react";
|
||||
import { ArrowRight, CalendarClock, Send, Train, Weight } from "lucide-react";
|
||||
|
||||
import FleetToolbar from "@/components/fleet/FleetToolbar";
|
||||
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
|
||||
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||
import {
|
||||
FreightTypeBadge,
|
||||
ScheduleStatusBadge,
|
||||
} from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||
RouteCorridor,
|
||||
StatTile,
|
||||
StatusPill,
|
||||
scheduleBrand,
|
||||
} from "@/components/trainScheduling/scheduleVisuals";
|
||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import { useRoutes } from "@/hooks/useRoutes";
|
||||
@@ -34,21 +37,24 @@ import {
|
||||
useScheduleMutations,
|
||||
} from "@/hooks/trainScheduling/useTrainScheduling";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { FreightType, TrainScheduleListItem } from "@/types/trainScheduling";
|
||||
import type { TrainScheduleListItem } from "@/types/trainScheduling";
|
||||
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
|
||||
import { schedulingWorkflow } from "@/components/trainScheduling/schedulingWorkflow.styles";
|
||||
|
||||
const formatDate = (value?: string | null) => {
|
||||
if (!value) return "—";
|
||||
const splitDate = (value?: string | null) => {
|
||||
if (!value) return { day: "—", time: "" };
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "—";
|
||||
return new Intl.DateTimeFormat("en", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
if (Number.isNaN(date.getTime())) return { day: "—", time: "" };
|
||||
return {
|
||||
day: new Intl.DateTimeFormat("en", {
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
}).format(date),
|
||||
time: new Intl.DateTimeFormat("en", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(date),
|
||||
};
|
||||
};
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
@@ -83,9 +89,28 @@ export default function TrainScheduleV2ListPage() {
|
||||
[routesQuery.data],
|
||||
);
|
||||
|
||||
const allSchedules = schedulesQuery.data ?? [];
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const base = {
|
||||
total: allSchedules.length,
|
||||
scheduled: 0,
|
||||
dispatched: 0,
|
||||
draft: 0,
|
||||
weight: 0,
|
||||
};
|
||||
for (const s of allSchedules) {
|
||||
if (s.status === "SCHEDULED") base.scheduled += 1;
|
||||
if (s.status === "DISPATCHED") base.dispatched += 1;
|
||||
if (s.status === "DRAFT") base.draft += 1;
|
||||
base.weight += s.totalWeightTons ?? 0;
|
||||
}
|
||||
return base;
|
||||
}, [allSchedules]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return (schedulesQuery.data ?? []).filter((s) => {
|
||||
return allSchedules.filter((s) => {
|
||||
if (statusFilter !== "ALL" && s.status !== statusFilter) return false;
|
||||
if (freightFilter !== "ALL" && s.freightType !== freightFilter) return false;
|
||||
if (!query) return true;
|
||||
@@ -103,7 +128,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
.toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}, [schedulesQuery.data, search, statusFilter, freightFilter]);
|
||||
}, [allSchedules, search, statusFilter, freightFilter]);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(filtered.length / pagination.pageSize));
|
||||
const paged = useMemo(() => {
|
||||
@@ -119,19 +144,55 @@ export default function TrainScheduleV2ListPage() {
|
||||
id: "date",
|
||||
header: "Departure",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => formatDate(row.original.scheduleDate),
|
||||
cell: ({ row }) => {
|
||||
const { day, time } = splitDate(row.original.scheduleDate);
|
||||
return (
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 9,
|
||||
background: "var(--mantine-color-green-0)",
|
||||
color: "var(--mantine-color-green-7)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<CalendarClock size={16} />
|
||||
</Box>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw={600} lh={1.2}>
|
||||
{day}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lh={1.2}>
|
||||
{time || "—"}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
header: "Route",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => row.original.routeName ?? "—",
|
||||
},
|
||||
{
|
||||
id: "corridor",
|
||||
header: "Corridor",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => `${row.original.origin ?? "—"} → ${row.original.destination ?? "—"}`,
|
||||
cell: ({ row }) => (
|
||||
<Stack gap={4}>
|
||||
<Text size="sm" fw={600} lh={1.2}>
|
||||
{row.original.routeName ?? "—"}
|
||||
</Text>
|
||||
<Box maw={220}>
|
||||
<RouteCorridor
|
||||
origin={row.original.origin}
|
||||
destination={row.original.destination}
|
||||
variant="compact"
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "freight",
|
||||
@@ -143,20 +204,37 @@ export default function TrainScheduleV2ListPage() {
|
||||
id: "loco",
|
||||
header: "Locomotive",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => row.original.locomotive?.code ?? "—",
|
||||
cell: ({ row }) =>
|
||||
row.original.locomotive?.code ? (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Train size={14} color="var(--mantine-color-gray-5)" />
|
||||
<Text size="sm" fw={500}>
|
||||
{row.original.locomotive.code}
|
||||
</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "metrics",
|
||||
header: "Bookings / Wagons",
|
||||
header: "Load",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) =>
|
||||
`${row.original.bookingsCount} / ${row.original.wagonCount} · ${row.original.totalWeightTons}T`,
|
||||
cell: ({ row }) => (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<MetricChip value={row.original.bookingsCount} label="bkg" />
|
||||
<MetricChip value={row.original.wagonCount} label="wgn" />
|
||||
<MetricChip value={`${row.original.totalWeightTons}T`} label="" subtle />
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => <ScheduleStatusBadge status={row.original.status} />,
|
||||
cell: ({ row }) => <StatusPill status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
@@ -166,7 +244,9 @@ export default function TrainScheduleV2ListPage() {
|
||||
<Group gap={6} justify="flex-end" wrap="nowrap">
|
||||
<Button
|
||||
variant="light"
|
||||
color="green"
|
||||
size="compact-sm"
|
||||
rightSection={<ArrowRight size={14} />}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/operations/train-scheduling-v2/${row.original.id}`)
|
||||
}
|
||||
@@ -175,7 +255,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
</Button>
|
||||
{["DRAFT", "SCHEDULED"].includes(row.original.status) ? (
|
||||
<Button
|
||||
variant="light"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="compact-sm"
|
||||
loading={cancel.isPending}
|
||||
@@ -202,7 +282,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
),
|
||||
},
|
||||
];
|
||||
}, [navigate, cancel.isPending, toast]);
|
||||
}, [navigate, cancel.isPending, cancel, toast]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!routeId || !scheduleDate || !locomotiveId) {
|
||||
@@ -232,27 +312,94 @@ export default function TrainScheduleV2ListPage() {
|
||||
: "success";
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Stack gap="lg">
|
||||
{/* Hero banner */}
|
||||
<Paper
|
||||
p="lg"
|
||||
radius={schedulingWorkflow.card.radius}
|
||||
withBorder
|
||||
radius="xl"
|
||||
p="xl"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, var(--mantine-color-teal-0) 0%, white 55%, var(--mantine-color-gray-0) 100%)",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
background: scheduleBrand.heroGradient,
|
||||
boxShadow: scheduleBrand.shadow,
|
||||
}}
|
||||
>
|
||||
<Group gap="md" align="center">
|
||||
<ThemeIcon size={48} radius="xl" variant="gradient" gradient={{ from: "teal", to: "green", deg: 135 }}>
|
||||
<Train size={24} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2}>
|
||||
<Title order={3}>Train Schedules</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
Plan departures, allocate bookings, and dispatch trains across corridors.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
{/* decorative glow */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -90,
|
||||
right: -60,
|
||||
width: 280,
|
||||
height: 280,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(255,255,255,0.12)",
|
||||
filter: "blur(8px)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: -120,
|
||||
right: 120,
|
||||
width: 220,
|
||||
height: 220,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(255,255,255,0.06)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
<Stack gap="lg" style={{ position: "relative" }}>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Group gap="md" align="center" wrap="nowrap">
|
||||
<ThemeIcon
|
||||
size={56}
|
||||
radius="lg"
|
||||
variant="white"
|
||||
style={{ color: "var(--mantine-color-green-7)" }}
|
||||
>
|
||||
<Train size={28} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={4}>
|
||||
<Title order={2} c="white" fw={700}>
|
||||
Train Schedules
|
||||
</Title>
|
||||
<Text size="sm" c="rgba(255,255,255,0.85)" maw={520}>
|
||||
Plan departures, allocate bookings, and dispatch trains across
|
||||
every corridor.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Button
|
||||
size="md"
|
||||
radius="lg"
|
||||
variant="white"
|
||||
c="green.8"
|
||||
leftSection={<Train size={18} />}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
New schedule
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
||||
<StatTile onDark icon={Train} label="Total trains" value={stats.total} />
|
||||
<StatTile
|
||||
onDark
|
||||
icon={CalendarClock}
|
||||
label="Scheduled"
|
||||
value={stats.scheduled}
|
||||
/>
|
||||
<StatTile onDark icon={Send} label="Dispatched" value={stats.dispatched} />
|
||||
<StatTile
|
||||
onDark
|
||||
icon={Weight}
|
||||
label="Planned load"
|
||||
value={`${Math.round(stats.weight)}T`}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
||||
@@ -338,37 +485,15 @@ export default function TrainScheduleV2ListPage() {
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" p="md">
|
||||
{paged.map((schedule) => (
|
||||
<Card key={schedule.id} radius="lg" padding="lg" withBorder>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600} size="sm">
|
||||
{schedule.routeName ?? "Train schedule"}
|
||||
</Text>
|
||||
<ScheduleStatusBadge status={schedule.status} />
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{formatDate(schedule.scheduleDate)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{schedule.origin} → {schedule.destination}
|
||||
</Text>
|
||||
<Group gap={6}>
|
||||
<FreightTypeBadge freightType={schedule.freightType} />
|
||||
<Text size="xs" c="dimmed">
|
||||
{schedule.bookingsCount} bookings · {schedule.wagonCount} wagons
|
||||
</Text>
|
||||
</Group>
|
||||
<Button
|
||||
variant="light"
|
||||
size="compact-sm"
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}`)
|
||||
}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
<ScheduleCard
|
||||
key={schedule.id}
|
||||
schedule={schedule}
|
||||
onOpen={() =>
|
||||
navigate(
|
||||
`/dashboard/operations/train-scheduling-v2/${schedule.id}`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
@@ -436,3 +561,127 @@ export default function TrainScheduleV2ListPage() {
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricChip({
|
||||
value,
|
||||
label,
|
||||
subtle = false,
|
||||
}: {
|
||||
value: string | number;
|
||||
label: string;
|
||||
subtle?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Group
|
||||
gap={4}
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
padding: "2px 8px",
|
||||
borderRadius: 8,
|
||||
background: subtle
|
||||
? "var(--mantine-color-gray-1)"
|
||||
: "var(--mantine-color-green-0)",
|
||||
border: `1px solid ${
|
||||
subtle ? "var(--mantine-color-gray-2)" : "var(--mantine-color-green-1)"
|
||||
}`,
|
||||
}}
|
||||
>
|
||||
<Text size="sm" fw={700} c={subtle ? "gray.7" : "green.8"} lh={1.2}>
|
||||
{value}
|
||||
</Text>
|
||||
{label ? (
|
||||
<Text size="xs" c="dimmed" lh={1.2}>
|
||||
{label}
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleCard({
|
||||
schedule,
|
||||
onOpen,
|
||||
}: {
|
||||
schedule: TrainScheduleListItem;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const { day, time } = splitDate(schedule.scheduleDate);
|
||||
return (
|
||||
<Card
|
||||
radius="lg"
|
||||
padding={0}
|
||||
withBorder
|
||||
onClick={onOpen}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
overflow: "hidden",
|
||||
borderColor: "var(--mantine-color-gray-2)",
|
||||
transition: "box-shadow 150ms ease, transform 150ms ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.boxShadow = scheduleBrand.shadowSm;
|
||||
e.currentTarget.style.transform = "translateY(-2px)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.boxShadow = "";
|
||||
e.currentTarget.style.transform = "";
|
||||
}}
|
||||
>
|
||||
{/* accent strip */}
|
||||
<Box style={{ height: 4, background: scheduleBrand.heroGradient }} />
|
||||
<Stack gap="sm" p="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon size={38} radius="md" variant="light" color="green">
|
||||
<Train size={18} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text fw={600} size="sm" lineClamp={1}>
|
||||
{schedule.routeName ?? "Train schedule"}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{day} · {time}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<StatusPill status={schedule.status} />
|
||||
</Group>
|
||||
|
||||
<Box
|
||||
p="xs"
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-1)",
|
||||
}}
|
||||
>
|
||||
<RouteCorridor origin={schedule.origin} destination={schedule.destination} />
|
||||
</Box>
|
||||
|
||||
<Group justify="space-between" align="center">
|
||||
<FreightTypeBadge freightType={schedule.freightType} />
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<MetricChip value={schedule.bookingsCount} label="bkg" />
|
||||
<MetricChip value={schedule.wagonCount} label="wgn" />
|
||||
<MetricChip value={`${schedule.totalWeightTons}T`} label="" subtle />
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
color="green"
|
||||
size="sm"
|
||||
radius="md"
|
||||
fullWidth
|
||||
rightSection={<ArrowRight size={15} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpen();
|
||||
}}
|
||||
>
|
||||
Open schedule
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,11 +32,13 @@ export const trainSchedulingService = {
|
||||
freightType?: FreightType,
|
||||
): Promise<EligibleContainerBookingsResponse> => {
|
||||
const useUnified = !freightType || freightType === "MIXED";
|
||||
// The container/bulk endpoints already encode freight type in the path, and their
|
||||
// query DTOs reject an extra `freightType` param — so only pass the station filters.
|
||||
const response = await client.get<EligibleContainerBookingsResponse>(
|
||||
useUnified
|
||||
? URL_CONSTANTS.TRAIN_SCHEDULING.ELIGIBLE_BOOKINGS
|
||||
: pathsFor(freightType).ELIGIBLE_BOOKINGS,
|
||||
{ params: { ...filters, ...(freightType && freightType !== "MIXED" ? { freightType } : {}) } },
|
||||
{ params: { ...filters } },
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
@@ -72,6 +72,7 @@ export interface ContainerUnitRow {
|
||||
wagonsPerUnit?: number;
|
||||
containersPerWagon?: number;
|
||||
teuSlots?: number;
|
||||
containerNumber?: string | null;
|
||||
}
|
||||
|
||||
export interface ContainerPlacement {
|
||||
|
||||
Reference in New Issue
Block a user