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

@@ -1,6 +1,15 @@
import { Badge, Group, Pagination, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeftRight, History, MapPin, Minus, Plus, TrainFront, User } from "lucide-react";
import {
ArrowLeftRight,
History,
MapPin,
MessageSquare,
Minus,
Plus,
TrainFront,
User,
} from "lucide-react";
import { useState } from "react";
import { api } from "@/services/api";
@@ -49,7 +58,8 @@ export default function TrainHistoryPanel({ trainId }: { trainId: string }) {
</Text>
<Text size="sm" c="dimmed">
Who attached, detached or switched which wagon on this train from
the builder and from its trips newest first.
the builder and from its trips newest first, with the reason
given for detaching off a scheduled run.
</Text>
</Stack>
</Group>
@@ -120,6 +130,17 @@ export default function TrainHistoryPanel({ trainId }: { trainId: string }) {
</Group>
) : null}
</Group>
{entry.reason ? (
<Group gap={4} wrap="nowrap" align="flex-start" mt={4}>
<MessageSquare
size={12}
style={{ flexShrink: 0, marginTop: 3 }}
/>
<Text size="xs" c="dimmed" style={{ fontStyle: "italic" }}>
{entry.reason}
</Text>
</Group>
) : null}
</Timeline.Item>
);
})}

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

View File

@@ -491,6 +491,13 @@ export const URL_CONSTANTS = {
`/train-scheduling/schedules/${id}/bookings/${bookingId}/load`,
BOOKING_UNLOAD: (id: string, bookingId: string) =>
`/train-scheduling/schedules/${id}/bookings/${bookingId}/unload`,
BOOKING_WAGON_LOAD: (id: string, bookingId: string, allocationId: string) =>
`/train-scheduling/schedules/${id}/bookings/${bookingId}/wagons/${allocationId}/load`,
BOOKING_WAGON_UNLOAD: (id: string, bookingId: string, allocationId: string) =>
`/train-scheduling/schedules/${id}/bookings/${bookingId}/wagons/${allocationId}/unload`,
BOOKING_WAGONS: (bookingId: string) => `/bookings/${bookingId}/wagons`,
CANCEL_REMAINING_WAGONS: (bookingId: string) =>
`/bookings/${bookingId}/wagon-cancellations/at-loading`,
INTERCITY_BOOKINGS: "/train-scheduling/intercity/bookings",
INTERCITY_CANDIDATES: (id: string) =>
`/train-scheduling/schedules/${id}/intercity-candidates`,

View File

@@ -90,17 +90,8 @@ export default function TrainBuilderDetailPage() {
const [yardModalOpen, setYardModalOpen] = useState(false);
const [disbandOpen, setDisbandOpen] = useState(false);
const [deactivateOpen, setDeactivateOpen] = useState(false);
const [maintenanceTarget, setMaintenanceTarget] =
useState<TrainCompositionWagon | null>(null);
const [maintenanceNote, setMaintenanceNote] = useState("");
// Clearing the note with the target stops one wagon's reason being carried
// over onto the next wagon sent to maintenance.
const closeMaintenance = () => {
setMaintenanceTarget(null);
setMaintenanceNote("");
};
// Detach-approval flow: on a SCHEDULED run, detach/maintenance is filed as a
// request (with reason) and executed by a second staffer's approval.
// Every detach / maintenance move asks for a reason first — it is recorded
// as an auto-approved audit row and on the train's wagon history.
const [requestTarget, setRequestTarget] = useState<{
wagon: TrainCompositionWagon;
action: "DETACH" | "MAINTENANCE";
@@ -110,15 +101,8 @@ export default function TrainBuilderDetailPage() {
setRequestTarget(null);
setRequestReason("");
};
const [rejectTarget, setRejectTarget] = useState<WagonDetachRequestRow | null>(null);
const [rejectNote, setRejectNote] = useState("");
const closeReject = () => {
setRejectTarget(null);
setRejectNote("");
};
const { user } = useAuth();
const canAssign = hasPermission(user, FREIGHT_PERMS.trains.assignWagons);
const canApproveDetach = hasPermission(user, FREIGHT_PERMS.trains.approveWagonDetach);
const canChangeLocomotives = hasPermission(user, FREIGHT_PERMS.trains.changeLocomotives);
const canChangeYard = hasPermission(user, FREIGHT_PERMS.trains.changeYard);
const canChangeWagonYard = hasPermission(user, FREIGHT_PERMS.trains.changeWagonYard);
@@ -149,35 +133,17 @@ export default function TrainBuilderDetailPage() {
enabled: Boolean(id),
}),
);
const createDetachRequest = useMutation(
api.trainBuilder.createDetachRequest.mutationOptions(),
);
const approveDetachRequest = useMutation(
api.trainBuilder.approveDetachRequest.mutationOptions(),
);
const rejectDetachRequest = useMutation(
api.trainBuilder.rejectDetachRequest.mutationOptions(),
);
const disband = useMutation(api.trainBuilder.disband.mutationOptions());
const deactivate = useMutation(api.trainBuilder.deactivate.mutationOptions());
const activate = useMutation(api.trainBuilder.activate.mutationOptions());
const composition = compositionQuery.data;
// Approval kicks in once a run is SCHEDULED. DRAFT stays direct-edit; a
// dispatched train is frozen outright (composition.editable is false).
const requiresDetachApproval = (composition?.activeSchedules ?? []).some(
(s) => s.status === "SCHEDULED",
);
const detachRequests = useMemo(
() => detachRequestsQuery.data ?? [],
[detachRequestsQuery.data],
);
const pendingDetachRequests = detachRequests.filter((r) => r.status === "PENDING");
const pendingWagonIds = useMemo(
() => new Set(detachRequests.filter((r) => r.status === "PENDING").map((r) => r.wagonId)),
[detachRequests],
);
// The diagram memoizes off its `locomotives`/`wagons` props; building those
// arrays inline in JSX would hand it a new identity on every render and
@@ -270,32 +236,19 @@ export default function TrainBuilderDetailPage() {
[withToast, reorderWagons.mutateAsync, trainId],
);
const wagons = composition?.wagons;
const openDetachRequest = useCallback(
const openDetachReason = useCallback(
(wagonId: string, action: "DETACH" | "MAINTENANCE") => {
if (pendingWagonIds.has(wagonId)) {
toast({
title: "A detach request for this wagon is already pending approval",
});
return;
}
const wagon = wagons?.find((w) => w.id === wagonId);
if (wagon) setRequestTarget({ wagon, action });
},
[pendingWagonIds, wagons, toast],
[wagons],
);
const handleRemove = useCallback(
(wagonId: string) => {
if (!trainId) return;
if (requiresDetachApproval) {
openDetachRequest(wagonId, "DETACH");
return;
}
void withToast(
() => removeWagon.mutateAsync({ id: trainId, wagonId }),
"Could not detach wagon",
);
openDetachReason(wagonId, "DETACH");
},
[withToast, removeWagon.mutateAsync, trainId, requiresDetachApproval, openDetachRequest],
[trainId, openDetachReason],
);
const handleChangeWagonYard = useCallback(
(wagonId: string, currentYardId: string) => {
@@ -319,13 +272,9 @@ export default function TrainBuilderDetailPage() {
);
const handleMaintenance = useCallback(
(wagon: TrainCompositionWagon) => {
if (requiresDetachApproval) {
openDetachRequest(wagon.id, "MAINTENANCE");
return;
}
setMaintenanceTarget(wagon);
openDetachReason(wagon.id, "MAINTENANCE");
},
[requiresDetachApproval, openDetachRequest],
[openDetachReason],
);
if (compositionQuery.isLoading) {
@@ -502,9 +451,11 @@ export default function TrainBuilderDetailPage() {
</Alert>
) : null}
{!composition.editable ? (
<Alert color="yellow" icon={<AlertTriangle size={16} />}>
This train is out on a dispatched run its composition is frozen until arrival.
{(composition.activeSchedules ?? []).some((s) => s.status === "DISPATCHED") ? (
<Alert color="blue" icon={<AlertTriangle size={16} />}>
This train is out on a dispatched run. You can still edit its composition
the dispatched run keeps the wagon plan it departed with, and your changes
apply to scheduled (not yet departed) runs only.
</Alert>
) : null}
@@ -563,7 +514,7 @@ export default function TrainBuilderDetailPage() {
))}
</Group>
{detachRequests.length ? (
{/* {detachRequests.length ? (
<Card>
<Stack gap="sm">
<Group justify="space-between">
@@ -576,8 +527,8 @@ export default function TrainBuilderDetailPage() {
</Group>
<Text size="xs" c="dimmed">
While this train is on a scheduled run, detaching a wagon (or sending it to
maintenance) needs a second staff member's approval. Decided requests stay
here as the audit trail.
maintenance) requires a reason — recorded here as the audit trail of who
did it and why.
</Text>
{detachRequests.map((req) => {
const isOwn = Boolean(req.requestedById && user?.id === req.requestedById);
@@ -622,49 +573,9 @@ export default function TrainBuilderDetailPage() {
</Text>
) : null}
</Stack>
{req.status === "PENDING" && canApproveDetach ? (
<Group gap="xs" wrap="nowrap">
<Tooltip
label="You filed this request — a different staff member must approve it"
disabled={!isOwn}
withArrow
>
<Button
size="compact-sm"
color="green"
disabled={isOwn}
loading={approveDetachRequest.isPending}
onClick={() =>
void withToast(async () => {
await approveDetachRequest.mutateAsync({
id: composition.id,
requestId: req.id,
});
toast({
title: `Wagon ${req.wagonNumber} ${
req.action === "MAINTENANCE"
? "sent to maintenance"
: "detached"
}`,
});
}, "Could not approve request")
}
>
Approve
</Button>
</Tooltip>
<Button
size="compact-sm"
variant="light"
color="red"
onClick={() => setRejectTarget(req)}
>
Reject
</Button>
</Group>
) : req.status === "PENDING" ? (
{req.status === "PENDING" ? (
<Text size="xs" c="dimmed">
Awaiting approval
Legacy request — approval flow removed
</Text>
) : null}
</Group>
@@ -672,7 +583,7 @@ export default function TrainBuilderDetailPage() {
})}
</Stack>
</Card>
) : null}
) : null} */}
<Stack gap="sm">
<TrainCompositionDiagram
@@ -811,71 +722,14 @@ export default function TrainBuilderDetailPage() {
onClose={() => setYardModalOpen(false)}
/>
<Modal
opened={Boolean(maintenanceTarget)}
onClose={closeMaintenance}
title={<Text fw={600}>Send wagon to maintenance?</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Wagon{" "}
<Text span fw={700} ff="monospace" c="dark">
{maintenanceTarget?.wagonNumber}
</Text>{" "}
is detached from train{" "}
<Text span fw={700} c="dark">
{trainRunLabel}
</Text>{" "}
and set to MAINTENANCE it stays out of the available pool until it
clears. The detach is stamped with the time and this train's run
numbers in the wagon's history.
</Text>
<Textarea
label="Note"
placeholder="Optional note (e.g. reason for maintenance)"
value={maintenanceNote}
onChange={(e) => setMaintenanceNote(e.currentTarget.value)}
autosize
minRows={2}
/>
<Group justify="flex-end">
<Button variant="default" onClick={closeMaintenance}>
Keep in consist
</Button>
<Button
color="orange"
leftSection={<Wrench size={16} />}
loading={maintenanceWagon.isPending}
onClick={() =>
void withToast(async () => {
await maintenanceWagon.mutateAsync({
id: composition.id,
wagonId: maintenanceTarget!.id,
note: maintenanceNote.trim() || undefined,
});
toast({
title: `Wagon ${maintenanceTarget!.wagonNumber} sent to maintenance`,
});
closeMaintenance();
}, "Could not send wagon to maintenance")
}
>
Send to maintenance
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={Boolean(requestTarget)}
onClose={closeRequest}
title={
<Text fw={600}>
{requestTarget?.action === "MAINTENANCE"
? "Request maintenance approval?"
: "Request detach approval?"}
? "Send wagon to maintenance?"
: "Detach wagon?"}
</Text>
}
radius="lg"
@@ -883,22 +737,28 @@ export default function TrainBuilderDetailPage() {
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Train{" "}
<Text span fw={700} c="dark">
{trainRunLabel}
</Text>{" "}
is on a scheduled run, so wagon{" "}
Wagon{" "}
<Text span fw={700} ff="monospace" c="dark">
{requestTarget?.wagon.wagonNumber}
</Text>{" "}
is not detached now your request goes to a staff member with approval
rights, and the{" "}
{requestTarget?.action === "MAINTENANCE" ? "maintenance move" : "detach"}{" "}
happens the moment they approve it.
{requestTarget?.action === "MAINTENANCE"
? "leaves train "
: "is detached from train "}
<Text span fw={700} c="dark">
{trainRunLabel}
</Text>{" "}
{requestTarget?.action === "MAINTENANCE"
? "and is set to MAINTENANCE — it stays out of the available pool until it clears."
: "immediately."}{" "}
The reason is required and shows in this train&apos;s History tab.
</Text>
<Textarea
label="Reason"
placeholder="Why must this wagon leave the scheduled consist? (required)"
placeholder={
requestTarget?.action === "MAINTENANCE"
? "Why is this wagon going to maintenance? (required)"
: "Why is this wagon leaving the consist? (required)"
}
value={requestReason}
onChange={(e) => setRequestReason(e.currentTarget.value)}
autosize
@@ -919,73 +779,36 @@ export default function TrainBuilderDetailPage() {
)
}
disabled={!requestReason.trim()}
loading={createDetachRequest.isPending}
loading={removeWagon.isPending || maintenanceWagon.isPending}
onClick={() =>
void withToast(async () => {
await createDetachRequest.mutateAsync({
id: composition.id,
wagonId: requestTarget!.wagon.id,
action: requestTarget!.action,
reason: requestReason.trim(),
});
if (requestTarget!.action === "MAINTENANCE") {
await maintenanceWagon.mutateAsync({
id: composition.id,
wagonId: requestTarget!.wagon.id,
note: requestReason.trim(),
});
} else {
await removeWagon.mutateAsync({
id: composition.id,
wagonId: requestTarget!.wagon.id,
reason: requestReason.trim(),
});
}
toast({
title: `Request for wagon ${requestTarget!.wagon.wagonNumber} filed — awaiting approval`,
title: `Wagon ${requestTarget!.wagon.wagonNumber} ${
requestTarget!.action === "MAINTENANCE"
? "sent to maintenance"
: "detached"
}`,
});
closeRequest();
}, "Could not file the request")
}, "Could not detach the wagon")
}
>
Request approval
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={Boolean(rejectTarget)}
onClose={closeReject}
title={<Text fw={600}>Reject this request?</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Wagon{" "}
<Text span fw={700} ff="monospace" c="dark">
{rejectTarget?.wagonNumber}
</Text>{" "}
stays in the consist. The requester sees your note in the request history.
</Text>
<Textarea
label="Why is it rejected?"
placeholder="Required"
value={rejectNote}
onChange={(e) => setRejectNote(e.currentTarget.value)}
autosize
minRows={2}
required
/>
<Group justify="flex-end">
<Button variant="default" onClick={closeReject}>
Cancel
</Button>
<Button
color="red"
disabled={!rejectNote.trim()}
loading={rejectDetachRequest.isPending}
onClick={() =>
void withToast(async () => {
await rejectDetachRequest.mutateAsync({
id: composition.id,
requestId: rejectTarget!.id,
note: rejectNote.trim(),
});
toast({ title: `Request for wagon ${rejectTarget!.wagonNumber} rejected` });
closeReject();
}, "Could not reject the request")
}
>
Reject request
{requestTarget?.action === "MAINTENANCE"
? "Send to maintenance"
: "Detach wagon"}
</Button>
</Group>
</Stack>

View File

@@ -940,6 +940,52 @@ export const api = {
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
loadScheduleBookingWagon: endpoint<
{ scheduleId: string; bookingId: string; allocationId: string },
import("@/types/trainScheduling").WagonLoadResult
>(
"train-scheduling",
"booking-wagon-load",
({ scheduleId, bookingId, allocationId }) =>
trainSchedulingService.loadScheduleBookingWagon(scheduleId, bookingId, allocationId),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
unloadScheduleBookingWagon: endpoint<
{ scheduleId: string; bookingId: string; allocationId: string },
import("@/types/trainScheduling").WagonLoadResult
>(
"train-scheduling",
"booking-wagon-unload",
({ scheduleId, bookingId, allocationId }) =>
trainSchedulingService.unloadScheduleBookingWagon(scheduleId, bookingId, allocationId),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
bookingWagons: endpoint<
{ bookingId: string },
import("@/types/trainScheduling").BookingWagonRow[]
>(
"train-scheduling",
"booking-wagons",
({ bookingId }) => trainSchedulingService.bookingWagons(bookingId),
({ bookingId }) => ["train-scheduling", "booking-wagons", bookingId],
),
cancelRemainingWagons: endpoint<
{ bookingId: string; scheduleId: string; reason: string; edrFault?: boolean },
unknown
>(
"train-scheduling",
"cancel-remaining-wagons",
({ bookingId, scheduleId, reason, edrFault }) =>
trainSchedulingService.cancelRemainingWagons(bookingId, { scheduleId, reason, edrFault }),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
intercityBookings: endpoint<
void,
import("@/types/trainScheduling").IntercityRideAlongRow[]
@@ -2244,11 +2290,14 @@ export const api = {
seedComposition,
),
removeWagon: endpoint<{ id: string; wagonId: string }, TrainComposition>(
removeWagon: endpoint<
{ id: string; wagonId: string; reason?: string },
TrainComposition
>(
"train-builder",
"removeWagon",
({ id, wagonId }) =>
trainBuilderService.removeWagon(id, wagonId).then((r) => r.data),
({ id, wagonId, reason }) =>
trainBuilderService.removeWagon(id, wagonId, reason).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_WAGON_INVALIDATIONS,
seedComposition,
@@ -2275,43 +2324,6 @@ export const api = {
({ id }) => trainBuilderService.detachRequests(id).then((r) => r.data),
),
createDetachRequest: endpoint<
{ id: string; wagonId: string; action: "DETACH" | "MAINTENANCE"; reason: string },
WagonDetachRequestRow
>(
"train-builder",
"createDetachRequest",
({ id, wagonId, action, reason }) =>
trainBuilderService.createDetachRequest(id, wagonId, { action, reason }).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
),
approveDetachRequest: endpoint<
{ id: string; requestId: string; note?: string },
TrainComposition
>(
"train-builder",
"approveDetachRequest",
({ id, requestId, note }) =>
trainBuilderService.approveDetachRequest(id, requestId, note).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_WAGON_INVALIDATIONS,
seedComposition,
),
rejectDetachRequest: endpoint<
{ id: string; requestId: string; note: string },
TrainComposition
>(
"train-builder",
"rejectDetachRequest",
({ id, requestId, note }) =>
trainBuilderService.rejectDetachRequest(id, requestId, note).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
),
reorderWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
"train-builder",
"reorderWagons",

View File

@@ -311,6 +311,11 @@ export interface TrainHistoryEntry {
actor: string | null;
/** Set when the change came from a trip (schedule); null = train-builder edit. */
scheduleReference: string | null;
/**
* Why the wagon left the consist — required for a detach / maintenance move
* on a SCHEDULED run. Null for trip events and unscheduled builder edits.
*/
reason: string | null;
occurredAt: string;
}
@@ -436,37 +441,20 @@ export const trainBuilderService = {
}),
assignWagons: (id: string, wagonIds: string[]) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons`, { wagonIds }),
removeWagon: (id: string, wagonId: string) =>
apiClient.delete<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}`),
/** `reason` is required by the API while the train is on a SCHEDULED run. */
removeWagon: (id: string, wagonId: string, reason?: string) =>
apiClient.delete<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}`, {
data: reason ? { reason } : undefined,
}),
/** Detach a wagon and move it to MAINTENANCE status. */
/** `note` is the maintenance reason — recorded with the train it came off. */
sendWagonToMaintenance: (id: string, wagonId: string, note?: string) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}/maintenance`, {
note,
}),
/** Requests to detach a wagon from a SCHEDULED train, newest first. */
/** Detach/maintenance audit rows of a SCHEDULED-run train, newest first. */
detachRequests: (id: string) =>
apiClient.get<WagonDetachRequestRow[]>(`${BASE}/${id}/detach-requests`),
/** File a detach/maintenance approval request (reason required). */
createDetachRequest: (
id: string,
wagonId: string,
payload: { action: "DETACH" | "MAINTENANCE"; reason: string },
) =>
apiClient.post<WagonDetachRequestRow>(
`${BASE}/${id}/wagons/${wagonId}/detach-requests`,
payload,
),
/** Approve a pending request — executes the detach immediately. */
approveDetachRequest: (id: string, requestId: string, note?: string) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/detach-requests/${requestId}/approve`, {
note,
}),
/** Reject a pending request — a note explaining why is required. */
rejectDetachRequest: (id: string, requestId: string, note: string) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/detach-requests/${requestId}/reject`, {
note,
}),
reorderWagons: (id: string, wagonIds: string[]) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/reorder-wagons`, { wagonIds }),
/** Park the train indefinitely — only allowed with no active schedule. */

View File

@@ -11,6 +11,8 @@ import type {
BookingWindow,
AssignBookingsPayload,
BookingLoadResult,
BookingWagonRow,
WagonLoadResult,
BookingUnloadResult,
CompositionRemovalEntry,
DocReviewAlert,
@@ -510,6 +512,48 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
loadScheduleBookingWagon: async (
scheduleId: string,
bookingId: string,
allocationId: string,
): Promise<WagonLoadResult> => {
const response = await client.post<WagonLoadResult>(
URL_CONSTANTS.TRAIN_SCHEDULING.BOOKING_WAGON_LOAD(scheduleId, bookingId, allocationId),
{},
);
return unwrap(response.data);
},
unloadScheduleBookingWagon: async (
scheduleId: string,
bookingId: string,
allocationId: string,
): Promise<WagonLoadResult> => {
const response = await client.post<WagonLoadResult>(
URL_CONSTANTS.TRAIN_SCHEDULING.BOOKING_WAGON_UNLOAD(scheduleId, bookingId, allocationId),
{},
);
return unwrap(response.data);
},
bookingWagons: async (bookingId: string): Promise<BookingWagonRow[]> => {
const response = await client.get<BookingWagonRow[]>(
URL_CONSTANTS.TRAIN_SCHEDULING.BOOKING_WAGONS(bookingId),
);
return unwrap(response.data);
},
cancelRemainingWagons: async (
bookingId: string,
payload: { scheduleId: string; reason: string; edrFault?: boolean },
): Promise<unknown> => {
const response = await client.post(
URL_CONSTANTS.TRAIN_SCHEDULING.CANCEL_REMAINING_WAGONS(bookingId),
payload,
);
return unwrap(response.data);
},
listIntercityBookings: async (): Promise<
import("@/types/trainScheduling").IntercityRideAlongRow[]
> => {

View File

@@ -1206,6 +1206,34 @@ export interface BookingLoadResult {
loadedAt: string;
}
/** Per-wagon load/unload confirmation; `completed` = the booking finished with it. */
export interface WagonLoadResult {
bookingId: string;
allocationId: string;
status: string;
loadedWagons?: number;
unloadedWagons?: number;
totalWagons: number;
completed: boolean;
}
/** One allocated wagon of a booking, from GET /bookings/:id/wagons. */
export interface BookingWagonRow {
allocationId: string;
sequenceNo: number | null;
wagonNumber: string | null;
wagonType: string | null;
wagonTypeCode: string | null;
allocatedWeightTons: number | string | null;
loadType: string | null;
status: string;
containers: Array<{
containerNumber: string | null;
sizeFt: number | null;
grossWeightTons: number | string | null;
}>;
}
export interface BookingUnloadResult {
bookingId: string;
/** 'ARRIVED' for import/export, 'COMPLETED' for intercity. */