refactor(train-scheduling): rename and restructure container movement logic

This commit is contained in:
Marshal
2026-07-21 23:49:10 +00:00
parent d25612c2f0
commit ed3c8307bb
13 changed files with 470 additions and 523 deletions

View File

@@ -15,14 +15,12 @@ import { freightBrand } from "@/theme/freight-brand";
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
type Locomotive = NonNullable<TrainScheduleDetail["trainSet"]>["locomotive"];
export interface ContainerMove {
itemId: string;
export interface WagonLoadMove {
sourceWagonId: string;
targetWagonId: string;
/** Present when the drop landed on another container — swap the two. */
swapWithItemId?: string;
}
type DragState = { itemId: string; sourceWagonId: string } | null;
type DragState = { sourceWagonId: string } | null;
interface InteractiveTrainConsistProps {
wagons: Wagon[];
@@ -33,9 +31,9 @@ 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). */
/** Wagon loads become draggable: drop on an empty wagon to move, a loaded one to swap. */
canRearrange?: boolean;
onMoveContainer?: (move: ContainerMove) => void;
onMoveLoad?: (move: WagonLoadMove) => void;
}
const wagonItems = (wagon: Wagon) =>
@@ -171,7 +169,7 @@ function WagonCar({
onSelect,
drag,
onDragChange,
onMoveContainer,
onMoveLoad,
canRearrange,
}: {
wagon: Wagon;
@@ -181,7 +179,7 @@ function WagonCar({
onSelect: () => void;
drag: DragState;
onDragChange: (drag: DragState) => void;
onMoveContainer?: (move: ContainerMove) => void;
onMoveLoad?: (move: WagonLoadMove) => void;
canRearrange: boolean;
}) {
const [dropHover, setDropHover] = useState(false);
@@ -203,11 +201,12 @@ function WagonCar({
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,
);
// The whole load drags as one unit (a 20ft pair never splits). Any OTHER
// wagon is a drop target: empty → move (a consist-only wagon repins), loaded
// → the two loads swap. The API validates wagon type + payload weight.
const draggable = canRearrange && !isEmpty;
const beingDragged = drag?.sourceWagonId === wagon.id;
const dropEligible = Boolean(drag && !beingDragged);
const endDrag = () => {
onDragChange(null);
setDropHover(false);
@@ -238,7 +237,7 @@ function WagonCar({
onDrop={(e) => {
if (dropEligible && drag) {
e.preventDefault();
onMoveContainer?.({ itemId: drag.itemId, targetWagonId: wagon.id });
onMoveLoad?.({ sourceWagonId: drag.sourceWagonId, targetWagonId: wagon.id });
}
endDrag();
}}
@@ -302,8 +301,27 @@ function WagonCar({
)}
</Group>
{/* body */}
<Box style={{ flex: 1, padding: "3px 7px", display: "flex", alignItems: "center" }}>
{/* body — the cargo area is the drag handle for the wagon's whole load */}
<Box
draggable={draggable}
onDragStart={(e) => {
e.stopPropagation();
e.dataTransfer.effectAllowed = "move";
// Firefox needs data set for the drag to start.
e.dataTransfer.setData("text/plain", wagon.id);
onDragChange({ sourceWagonId: wagon.id });
}}
onDragEnd={endDrag}
style={{
flex: 1,
padding: "3px 7px",
display: "flex",
alignItems: "center",
cursor: draggable ? "grab" : undefined,
opacity: beingDragged ? 0.35 : 1,
transition: "opacity 120ms ease",
}}
>
{isEmpty ? (
<Text size="9px" c="dimmed" ta="center" style={{ width: "100%" }}>
Available
@@ -333,83 +351,29 @@ function WagonCar({
</Stack>
) : (
<Group gap={3} justify="center" wrap="nowrap" style={{ width: "100%" }}>
{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
style={{
flex: 1,
minWidth: 0,
height: 26,
borderRadius: 4,
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",
}}
>
<Text size="8px" fw={700} c="white">
</Text>
</Box>
{(blocks.length ? blocks.map((c) => c.containerNumber?.trim() || "—") : ["—"]).map(
(cn, i) => (
<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]}`,
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>
</Box>
),
)}
</Group>
)}
@@ -563,7 +527,7 @@ export const InteractiveTrainConsist = ({
onSelectWagon,
highlightBookingId,
canRearrange = false,
onMoveContainer,
onMoveLoad,
}: InteractiveTrainConsistProps) => {
const [drag, setDrag] = useState<DragState>(null);
return (
@@ -597,7 +561,7 @@ export const InteractiveTrainConsist = ({
onSelect={() => onSelectWagon(wagon)}
drag={drag}
onDragChange={setDrag}
onMoveContainer={onMoveContainer}
onMoveLoad={onMoveLoad}
canRearrange={canRearrange}
/>
</Group>

View File

@@ -5,7 +5,7 @@ import { Hand, MousePointerClick, TrainFront } from "lucide-react";
import type { TrainScheduleDetail } from "@/types/trainScheduling";
import { TrainStatsBar } from "./TrainStatsBar";
import { WagonCard } from "./WagonCard";
import { InteractiveTrainConsist, type ContainerMove } from "./InteractiveTrainConsist";
import { InteractiveTrainConsist, type WagonLoadMove } from "./InteractiveTrainConsist";
import { RemoveBookingModal } from "./RemoveBookingModal";
import { useMutation } from "@tanstack/react-query";
import { api } from "@/services/api";
@@ -57,33 +57,34 @@ export const TrainConsistView = ({
const removeWagonMutation = useMutation(
api.trainScheduling.removeWagonSlot.mutationOptions(),
);
const moveContainerMutation = useMutation(
api.trainScheduling.moveContainerItem.mutationOptions(),
const moveLoadMutation = useMutation(
api.trainScheduling.moveWagonLoad.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;
const handleMoveLoad = async (move: WagonLoadMove) => {
if (moveLoadMutation.isPending) return;
const targetLoaded =
(wagons.find((w) => w.id === move.targetWagonId)?.allocations?.length ?? 0) > 0;
try {
await moveContainerMutation.mutateAsync({
await moveLoadMutation.mutateAsync({
scheduleId,
itemId: move.itemId,
targetTrainSetWagonId: move.targetWagonId,
swapWithItemId: move.swapWithItemId,
wagonId: move.sourceWagonId,
targetWagonId: move.targetWagonId,
});
toast({ title: move.swapWithItemId ? "Containers swapped" : "Container moved" });
toast({ title: targetLoaded ? "Wagon loads swapped" : "Load moved" });
} catch (error) {
const message = isAxiosError(error)
? ((error.response?.data as { message?: string | string[] } | undefined)?.message ?? null)
: null;
toast({
title: "Could not move container",
title: "Could not move the load",
description: Array.isArray(message)
? message.join(", ")
: (message ?? "The move was rejected — check the wagon's space and load."),
: (message ?? "The move was rejected — check the wagon's type and payload."),
variant: "destructive",
});
}
@@ -187,7 +188,7 @@ export const TrainConsistView = ({
<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
Drag a wagon's cargo onto an empty wagon to move it onto a loaded one to swap
</Text>
</Group>
) : null}
@@ -197,7 +198,7 @@ export const TrainConsistView = ({
</Group>
</Group>
<Box p="md" style={{ opacity: moveContainerMutation.isPending ? 0.6 : 1 }}>
<Box p="md" style={{ opacity: moveLoadMutation.isPending ? 0.6 : 1 }}>
<InteractiveTrainConsist
wagons={wagons}
locomotive={trainSet?.locomotive}
@@ -205,8 +206,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)}
canRearrange={canRearrange && !moveLoadMutation.isPending}
onMoveLoad={(move) => void handleMoveLoad(move)}
/>
</Box>
</Paper>
@@ -234,7 +235,7 @@ export const TrainConsistView = ({
onRemoveBooking={handleRemoveBooking}
onRemoveWagon={handleRemoveWagon}
wagons={wagons}
onMoveContainer={canRearrange ? (move) => void handleMoveContainer(move) : undefined}
onMoveLoad={canRearrange ? (move) => void handleMoveLoad(move) : undefined}
/>
</Box>
) : wagons.length ? (

View File

@@ -1,5 +1,4 @@
import {
ActionIcon,
Badge,
Box,
Button,
@@ -10,7 +9,6 @@ import {
Stack,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import {
ArrowLeftRight,
@@ -24,7 +22,7 @@ import {
} from "lucide-react";
import type { TrainScheduleDetail } from "@/types/trainScheduling";
import { ContainerNumberInput } from "./ContainerNumberInput";
import type { ContainerMove } from "./InteractiveTrainConsist";
import type { WagonLoadMove } from "./InteractiveTrainConsist";
import { freightBrand } from "@/theme/freight-brand";
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
@@ -36,13 +34,12 @@ interface WagonCardProps {
scheduleStatus?: string;
onRemoveBooking: (wagon: Wagon) => void;
onRemoveWagon: (wagonId: string) => void;
/** All wagons of the consist — targets for the per-container move menu. */
/** All wagons of the consist — targets for the move-load menu. */
wagons?: Wagon[];
onMoveContainer?: (move: ContainerMove) => void;
onMoveLoad?: (move: WagonLoadMove) => void;
}
const itemCountOf = (w: Wagon) =>
(w.allocations ?? []).reduce((sum, a) => sum + (a.containerItems?.length ?? 0), 0);
const itemAllocCount = (w: Wagon) => w.allocations?.length ?? 0;
const isBulkWagon = (w: Wagon) =>
(w.allocations ?? []).some((a) => (a.loadType ?? "").toUpperCase().includes("BULK"));
@@ -55,7 +52,7 @@ export const WagonCard = ({
onRemoveBooking,
onRemoveWagon,
wagons,
onMoveContainer,
onMoveLoad,
}: WagonCardProps) => {
const isDispatched = scheduleStatus === "DISPATCHED";
const allocation = wagon.allocations?.[0];
@@ -134,67 +131,20 @@ export const WagonCard = ({
Containers
</Text>
<Stack gap={6}>
{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>
);
})}
{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>
))}
</Stack>
</Box>
) : null}
@@ -226,16 +176,62 @@ export const WagonCard = ({
</Box>
{!isDispatched ? (
<Button
variant="light"
color="red"
size="xs"
leftSection={<X size={14} />}
onClick={() => onRemoveBooking(wagon)}
fullWidth
>
Remove booking
</Button>
<Group gap="xs" grow>
{onMoveLoad ? (
<Menu shadow="md" width={240} position="bottom" withinPortal>
<Menu.Target>
<Button
variant="light"
color="cyan"
size="xs"
leftSection={<ArrowLeftRight size={14} />}
>
Move load
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Move this wagon's load to</Menu.Label>
{(wagons ?? [])
.filter((w) => w.id !== wagon.id)
.sort((a, b) => itemAllocCount(a) - itemAllocCount(b))
.map((w) => {
const loaded = itemAllocCount(w) > 0;
return (
<Menu.Item
key={w.id}
onClick={() =>
onMoveLoad({ sourceWagonId: wagon.id, targetWagonId: w.id })
}
>
<Group gap={6} wrap="nowrap" justify="space-between">
<Text size="xs" fw={600} truncate>
#{w.sequenceNo} ·{" "}
{w.physicalWagonNumber ?? w.wagonType?.code ?? "Wagon"}
</Text>
<Badge
size="xs"
variant="light"
color={loaded ? (isBulkWagon(w) ? "orange" : "cyan") : "gray"}
>
{loaded ? "swap" : "empty"}
</Badge>
</Group>
</Menu.Item>
);
})}
</Menu.Dropdown>
</Menu>
) : null}
<Button
variant="light"
color="red"
size="xs"
leftSection={<X size={14} />}
onClick={() => onRemoveBooking(wagon)}
>
Remove booking
</Button>
</Group>
) : null}
</>
) : (