Files
edr-platform/apps/edr-freight-web/backoffice/src/components/trainScheduling/YardWorkPanel.tsx
Marshal 835c9e111c 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.
2026-07-21 23:02:06 +00:00

343 lines
11 KiB
TypeScript

import {
Alert,
Badge,
Button,
Divider,
Group,
Loader,
Paper,
Stack,
Table,
Text,
Tooltip,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle, MapPin, PackageCheck, PackageOpen, TrainFront } from "lucide-react";
import { Freight } from "@edr/types";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import type { YardWorkBookingRow, YardWorkYard } from "@/types/trainScheduling";
const parseError = (error: unknown, fallback: string) => {
const message = (error as { response?: { data?: { message?: string | string[] } } })
?.response?.data?.message;
if (Array.isArray(message)) return message.join("; ");
return message || (error as Error)?.message || fallback;
};
const fmtDate = (iso: string) => {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
};
const DIRECTION_COLORS: Record<string, string> = {
IMPORT: "blue",
EXPORT: "teal",
DOMESTIC: "violet",
};
/** DOMESTIC displays as "Intercity" — shared with the rest of the platform. */
const DIRECTION_LABELS: Record<string, string> = Freight.TRADE_DIRECTION_LABELS;
function DirectionChip({ direction }: { direction: string }) {
return (
<Badge size="sm" variant="light" color={DIRECTION_COLORS[direction] ?? "gray"}>
{DIRECTION_LABELS[direction] ?? direction}
</Badge>
);
}
function BookingCell({ row }: { row: YardWorkBookingRow }) {
return (
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600}>
{row.reference ?? row.id.slice(0, 8)}
</Text>
{row.isGovernment && (
<Badge size="xs" variant="light" color="grape">
GOV
</Badge>
)}
</Group>
);
}
function WorkTable({
rows,
side,
trainHere,
onLoad,
onUnload,
pendingBookingId,
}: {
rows: YardWorkBookingRow[];
side: "load" | "unload";
trainHere: boolean;
onLoad: (bookingId: string) => void;
onUnload: (bookingId: string) => void;
pendingBookingId: string | null;
}) {
if (rows.length === 0) {
return (
<Text size="sm" c="dimmed">
{side === "load" ? "No bookings board here." : "No bookings alight here."}
</Text>
);
}
return (
<Table.ScrollContainer minWidth={720}>
<Table verticalSpacing="xs" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Direction</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>{side === "load" ? "Loaded" : "Arrived"}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((row) => {
const timestamp = side === "load" ? row.loadedAt : row.arrivedAt;
const canAct = side === "load" ? row.canLoad : row.canUnload;
return (
<Table.Tr key={row.id}>
<Table.Td>
<BookingCell row={row} />
</Table.Td>
<Table.Td>
<Text size="sm">{row.customer}</Text>
</Table.Td>
<Table.Td>
<DirectionChip direction={row.tradeDirection} />
</Table.Td>
<Table.Td>
<BookingStatusBadge status={row.status} />
</Table.Td>
<Table.Td>
{timestamp ? (
<Text size="xs" c="dimmed">
{fmtDate(timestamp)}
</Text>
) : (
<Text size="xs" c="dimmed">
</Text>
)}
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end">
{side === "load" ? (
<Tooltip
label={
trainHere
? "Confirm cargo loaded at this yard"
: "Train must be at this yard"
}
disabled={!canAct && Boolean(row.loadedAt)}
>
<Button
size="compact-xs"
variant="light"
leftSection={<PackageCheck size={13} />}
disabled={!canAct || !trainHere}
loading={pendingBookingId === row.id}
onClick={() => onLoad(row.id)}
>
Load
</Button>
</Tooltip>
) : (
<Tooltip
label={
trainHere
? "Confirm cargo unloaded at this yard"
: "Train must be at this yard"
}
disabled={!canAct && Boolean(row.arrivedAt)}
>
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<PackageOpen size={13} />}
disabled={!canAct || !trainHere}
loading={pendingBookingId === row.id}
onClick={() => onUnload(row.id)}
>
Unload
</Button>
</Tooltip>
)}
</Group>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}
/**
* Per-yard load/unload worklist for one schedule — every trade direction. Each
* booking boards at its origin yard and alights at its destination yard; the
* operator confirms both while the train's last recorded checkpoint is at that
* yard (the server validates the position). Unloading stamps the booking's own
* arrival — ARRIVED for import/export, COMPLETED for intercity.
*/
export function YardWorkPanel({ scheduleId }: { scheduleId: string }) {
const { toast } = useToast();
const queryClient = useQueryClient();
const yardWorkQuery = useQuery(
api.trainScheduling.yardWork.queryOptions({
input: { scheduleId },
refetchInterval: 60_000,
}),
);
// 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({
onSuccess: () => {
void invalidate();
toast({ title: "Cargo loaded" });
},
onError: (err) =>
toast({
title: "Load failed",
description: parseError(err, "Could not confirm loading"),
variant: "destructive",
}),
}),
);
const unload = useMutation(
api.trainScheduling.unloadScheduleBooking.mutationOptions({
onSuccess: (result) => {
void invalidate();
toast({
title:
result.status === "COMPLETED"
? "Cargo unloaded — booking completed"
: "Cargo unloaded — booking arrived",
});
},
onError: (err) =>
toast({
title: "Unload failed",
description: parseError(err, "Could not confirm unloading"),
variant: "destructive",
}),
}),
);
const data = yardWorkQuery.data;
const yards: YardWorkYard[] = data?.yards ?? [];
const trainAtYardId = data?.trainAtYardId ?? null;
const pendingLoadId = load.isPending ? (load.variables?.bookingId ?? null) : null;
const pendingUnloadId = unload.isPending ? (unload.variables?.bookingId ?? null) : null;
return (
<Paper withBorder radius="lg" p="lg" mt="md">
<Stack gap="md">
<Group gap="xs">
<MapPin size={18} />
<Text fw={700}>Yard load / unload</Text>
</Group>
{yardWorkQuery.isLoading ? (
<Group gap="xs">
<Loader size="xs" />
<Text size="sm" c="dimmed">
Loading yard worklists
</Text>
</Group>
) : yardWorkQuery.isError ? (
<Alert color="red" variant="light" icon={<AlertCircle size={16} />}>
{parseError(yardWorkQuery.error, "Could not load the yard worklist")}
</Alert>
) : yards.length === 0 ? (
<Text size="sm" c="dimmed">
No bookings are assigned to this schedule yet.
</Text>
) : (
<>
<Text size="sm" c="dimmed">
What boards and alights at each stop. Confirm loading at a booking's
origin and unloading at its destination while the train is at that
yard — unloading stamps the booking's own arrival, even before the
train's final stop.
</Text>
{yards.map((yard, index) => {
const trainHere = trainAtYardId === yard.yardId;
return (
<Stack key={yard.yardId} gap="sm">
{index > 0 && <Divider />}
<Group gap="xs">
<Text fw={600}>{yard.yard}</Text>
{trainHere && (
<Badge
size="sm"
variant="light"
color="edr-green"
leftSection={<TrainFront size={12} />}
>
Train here
</Badge>
)}
</Group>
<Stack gap={6}>
<Text size="sm" fw={600} c="dimmed">
Board here
</Text>
<WorkTable
rows={yard.toLoad}
side="load"
trainHere={trainHere}
onLoad={(bookingId) => load.mutate({ scheduleId, bookingId })}
onUnload={(bookingId) => unload.mutate({ scheduleId, bookingId })}
pendingBookingId={pendingLoadId}
/>
</Stack>
<Stack gap={6}>
<Text size="sm" fw={600} c="dimmed">
Alight here
</Text>
<WorkTable
rows={yard.toUnload}
side="unload"
trainHere={trainHere}
onLoad={(bookingId) => load.mutate({ scheduleId, bookingId })}
onUnload={(bookingId) => unload.mutate({ scheduleId, bookingId })}
pendingBookingId={pendingUnloadId}
/>
</Stack>
</Stack>
);
})}
</>
)}
</Stack>
</Paper>
);
}