Files
edr-platform/apps/edr-freight-web/backoffice/src/components/trainScheduling/IntercityRideAlongPanel.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

414 lines
14 KiB
TypeScript

import { useState } from "react";
import {
Alert,
Badge,
Button,
Checkbox,
Group,
Loader,
Paper,
Stack,
Table,
Text,
Tooltip,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle, ArrowRight, PackageCheck, PackageOpen, TrainFront } from "lucide-react";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type {
IntercityBookingRow,
IntercityCapacity,
} 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;
};
// A capacity axis can be null when the schedule's locomotive has no limit
// configured for it — render "—" instead of crashing on toFixed.
function fmt(n: number | null | undefined): string {
if (n == null) return "—";
return Number.isInteger(n) ? String(n) : n.toFixed(1);
}
function CapacityBadges({ capacity }: { capacity: IntercityCapacity | null }) {
if (!capacity) {
return (
<Text size="sm" c="dimmed">
Capacity unknown schedule has no locomotive/train set yet.
</Text>
);
}
return (
<Group gap="xs">
<Badge
variant="light"
color={capacity.wagons == null ? "gray" : capacity.wagons > 0 ? "teal" : "red"}
>
{fmt(capacity.wagons)} wagons free
</Badge>
<Badge
variant="light"
color={capacity.weightTons == null ? "gray" : capacity.weightTons > 0 ? "teal" : "red"}
>
{fmt(capacity.weightTons)} t free
</Badge>
<Badge
variant="light"
color={capacity.lengthMeters == null ? "gray" : capacity.lengthMeters > 0 ? "teal" : "red"}
>
{fmt(capacity.lengthMeters)} m free
</Badge>
</Group>
);
}
function NeedCells({ need }: { need: IntercityCapacity | null }) {
if (!need) return <Table.Td colSpan={3}></Table.Td>;
return (
<>
<Table.Td>{fmt(need.wagons)}</Table.Td>
<Table.Td>{fmt(need.weightTons)} t</Table.Td>
<Table.Td>{fmt(need.lengthMeters)} m</Table.Td>
</>
);
}
function CorridorCell({ row }: { row: IntercityBookingRow }) {
return (
<Group gap={6} wrap="nowrap">
<Text size="sm">{row.origin}</Text>
<ArrowRight size={13} />
<Text size="sm">{row.destination}</Text>
</Group>
);
}
/**
* Intercity ride-along desk for one import/export schedule: waiting intercity
* bookings whose corridor lies on this train's route, checked against the
* remaining wagon/weight/length budget. Accepting opens the customer's pay
* window; after payment the booking is allocated. Loading/unloading is
* 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,
}: {
scheduleId: string;
direction: string | null | undefined;
}) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [selected, setSelected] = useState<string[]>([]);
const candidatesQuery = useQuery(
api.trainScheduling.intercityCandidates.queryOptions({
input: { scheduleId },
refetchInterval: 60_000,
}),
);
// 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({
onSuccess: (result) => {
setSelected([]);
void invalidate();
if (result.accepted.length > 0) {
toast({
title: `${result.accepted.length} intercity booking(s) accepted`,
description: "Customers have been asked to pay.",
});
}
for (const r of result.rejected) {
toast({
title: "Booking skipped",
description: r.reason,
variant: "destructive",
});
}
},
onError: (err) =>
toast({
title: "Accept failed",
description: parseError(err, "Could not accept intercity bookings"),
variant: "destructive",
}),
}),
);
const load = useMutation(
api.trainScheduling.loadIntercityBooking.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.unloadIntercityBooking.mutationOptions({
onSuccess: () => {
void invalidate();
toast({ title: "Cargo unloaded — booking completed" });
},
onError: (err) =>
toast({
title: "Unload failed",
description: parseError(err, "Could not confirm unloading"),
variant: "destructive",
}),
}),
);
// Intercity bookings only ride import/export trains.
if (direction !== "IMPORT" && direction !== "EXPORT") return null;
const data = candidatesQuery.data;
const candidates = data?.candidates ?? [];
const accepted = data?.accepted ?? [];
if (candidatesQuery.isLoading) {
return (
<Paper withBorder radius="lg" p="lg" mt="md">
<Group gap="xs">
<Loader size="xs" />
<Text size="sm" c="dimmed">
Loading intercity ride-along bookings
</Text>
</Group>
</Paper>
);
}
if (candidates.length === 0 && accepted.length === 0) return null;
return (
<Paper withBorder radius="lg" p="lg" mt="md">
<Stack gap="md">
<Group justify="space-between" wrap="wrap">
<Group gap="xs">
<TrainFront size={18} />
<Text fw={700}>Intercity ride-along</Text>
</Group>
<CapacityBadges capacity={data?.remaining ?? null} />
</Group>
{candidates.length > 0 && (
<>
<Text size="sm" c="dimmed">
Waiting intercity bookings whose corridor lies on this train's
route. Accepting opens the customer's payment window against the
free capacity above.
</Text>
<Table.ScrollContainer minWidth={720}>
<Table verticalSpacing="xs" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th w={36} />
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Corridor</Table.Th>
<Table.Th>Wagons</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Length</Table.Th>
<Table.Th>Fits</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{candidates.map((row) => (
<Table.Tr key={row.id}>
<Table.Td>
<Checkbox
size="xs"
checked={selected.includes(row.id)}
onChange={(e) =>
setSelected((prev) =>
e.currentTarget.checked
? [...prev, row.id]
: prev.filter((id) => id !== row.id),
)
}
/>
</Table.Td>
<Table.Td>
<Group gap={6}>
<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>
</Table.Td>
<Table.Td>
<Text size="sm">{row.customer}</Text>
</Table.Td>
<Table.Td>
<CorridorCell row={row} />
</Table.Td>
<NeedCells need={row.need} />
<Table.Td>
{row.fits ? (
<Badge size="sm" variant="light" color="teal">
Fits
</Badge>
) : (
<Tooltip label="Exceeds the remaining wagon/weight/length budget">
<Badge size="sm" variant="light" color="red">
No room
</Badge>
</Tooltip>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<Group justify="flex-end">
<Button
size="xs"
color="edr-green"
loading={accept.isPending}
disabled={selected.length === 0}
onClick={() => accept.mutate({ scheduleId, bookingIds: selected })}
>
Accept {selected.length > 0 ? `${selected.length} ` : ""}onto this train
</Button>
</Group>
</>
)}
{accepted.length > 0 && (
<>
<Text size="sm" fw={600}>
On this train
</Text>
<Table.ScrollContainer minWidth={680}>
<Table verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Corridor</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{accepted.map((row) => (
<Table.Tr key={row.id}>
<Table.Td>
<Text size="sm" fw={600}>
{row.reference ?? row.id.slice(0, 8)}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{row.customer}</Text>
</Table.Td>
<Table.Td>
<CorridorCell row={row} />
</Table.Td>
<Table.Td>
<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>
<Group gap="xs" justify="flex-end">
{row.status === "PAID" && (
<Tooltip label="Train must be at the booking's origin yard">
<Button
size="compact-xs"
variant="light"
leftSection={<PackageCheck size={13} />}
loading={load.isPending}
onClick={() =>
load.mutate({ scheduleId, bookingId: row.id })
}
>
Load
</Button>
</Tooltip>
)}
{row.status === "IN_TRANSIT" && (
<Tooltip label="Train must be at the booking's destination yard">
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<PackageOpen size={13} />}
loading={unload.isPending}
onClick={() =>
unload.mutate({ scheduleId, bookingId: row.id })
}
>
Unload
</Button>
</Tooltip>
)}
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</>
)}
{candidatesQuery.isError && (
<Alert color="red" variant="light" icon={<AlertCircle size={16} />}>
{parseError(candidatesQuery.error, "Could not load intercity candidates")}
</Alert>
)}
</Stack>
</Paper>
);
}