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:
Marshal
2026-08-28 07:33:28 +00:00
parent 8b8870e85e
commit ba56974e32
26 changed files with 1435 additions and 583 deletions

View File

@@ -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>
);
}