mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 15:48:11 +00:00
feat(train): enhance train history and scheduling features
- Added a reason field to train history entries for detach/maintenance actions. - Updated TrainHistoryPanel to display the reason for wagon detachments. - Introduced per-wagon load/unload functionality in ScheduleWorkspacePanel with a modal for managing individual wagons. - Implemented API endpoints for loading and unloading specific wagons, including the ability to cancel remaining wagons with a reason. - Refactored detach request handling in TrainBuilderDetailPage to streamline the process and remove the approval flow, requiring a reason for detachments. - Updated types and services to support new wagon loading/unloading features and booking wagon retrieval.
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
@@ -44,6 +46,7 @@ import { api } from "@/services/api";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type {
|
||||
BookingWagonRow,
|
||||
EligibleContainerBooking,
|
||||
FreightType,
|
||||
TrainScheduleDetail,
|
||||
@@ -298,6 +301,12 @@ export function ScheduleWorkspacePanel({
|
||||
);
|
||||
|
||||
const [moveBookingId, setMoveBookingId] = useState<string | null>(null);
|
||||
// Per-wagon loading/unloading modal for one booking.
|
||||
const [wagonModal, setWagonModal] = useState<{
|
||||
bookingId: string;
|
||||
ref: string;
|
||||
phase: "load" | "unload";
|
||||
} | null>(null);
|
||||
const [moveTarget, setMoveTarget] = useState<string | null>(null);
|
||||
|
||||
// Pool → pick a same-day schedule with free wagons and place the booking there.
|
||||
@@ -887,6 +896,25 @@ export function ScheduleWorkspacePanel({
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{showLoad && boardHere ? (
|
||||
<Tooltip
|
||||
label="Load wagon by wagon — and cancel any wagon that will not ride"
|
||||
withArrow
|
||||
>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
disabled={!canLoad || !loadWindowStarted}
|
||||
onClick={() =>
|
||||
setWagonModal({ bookingId: b.id, ref, phase: "load" })
|
||||
}
|
||||
>
|
||||
Wagons
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{showTruckToTrain ? (
|
||||
<Tooltip
|
||||
label={
|
||||
@@ -950,6 +978,22 @@ export function ScheduleWorkspacePanel({
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{showUnload && alightHere ? (
|
||||
<Tooltip label="Unload wagon by wagon" withArrow>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="orange"
|
||||
radius="md"
|
||||
disabled={!canUnload || !unloadWindowStarted}
|
||||
onClick={() =>
|
||||
setWagonModal({ bookingId: b.id, ref, phase: "unload" })
|
||||
}
|
||||
>
|
||||
Wagons
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{canManage && !riding && !done ? (
|
||||
journey?.isGovernment ? null : (
|
||||
<Tooltip label="Remove from this train" withArrow>
|
||||
@@ -984,6 +1028,18 @@ export function ScheduleWorkspacePanel({
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
{/* Per-wagon load/unload for one booking */}
|
||||
{wagonModal ? (
|
||||
<PerWagonModal
|
||||
scheduleId={schedule.id}
|
||||
bookingId={wagonModal.bookingId}
|
||||
reference={wagonModal.ref}
|
||||
phase={wagonModal.phase}
|
||||
onClose={() => setWagonModal(null)}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Pool → same-day train assignment modal */}
|
||||
<Modal
|
||||
opened={Boolean(poolAssign)}
|
||||
@@ -1355,3 +1411,246 @@ function BookingCard({
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-wagon loading/unloading of one booking. Load phase also offers the
|
||||
* at-loading cancel of everything not yet loaded: the booking shrinks to its
|
||||
* loaded wagons (CUSTOMER fault invoices the cancellation fee to pay after;
|
||||
* EDR fault charges nothing) — required before the train may dispatch.
|
||||
*/
|
||||
function PerWagonModal({
|
||||
scheduleId,
|
||||
bookingId,
|
||||
reference,
|
||||
phase,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
scheduleId: string;
|
||||
bookingId: string;
|
||||
reference: string;
|
||||
phase: "load" | "unload";
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [cancelOpen, setCancelOpen] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const [edrFault, setEdrFault] = useState(false);
|
||||
|
||||
const wagonsQuery = useQuery(api.trainScheduling.bookingWagons.queryOptions({
|
||||
input: { bookingId },
|
||||
}));
|
||||
const wagons: BookingWagonRow[] = wagonsQuery.data ?? [];
|
||||
const isDone = (w: BookingWagonRow) =>
|
||||
phase === "load"
|
||||
? w.status === "LOADED" || w.status === "DEPARTED"
|
||||
: w.status === "DEPARTED";
|
||||
const doneCount = wagons.filter(isDone).length;
|
||||
const pending = wagons.filter((w) => !isDone(w));
|
||||
|
||||
const loadWagon = useMutation(api.trainScheduling.loadScheduleBookingWagon.mutationOptions());
|
||||
const unloadWagon = useMutation(
|
||||
api.trainScheduling.unloadScheduleBookingWagon.mutationOptions(),
|
||||
);
|
||||
const cancelRemaining = useMutation(
|
||||
api.trainScheduling.cancelRemainingWagons.mutationOptions(),
|
||||
);
|
||||
const act = phase === "load" ? loadWagon : unloadWagon;
|
||||
|
||||
const errText = (err: unknown) =>
|
||||
isAxiosError(err)
|
||||
? ((err.response?.data as { message?: string })?.message ?? err.message)
|
||||
: String(err);
|
||||
|
||||
const onWagon = (allocationId: string) => {
|
||||
act
|
||||
.mutateAsync({ scheduleId, bookingId, allocationId })
|
||||
.then((r) => {
|
||||
void wagonsQuery.refetch();
|
||||
if (r.completed) {
|
||||
toast({
|
||||
title: phase === "load" ? "Booking fully loaded" : "Booking fully unloaded",
|
||||
description: `${reference}: every wagon is ${phase === "load" ? "loaded — the booking is in transit" : "unloaded — the booking arrived"}.`,
|
||||
});
|
||||
onChanged();
|
||||
onClose();
|
||||
} else {
|
||||
onChanged();
|
||||
}
|
||||
})
|
||||
.catch((err) =>
|
||||
toast({
|
||||
title: phase === "load" ? "Wagon load failed" : "Wagon unload failed",
|
||||
description: errText(err),
|
||||
variant: "destructive",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onCancelRemaining = () => {
|
||||
cancelRemaining
|
||||
.mutateAsync({ bookingId, scheduleId, reason: reason.trim(), edrFault })
|
||||
.then(() => {
|
||||
toast({
|
||||
title: "Remaining wagons cancelled",
|
||||
description: edrFault
|
||||
? `${reference}: ${pending.length} wagon(s) cancelled at EDR's fault — no fee charged; the credit is rebookable.`
|
||||
: `${reference}: ${pending.length} wagon(s) cancelled — the cancellation fee was invoiced to the customer; the credit is rebookable.`,
|
||||
});
|
||||
onChanged();
|
||||
onClose();
|
||||
})
|
||||
.catch((err) =>
|
||||
toast({
|
||||
title: "Cancellation failed",
|
||||
description: errText(err),
|
||||
variant: "destructive",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened
|
||||
onClose={onClose}
|
||||
title={
|
||||
<Group gap={8}>
|
||||
<Train size={18} />
|
||||
<Text fw={700}>
|
||||
{phase === "load" ? "Load" : "Unload"} {reference} wagon by wagon
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
centered
|
||||
radius="lg"
|
||||
size="lg"
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Group gap={8}>
|
||||
<Badge size="sm" radius="sm" variant="light" color={doneCount ? "edr-green" : "gray"}>
|
||||
{doneCount}/{wagons.length} {phase === "load" ? "loaded" : "unloaded"}
|
||||
</Badge>
|
||||
{phase === "load" && doneCount > 0 && pending.length > 0 ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
The train cannot dispatch until the rest are loaded or cancelled.
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
{wagonsQuery.isLoading ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
Loading wagons…
|
||||
</Text>
|
||||
) : wagons.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No wagon allocations yet — use the whole-booking button instead.
|
||||
</Text>
|
||||
) : (
|
||||
wagons.map((w) => (
|
||||
<Paper key={w.allocationId} withBorder radius="md" p="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Badge size="sm" radius="sm" variant="outline" color="gray">
|
||||
{w.sequenceNo != null ? `#${w.sequenceNo}` : "—"}
|
||||
</Badge>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{w.wagonNumber ?? w.wagonType ?? "Wagon"}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{w.wagonTypeCode ?? ""}
|
||||
{w.allocatedWeightTons
|
||||
? ` · ${Number(w.allocatedWeightTons).toFixed(1)}T`
|
||||
: ""}
|
||||
{w.containers?.length ? ` · ${w.containers.length} ctr` : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
{isDone(w) ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant="filled"
|
||||
color={phase === "load" ? "edr-green" : "orange"}
|
||||
leftSection={<CheckCircle2 size={11} />}
|
||||
>
|
||||
{phase === "load" ? "Loaded" : "Unloaded"}
|
||||
</Badge>
|
||||
) : (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="filled"
|
||||
color={phase === "load" ? "edr-green" : "orange"}
|
||||
radius="md"
|
||||
leftSection={
|
||||
phase === "load" ? <PackageCheck size={13} /> : <PackageOpen size={13} />
|
||||
}
|
||||
loading={
|
||||
act.isPending && act.variables?.allocationId === w.allocationId
|
||||
}
|
||||
onClick={() => onWagon(w.allocationId)}
|
||||
>
|
||||
{phase === "load" ? "Load" : "Unload"}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
))
|
||||
)}
|
||||
|
||||
{phase === "load" && doneCount > 0 && pending.length > 0 ? (
|
||||
!cancelOpen ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<X size={14} />}
|
||||
onClick={() => setCancelOpen(true)}
|
||||
>
|
||||
Cancel the {pending.length} remaining wagon{pending.length === 1 ? "" : "s"}…
|
||||
</Button>
|
||||
) : (
|
||||
<Paper withBorder radius="md" p="sm">
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
Cancel {pending.length} unloaded wagon
|
||||
{pending.length === 1 ? "" : "s"} of {reference}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
The booking shrinks to its loaded wagons and the freed freight
|
||||
becomes a rebookable credit. Customer fault: the cancellation
|
||||
fee is invoiced, payable afterwards. EDR fault: no fee.
|
||||
</Text>
|
||||
<Textarea
|
||||
label="Reason"
|
||||
placeholder="Why are these wagons not riding?"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.currentTarget.value)}
|
||||
minRows={2}
|
||||
required
|
||||
/>
|
||||
<Checkbox
|
||||
label="EDR's fault (wagon shortage, yard problem) — charge no fee"
|
||||
checked={edrFault}
|
||||
onChange={(e) => setEdrFault(e.currentTarget.checked)}
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" radius="md" onClick={() => setCancelOpen(false)}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
radius="md"
|
||||
disabled={!reason.trim()}
|
||||
loading={cancelRemaining.isPending}
|
||||
onClick={onCancelRemaining}
|
||||
>
|
||||
Cancel wagons{edrFault ? " (no fee)" : " (fee applies)"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)
|
||||
) : null}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user