mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
- 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.
343 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|