mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 01:48:12 +00:00
feat(train-scheduling): implement container movement between wagons
- Added functionality to move containers between wagons in the train scheduling system. - Introduced API endpoint and service method to handle container movement. - Updated component to support drag-and-drop for rearranging containers. - Enhanced to allow moving containers to other wagons via a context menu. - Implemented UI feedback for container movement actions, including loading states and success/error notifications. - Updated relevant types and constants to accommodate new container movement logic. - Added tests for the rule engine to ensure proper handling of hazardous bookings.
This commit is contained in:
@@ -97,6 +97,15 @@ function CorridorCell({ row }: { row: IntercityBookingRow }) {
|
||||
* confirmed manually when the train is physically at the booking's origin /
|
||||
* destination yard (the server validates against recorded checkpoints).
|
||||
*/
|
||||
/** Plain-language journey states for the accepted ride-along table. */
|
||||
const INTERCITY_STATUS_META: Record<string, { label: string; color: string }> = {
|
||||
SELECTED_FOR_BATCH: { label: "Awaiting payment", color: "yellow" },
|
||||
APPROVED: { label: "Ready to load (gov)", color: "edr-green" },
|
||||
PAID: { label: "Paid — ready to load", color: "edr-green" },
|
||||
IN_TRANSIT: { label: "Loaded — in transit", color: "indigo" },
|
||||
COMPLETED: { label: "Delivered", color: "teal" },
|
||||
};
|
||||
|
||||
export function IntercityRideAlongPanel({
|
||||
scheduleId,
|
||||
direction,
|
||||
@@ -115,10 +124,20 @@ export function IntercityRideAlongPanel({
|
||||
}),
|
||||
);
|
||||
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({
|
||||
// Accepting/loading/unloading a ride-along changes the schedule's booking
|
||||
// list, the yard worklists AND this panel — refresh all three so the
|
||||
// workspace board and yard-work tables never show a stale picture.
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: api.trainScheduling.intercityCandidates.queryKey({ scheduleId }),
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: api.trainScheduling.yardWork.queryKey({ scheduleId }),
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: api.trainScheduling.scheduleDetail.queryKey({ id: scheduleId }),
|
||||
});
|
||||
};
|
||||
|
||||
const accept = useMutation(
|
||||
api.trainScheduling.acceptIntercityBookings.mutationOptions({
|
||||
@@ -330,8 +349,14 @@ export function IntercityRideAlongPanel({
|
||||
<CorridorCell row={row} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light">
|
||||
{row.status}
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={
|
||||
INTERCITY_STATUS_META[row.status ?? ""]?.color ?? "gray"
|
||||
}
|
||||
>
|
||||
{INTERCITY_STATUS_META[row.status ?? ""]?.label ?? row.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
|
||||
@@ -584,6 +584,15 @@ export function ScheduleWorkspacePanel({
|
||||
customer={b.customer}
|
||||
weightTons={b.weightTons}
|
||||
status={b.status}
|
||||
intercity={b.tradeDirection === "DOMESTIC"}
|
||||
leg={
|
||||
b.origin &&
|
||||
b.destination &&
|
||||
(b.originYardId !== schedule.originStation?.id ||
|
||||
b.destinationYardId !== schedule.destinationStation?.id)
|
||||
? `${b.origin} → ${b.destination}`
|
||||
: null
|
||||
}
|
||||
loadingStatus={b.wagonAssigned ? b.loadingStatus ?? "UNLOADED" : undefined}
|
||||
right={
|
||||
canManage ? (
|
||||
@@ -840,6 +849,8 @@ function BookingCard({
|
||||
status,
|
||||
loadingStatus,
|
||||
waitingForWagon,
|
||||
intercity,
|
||||
leg,
|
||||
right,
|
||||
}: {
|
||||
reference: string;
|
||||
@@ -849,6 +860,10 @@ function BookingCard({
|
||||
loadingStatus?: "LOADED" | "UNLOADED";
|
||||
/** Paid, but no wagon of the required type was free — waiting for one. */
|
||||
waitingForWagon?: boolean;
|
||||
/** DOMESTIC ride-along riding only part of this train's corridor. */
|
||||
intercity?: boolean;
|
||||
/** "Origin → Destination" when the booking rides a sub-corridor leg. */
|
||||
leg?: string | null;
|
||||
right?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
@@ -874,6 +889,16 @@ function BookingCard({
|
||||
{reference}
|
||||
</Text>
|
||||
{status ? <BookingStatusBadge status={status} /> : null}
|
||||
{intercity ? (
|
||||
<Tooltip
|
||||
label="Intercity ride-along — rides only its own leg of this train's corridor"
|
||||
withArrow
|
||||
>
|
||||
<Badge size="sm" radius="sm" variant="filled" color="indigo">
|
||||
Intercity
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{waitingForWagon ? (
|
||||
<Tooltip
|
||||
label="Paid, but no wagon of the required type was free. Free a wagon or assign it to a same-day train that has one."
|
||||
@@ -907,6 +932,11 @@ function BookingCard({
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
{leg ? (
|
||||
<Text size="xs" c="indigo.7" fw={600} style={{ whiteSpace: "nowrap" }}>
|
||||
{leg}
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
{right ? <Box style={{ flexShrink: 0 }}>{right}</Box> : null}
|
||||
|
||||
@@ -2,7 +2,10 @@ 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 & { physicalWagonNumber?: string | null }) | {
|
||||
type WagonSlot = (WagonPlanRow & {
|
||||
physicalWagonNumber?: string | null;
|
||||
tareWeightTons?: number | null;
|
||||
}) | {
|
||||
sequenceNo: number;
|
||||
capacityTons: number;
|
||||
assignedWeightTons: number;
|
||||
|
||||
@@ -201,10 +201,19 @@ export function YardWorkPanel({ scheduleId }: { scheduleId: string }) {
|
||||
}),
|
||||
);
|
||||
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({
|
||||
// Loading/unloading changes booking status on the schedule detail and the
|
||||
// intercity panel too — refresh all three so no surface shows a stale state.
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: api.trainScheduling.yardWork.queryKey({ scheduleId }),
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: api.trainScheduling.intercityCandidates.queryKey({ scheduleId }),
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: api.trainScheduling.scheduleDetail.queryKey({ id: scheduleId }),
|
||||
});
|
||||
};
|
||||
|
||||
const load = useMutation(
|
||||
api.trainScheduling.loadScheduleBooking.mutationOptions({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { Badge, Box, Group, HoverCard, Stack, Text } from "@mantine/core";
|
||||
import {
|
||||
Building2,
|
||||
@@ -14,6 +15,15 @@ import { freightBrand } from "@/theme/freight-brand";
|
||||
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
|
||||
type Locomotive = NonNullable<TrainScheduleDetail["trainSet"]>["locomotive"];
|
||||
|
||||
export interface ContainerMove {
|
||||
itemId: string;
|
||||
targetWagonId: string;
|
||||
/** Present when the drop landed on another container — swap the two. */
|
||||
swapWithItemId?: string;
|
||||
}
|
||||
|
||||
type DragState = { itemId: string; sourceWagonId: string } | null;
|
||||
|
||||
interface InteractiveTrainConsistProps {
|
||||
wagons: Wagon[];
|
||||
locomotive: Locomotive | null | undefined;
|
||||
@@ -23,8 +33,16 @@ interface InteractiveTrainConsistProps {
|
||||
onSelectWagon: (wagon: Wagon) => void;
|
||||
/** Booking id to highlight across the train (e.g. selected in the side panel). */
|
||||
highlightBookingId?: string | null;
|
||||
/** Containers become draggable between wagons (drop on a container = swap). */
|
||||
canRearrange?: boolean;
|
||||
onMoveContainer?: (move: ContainerMove) => void;
|
||||
}
|
||||
|
||||
const wagonItems = (wagon: Wagon) =>
|
||||
(wagon.allocations ?? [])
|
||||
.flatMap((a) => a.containerItems ?? [])
|
||||
.sort((a, b) => (a.positionOnWagon ?? 99) - (b.positionOnWagon ?? 99));
|
||||
|
||||
const CONTAINER_GRADIENTS = [
|
||||
"linear-gradient(180deg, var(--mantine-color-cyan-5), var(--mantine-color-cyan-7))",
|
||||
"linear-gradient(180deg, var(--mantine-color-blue-5), var(--mantine-color-blue-7))",
|
||||
@@ -151,16 +169,27 @@ function WagonCar({
|
||||
selected,
|
||||
highlighted,
|
||||
onSelect,
|
||||
drag,
|
||||
onDragChange,
|
||||
onMoveContainer,
|
||||
canRearrange,
|
||||
}: {
|
||||
wagon: Wagon;
|
||||
company: string | null;
|
||||
selected: boolean;
|
||||
highlighted: boolean;
|
||||
onSelect: () => void;
|
||||
drag: DragState;
|
||||
onDragChange: (drag: DragState) => void;
|
||||
onMoveContainer?: (move: ContainerMove) => void;
|
||||
canRearrange: boolean;
|
||||
}) {
|
||||
const [dropHover, setDropHover] = useState(false);
|
||||
const allocation = wagon.allocations?.[0];
|
||||
const isEmpty = !allocation;
|
||||
const isBulk = (allocation?.loadType ?? "").toUpperCase().includes("BULK");
|
||||
const isBulk = (wagon.allocations ?? []).some((a) =>
|
||||
(a.loadType ?? "").toUpperCase().includes("BULK"),
|
||||
);
|
||||
// GROSS on both sides: cargo + tare vs rated payload + tare.
|
||||
const tare = wagon.tareWeightTons ?? 0;
|
||||
const assigned =
|
||||
@@ -170,10 +199,19 @@ function WagonCar({
|
||||
const accent = isEmpty ? "gray" : isBulk ? "orange" : "cyan";
|
||||
const accentVar = `var(--mantine-color-${accent}-6)`;
|
||||
|
||||
const containerNumbers = (allocation?.containerItems ?? []).map(
|
||||
(c) => c.containerNumber?.trim() || "—",
|
||||
const items = wagonItems(wagon);
|
||||
const blocks = items.slice(0, 2);
|
||||
const containerNumbers = items.map((c) => c.containerNumber?.trim() || "—");
|
||||
|
||||
// Where a dragged container may land: another wagon, not bulk-loaded, with a
|
||||
// free half (the API re-checks TEU/weight — this only paints the hint).
|
||||
const dropEligible = Boolean(
|
||||
drag && drag.sourceWagonId !== wagon.id && !isBulk && items.length < 2,
|
||||
);
|
||||
const blocks = containerNumbers.slice(0, 2);
|
||||
const endDrag = () => {
|
||||
onDragChange(null);
|
||||
setDropHover(false);
|
||||
};
|
||||
|
||||
const ringColor = selected
|
||||
? freightBrand.primary
|
||||
@@ -189,6 +227,21 @@ function WagonCar({
|
||||
style={{ width: 120, flexShrink: 0, cursor: "pointer" }}
|
||||
>
|
||||
<Box
|
||||
onDragOver={(e) => {
|
||||
if (dropEligible) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDropHover(true);
|
||||
}
|
||||
}}
|
||||
onDragLeave={() => setDropHover(false)}
|
||||
onDrop={(e) => {
|
||||
if (dropEligible && drag) {
|
||||
e.preventDefault();
|
||||
onMoveContainer?.({ itemId: drag.itemId, targetWagonId: wagon.id });
|
||||
}
|
||||
endDrag();
|
||||
}}
|
||||
style={{
|
||||
position: "relative",
|
||||
height: 70,
|
||||
@@ -205,10 +258,16 @@ function WagonCar({
|
||||
: isEmpty
|
||||
? "none"
|
||||
: "0 3px 10px rgba(15,41,27,0.08)",
|
||||
outline: dropHover
|
||||
? "2px solid var(--mantine-color-cyan-6)"
|
||||
: dropEligible
|
||||
? "2px dashed var(--mantine-color-cyan-4)"
|
||||
: "none",
|
||||
outlineOffset: 2,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
transition: "box-shadow 120ms ease",
|
||||
transition: "box-shadow 120ms ease, outline-color 120ms ease",
|
||||
}}
|
||||
>
|
||||
{/* top accent strip */}
|
||||
@@ -274,28 +333,84 @@ function WagonCar({
|
||||
</Stack>
|
||||
) : (
|
||||
<Group gap={3} justify="center" wrap="nowrap" style={{ width: "100%" }}>
|
||||
{(blocks.length ? blocks : ["—"]).map((cn, i) => (
|
||||
{blocks.length ? (
|
||||
blocks.map((item, i) => {
|
||||
const isDragged = drag?.itemId === item.id;
|
||||
const swapEligible = Boolean(drag && drag.itemId !== item.id);
|
||||
return (
|
||||
<Box
|
||||
key={item.id}
|
||||
draggable={canRearrange}
|
||||
onDragStart={(e) => {
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
// Firefox needs data set for the drag to start.
|
||||
e.dataTransfer.setData("text/plain", item.id);
|
||||
onDragChange({ itemId: item.id, sourceWagonId: wagon.id });
|
||||
}}
|
||||
onDragEnd={endDrag}
|
||||
onDragOver={(e) => {
|
||||
if (swapEligible) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
}
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (swapEligible && drag) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onMoveContainer?.({
|
||||
itemId: drag.itemId,
|
||||
targetWagonId: wagon.id,
|
||||
swapWithItemId: item.id,
|
||||
});
|
||||
}
|
||||
endDrag();
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
height: 26,
|
||||
borderRadius: 4,
|
||||
background: CONTAINER_GRADIENTS[i % CONTAINER_GRADIENTS.length],
|
||||
border: `1px solid ${CONTAINER_BORDERS[i % CONTAINER_BORDERS.length]}`,
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.3)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "0 2px",
|
||||
cursor: canRearrange ? "grab" : undefined,
|
||||
opacity: isDragged ? 0.35 : 1,
|
||||
transition: "opacity 120ms ease",
|
||||
}}
|
||||
>
|
||||
<Text size="8px" fw={700} c="white" truncate style={{ maxWidth: "100%" }}>
|
||||
{item.containerNumber?.trim() || "—"}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Box
|
||||
key={i}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
height: 26,
|
||||
borderRadius: 4,
|
||||
background: CONTAINER_GRADIENTS[i % CONTAINER_GRADIENTS.length],
|
||||
border: `1px solid ${CONTAINER_BORDERS[i % CONTAINER_BORDERS.length]}`,
|
||||
background: CONTAINER_GRADIENTS[0],
|
||||
border: `1px solid ${CONTAINER_BORDERS[0]}`,
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.3)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "0 2px",
|
||||
}}
|
||||
>
|
||||
<Text size="8px" fw={700} c="white" truncate style={{ maxWidth: "100%" }}>
|
||||
{cn}
|
||||
<Text size="8px" fw={700} c="white">
|
||||
—
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
@@ -447,7 +562,10 @@ export const InteractiveTrainConsist = ({
|
||||
selectedWagonId,
|
||||
onSelectWagon,
|
||||
highlightBookingId,
|
||||
canRearrange = false,
|
||||
onMoveContainer,
|
||||
}: InteractiveTrainConsistProps) => {
|
||||
const [drag, setDrag] = useState<DragState>(null);
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
@@ -477,6 +595,10 @@ export const InteractiveTrainConsist = ({
|
||||
selected={selectedWagonId === wagon.id}
|
||||
highlighted={Boolean(highlightBookingId && bookingId === highlightBookingId)}
|
||||
onSelect={() => onSelectWagon(wagon)}
|
||||
drag={drag}
|
||||
onDragChange={setDrag}
|
||||
onMoveContainer={onMoveContainer}
|
||||
canRearrange={canRearrange}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Badge, Box, Group, Paper, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import { MousePointerClick, TrainFront } from "lucide-react";
|
||||
import { isAxiosError } from "axios";
|
||||
import { Hand, MousePointerClick, TrainFront } from "lucide-react";
|
||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
import { TrainStatsBar } from "./TrainStatsBar";
|
||||
import { WagonCard } from "./WagonCard";
|
||||
import { InteractiveTrainConsist } from "./InteractiveTrainConsist";
|
||||
import { InteractiveTrainConsist, type ContainerMove } from "./InteractiveTrainConsist";
|
||||
import { RemoveBookingModal } from "./RemoveBookingModal";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
|
||||
@@ -47,6 +49,7 @@ export const TrainConsistView = ({
|
||||
}: TrainConsistViewProps) => {
|
||||
const [selectedWagonId, setSelectedWagonId] = useState<string | null>(null);
|
||||
const [removeModalOpen, setRemoveModalOpen] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const unassignMutation = useMutation(
|
||||
api.trainScheduling.unassignBooking.mutationOptions(),
|
||||
@@ -54,9 +57,37 @@ export const TrainConsistView = ({
|
||||
const removeWagonMutation = useMutation(
|
||||
api.trainScheduling.removeWagonSlot.mutationOptions(),
|
||||
);
|
||||
const moveContainerMutation = useMutation(
|
||||
api.trainScheduling.moveContainerItem.mutationOptions(),
|
||||
);
|
||||
|
||||
const trainSet = scheduleDetail.trainSet;
|
||||
const wagons = trainSet?.wagons ?? [];
|
||||
const canRearrange = !["DISPATCHED", "ARRIVED"].includes(scheduleDetail.status);
|
||||
|
||||
const handleMoveContainer = async (move: ContainerMove) => {
|
||||
if (moveContainerMutation.isPending) return;
|
||||
try {
|
||||
await moveContainerMutation.mutateAsync({
|
||||
scheduleId,
|
||||
itemId: move.itemId,
|
||||
targetTrainSetWagonId: move.targetWagonId,
|
||||
swapWithItemId: move.swapWithItemId,
|
||||
});
|
||||
toast({ title: move.swapWithItemId ? "Containers swapped" : "Container moved" });
|
||||
} catch (error) {
|
||||
const message = isAxiosError(error)
|
||||
? ((error.response?.data as { message?: string | string[] } | undefined)?.message ?? null)
|
||||
: null;
|
||||
toast({
|
||||
title: "Could not move container",
|
||||
description: Array.isArray(message)
|
||||
? message.join(", ")
|
||||
: (message ?? "The move was rejected — check the wagon's space and load."),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Join company/customer name from schedule bookings by booking id.
|
||||
const companyByBooking = useMemo(() => {
|
||||
@@ -152,13 +183,21 @@ export const TrainConsistView = ({
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="md" wrap="nowrap" visibleFrom="sm">
|
||||
{canRearrange ? (
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<Hand size={12} color="var(--mantine-color-cyan-7)" />
|
||||
<Text size="xs" c="dimmed">
|
||||
Drag a container to move it — drop on a container to swap
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
<LegendDot color="cyan" label="Container" />
|
||||
<LegendDot color="orange" label="Bulk" />
|
||||
<LegendDot color="gray" label="Empty" dashed />
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Box p="md">
|
||||
<Box p="md" style={{ opacity: moveContainerMutation.isPending ? 0.6 : 1 }}>
|
||||
<InteractiveTrainConsist
|
||||
wagons={wagons}
|
||||
locomotive={trainSet?.locomotive}
|
||||
@@ -166,6 +205,8 @@ export const TrainConsistView = ({
|
||||
selectedWagonId={selectedWagonId}
|
||||
onSelectWagon={(w) => setSelectedWagonId((prev) => (prev === w.id ? null : w.id))}
|
||||
highlightBookingId={highlightBookingId}
|
||||
canRearrange={canRearrange && !moveContainerMutation.isPending}
|
||||
onMoveContainer={(move) => void handleMoveContainer(move)}
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
@@ -178,7 +219,7 @@ export const TrainConsistView = ({
|
||||
Editing wagon #{selectedWagon.sequenceNo}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">
|
||||
Update container numbers or remove the booking
|
||||
Update container numbers, move containers to another wagon, or remove the booking
|
||||
</Text>
|
||||
</Group>
|
||||
<WagonCard
|
||||
@@ -192,6 +233,8 @@ export const TrainConsistView = ({
|
||||
scheduleStatus={scheduleDetail.status}
|
||||
onRemoveBooking={handleRemoveBooking}
|
||||
onRemoveWagon={handleRemoveWagon}
|
||||
wagons={wagons}
|
||||
onMoveContainer={canRearrange ? (move) => void handleMoveContainer(move) : undefined}
|
||||
/>
|
||||
</Box>
|
||||
) : wagons.length ? (
|
||||
@@ -209,7 +252,8 @@ export const TrainConsistView = ({
|
||||
<MousePointerClick size={13} />
|
||||
</ThemeIcon>
|
||||
<Text size="xs" c="dimmed">
|
||||
Click a wagon in the train to edit container numbers or remove its booking.
|
||||
Click a wagon to edit its containers — or drag a container between wagons to
|
||||
rearrange the load.
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import { Badge, Box, Button, Card, Group, Progress, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Menu,
|
||||
Progress,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
ArrowLeftRight,
|
||||
Building2,
|
||||
Container as ContainerIcon,
|
||||
Fuel,
|
||||
@@ -10,6 +24,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
import { ContainerNumberInput } from "./ContainerNumberInput";
|
||||
import type { ContainerMove } from "./InteractiveTrainConsist";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
|
||||
@@ -21,8 +36,17 @@ interface WagonCardProps {
|
||||
scheduleStatus?: string;
|
||||
onRemoveBooking: (wagon: Wagon) => void;
|
||||
onRemoveWagon: (wagonId: string) => void;
|
||||
/** All wagons of the consist — targets for the per-container move menu. */
|
||||
wagons?: Wagon[];
|
||||
onMoveContainer?: (move: ContainerMove) => void;
|
||||
}
|
||||
|
||||
const itemCountOf = (w: Wagon) =>
|
||||
(w.allocations ?? []).reduce((sum, a) => sum + (a.containerItems?.length ?? 0), 0);
|
||||
|
||||
const isBulkWagon = (w: Wagon) =>
|
||||
(w.allocations ?? []).some((a) => (a.loadType ?? "").toUpperCase().includes("BULK"));
|
||||
|
||||
export const WagonCard = ({
|
||||
wagon,
|
||||
company,
|
||||
@@ -30,6 +54,8 @@ export const WagonCard = ({
|
||||
scheduleStatus,
|
||||
onRemoveBooking,
|
||||
onRemoveWagon,
|
||||
wagons,
|
||||
onMoveContainer,
|
||||
}: WagonCardProps) => {
|
||||
const isDispatched = scheduleStatus === "DISPATCHED";
|
||||
const allocation = wagon.allocations?.[0];
|
||||
@@ -108,20 +134,67 @@ export const WagonCard = ({
|
||||
Containers
|
||||
</Text>
|
||||
<Stack gap={6}>
|
||||
{allocation.containerItems.map((item, idx) => (
|
||||
<Group key={item.id} gap={8} wrap="nowrap">
|
||||
<ContainerIcon size={13} color="var(--mantine-color-cyan-7)" />
|
||||
<Text size="xs" c="dimmed">
|
||||
#{idx + 1}
|
||||
</Text>
|
||||
<ContainerNumberInput
|
||||
value={item.containerNumber ?? null}
|
||||
itemId={item.id}
|
||||
scheduleId={scheduleId}
|
||||
disabled={isDispatched}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
{allocation.containerItems.map((item, idx) => {
|
||||
const targets = (wagons ?? []).filter(
|
||||
(w) => w.id !== wagon.id && !isBulkWagon(w) && itemCountOf(w) < 2,
|
||||
);
|
||||
return (
|
||||
<Group key={item.id} gap={8} wrap="nowrap">
|
||||
<ContainerIcon size={13} color="var(--mantine-color-cyan-7)" />
|
||||
<Text size="xs" c="dimmed">
|
||||
#{idx + 1}
|
||||
</Text>
|
||||
<ContainerNumberInput
|
||||
value={item.containerNumber ?? null}
|
||||
itemId={item.id}
|
||||
scheduleId={scheduleId}
|
||||
disabled={isDispatched}
|
||||
/>
|
||||
{!isDispatched && onMoveContainer ? (
|
||||
<Menu shadow="md" width={220} position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<Tooltip label="Move to another wagon" withArrow>
|
||||
<ActionIcon variant="light" color="cyan" size="sm">
|
||||
<ArrowLeftRight size={13} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>Move to wagon</Menu.Label>
|
||||
{targets.length ? (
|
||||
targets.map((w) => {
|
||||
const count = itemCountOf(w);
|
||||
return (
|
||||
<Menu.Item
|
||||
key={w.id}
|
||||
onClick={() =>
|
||||
onMoveContainer({
|
||||
itemId: item.id,
|
||||
targetWagonId: w.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Group gap={6} wrap="nowrap" justify="space-between">
|
||||
<Text size="xs" fw={600}>
|
||||
#{w.sequenceNo} ·{" "}
|
||||
{w.physicalWagonNumber ?? w.wagonType?.code ?? "Wagon"}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color={count ? "cyan" : "gray"}>
|
||||
{count ? `${count}/2` : "empty"}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Menu.Item disabled>No wagon has free space</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
) : null}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
@@ -280,3 +280,135 @@ export function RouteCorridor({
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Minimal booking shape the occupancy strip needs from TrainScheduleDetail. */
|
||||
export type SegmentStripBooking = {
|
||||
originYardId?: string | null;
|
||||
destinationYardId?: string | null;
|
||||
tradeDirection?: string | null;
|
||||
wagonsRequired?: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-segment wagon occupancy along the corridor: which legs are full and
|
||||
* which still run empty. Through cargo (unknown/off-route yards) occupies the
|
||||
* whole corridor; a ride-along counts only on its own leg — this is what makes
|
||||
* "export full Adama→Doraleh, intercity riding Gelan→Adama" legible at a
|
||||
* glance instead of two disconnected booking lists.
|
||||
*/
|
||||
export function SegmentOccupancyStrip({
|
||||
stops,
|
||||
bookings,
|
||||
maxWagons,
|
||||
}: {
|
||||
stops: Array<{ yardId: string; label: string }>;
|
||||
bookings: SegmentStripBooking[];
|
||||
maxWagons?: number | null;
|
||||
}) {
|
||||
if (stops.length < 2) return null;
|
||||
const lastIdx = stops.length - 1;
|
||||
const indexOf = new Map(stops.map((s, i) => [s.yardId, i]));
|
||||
|
||||
const segments = stops.slice(0, -1).map((stop, edge) => {
|
||||
let cargo = 0;
|
||||
let intercity = 0;
|
||||
for (const b of bookings) {
|
||||
const from = (b.originYardId ? indexOf.get(b.originYardId) : undefined) ?? 0;
|
||||
const to =
|
||||
(b.destinationYardId ? indexOf.get(b.destinationYardId) : undefined) ??
|
||||
lastIdx;
|
||||
const rides = from <= edge && edge < (to > from ? to : lastIdx);
|
||||
if (!rides) continue;
|
||||
const wagons = Number(b.wagonsRequired) || 1;
|
||||
if (b.tradeDirection === "DOMESTIC") intercity += wagons;
|
||||
else cargo += wagons;
|
||||
}
|
||||
return { from: stop, to: stops[edge + 1], cargo, intercity };
|
||||
});
|
||||
|
||||
const cap = Number(maxWagons) || null;
|
||||
|
||||
return (
|
||||
<Group gap={0} wrap="nowrap" align="stretch" style={{ overflowX: "auto", paddingBottom: 4 }}>
|
||||
{segments.map((seg, i) => {
|
||||
const used = seg.cargo + seg.intercity;
|
||||
const pct = cap ? Math.min(100, Math.round((used / cap) * 100)) : null;
|
||||
const full = cap != null && used >= cap;
|
||||
return (
|
||||
<Group key={seg.from.yardId} gap={0} wrap="nowrap" align="stretch">
|
||||
<Stack gap={2} align="center" justify="flex-end" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
w={9}
|
||||
h={9}
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
border: `2px solid ${freightBrand.primary}`,
|
||||
background: i === 0 ? "white" : freightBrand.primary,
|
||||
}}
|
||||
/>
|
||||
<Text size="xs" fw={600} style={{ whiteSpace: "nowrap" }}>
|
||||
{seg.from.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={3} px={10} pb={16} justify="flex-end" style={{ minWidth: 130 }}>
|
||||
<Text size="xs" ta="center" fw={600} c={full ? "orange.8" : "dimmed"}>
|
||||
{used}
|
||||
{cap ? `/${cap}` : ""} wagons
|
||||
{full ? " · full" : ""}
|
||||
</Text>
|
||||
<Box
|
||||
style={{
|
||||
height: 6,
|
||||
borderRadius: 999,
|
||||
background: "var(--mantine-color-gray-2)",
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
{cap ? (
|
||||
<>
|
||||
<Box
|
||||
style={{
|
||||
width: `${Math.min(100, (seg.cargo / cap) * 100)}%`,
|
||||
background: freightBrand.primary,
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
style={{
|
||||
width: `${Math.min(100, (seg.intercity / cap) * 100)}%`,
|
||||
background: "var(--mantine-color-indigo-6)",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Box style={{ width: pct ? `${pct}%` : 0 }} />
|
||||
)}
|
||||
</Box>
|
||||
<Text size="xs" ta="center" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{seg.cargo} cargo
|
||||
{seg.intercity > 0 ? (
|
||||
<Text span size="xs" fw={700} c="indigo.7">
|
||||
{" "}
|
||||
· {seg.intercity} intercity
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
</Stack>
|
||||
{i === segments.length - 1 ? (
|
||||
<Stack gap={2} align="center" justify="flex-end" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
w={9}
|
||||
h={9}
|
||||
style={{ borderRadius: 999, background: freightBrand.primary }}
|
||||
/>
|
||||
<Text size="xs" fw={600} style={{ whiteSpace: "nowrap" }}>
|
||||
{seg.to.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user