mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
Merge pull request #1434 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -43,6 +43,7 @@ import {
|
||||
Flame,
|
||||
Link2,
|
||||
MapPin,
|
||||
MoveRight,
|
||||
Package,
|
||||
Receipt,
|
||||
Repeat,
|
||||
@@ -204,6 +205,19 @@ function emptyLine(size: string): ContainerLineDraft {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Heaviest load one wagon of this contract's bulk cargo may take, as computed
|
||||
* by the API from the cargo type's allowed wagon types. Null/undefined when no
|
||||
* wagon type is configured — the wagon-count check then falls away.
|
||||
*/
|
||||
function bulkMaxTonsPerWagon(
|
||||
contract: Freight.IContract,
|
||||
): number | null | undefined {
|
||||
return contract.cargoScope?.find(
|
||||
(scope) => scope.cargoType?.maxTonsPerWagon != null,
|
||||
)?.cargoType?.maxTonsPerWagon;
|
||||
}
|
||||
|
||||
function bulkUnitOfMeasure(
|
||||
contract: Freight.IContract,
|
||||
): "PER_TON" | "PER_ITEM" | "NUMBER_OF_WAGONS" {
|
||||
@@ -1000,8 +1014,20 @@ export default function GlCreateBookingForm() {
|
||||
}
|
||||
if (bulkUom === "NUMBER_OF_WAGONS") {
|
||||
const wagons = Number(bulk.requestedWagons || 0);
|
||||
const maxPerWagon = Number(
|
||||
(contract && bulkMaxTonsPerWagon(contract)) || 0,
|
||||
);
|
||||
if (!Number.isInteger(wagons) || wagons < 1) {
|
||||
errs.wagons = "Enter the number of wagons needed (at least 1).";
|
||||
} else if (qty > 0 && maxPerWagon > 0 && qty / wagons > maxPerWagon) {
|
||||
// Too few wagons for the tonnage can never ride: 200T across 3 wagons
|
||||
// is 66.67T each on a 50T wagon. Mirrors the server's
|
||||
// assertWagonShareFits so the button blocks before the API 400s.
|
||||
errs.wagons =
|
||||
`${qty} tons across ${wagons} wagon${wagons === 1 ? "" : "s"} loads ` +
|
||||
`${Math.round((qty / wagons) * 1000) / 1000}T per wagon, but a wagon of ` +
|
||||
`this cargo carries at most ${maxPerWagon}T — request at least ` +
|
||||
`${Math.ceil(qty / maxPerWagon)} wagons.`;
|
||||
}
|
||||
}
|
||||
const h = Number(bulk.hazardousQuantity || 0);
|
||||
@@ -1017,7 +1043,7 @@ export default function GlCreateBookingForm() {
|
||||
errs.reefer = `Can't exceed the cargo quantity (${qty}).`;
|
||||
}
|
||||
return errs;
|
||||
}, [isContainer, bulk, bulkUom]);
|
||||
}, [isContainer, bulk, bulkUom, contract]);
|
||||
|
||||
const dateError =
|
||||
!isIntercity && !scheduledDate ? "Select a shipment date." : undefined;
|
||||
@@ -1479,12 +1505,12 @@ export default function GlCreateBookingForm() {
|
||||
const header = (
|
||||
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md" mb="lg">
|
||||
<Box>
|
||||
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
|
||||
{completeBookingId ? "Complete Shipment Booking" : "New Shipment Booking"}
|
||||
<Title order={1} fw={800} fz={28} style={{ letterSpacing: "-0.01em" }}>
|
||||
{completeBookingId ? "Complete shipment booking" : "New Shipment Booking"}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
{completeBookingId
|
||||
? `Clearance is finalized — enter the cargo details and shipment day to complete the booking under contract ${contract.reference}.`
|
||||
? `Clearance is finalized. Enter cargo details and the binding shipment day to complete this booking under contract ${contract.reference}.`
|
||||
: `Book a shipment on behalf of the customer for contract ${contract.reference}.`}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -1603,20 +1629,49 @@ export default function GlCreateBookingForm() {
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
) : (
|
||||
<Paper withBorder radius="md" p="md" style={{ borderColor: "#E6ECF2" }}>
|
||||
<Text fz={14} fw={600}>
|
||||
{selectedRoute?.originYard?.label ??
|
||||
selectedRoute?.originYard?.code ??
|
||||
"—"}{" "}
|
||||
→{" "}
|
||||
{selectedRoute?.destinationYard?.label ??
|
||||
selectedRoute?.destinationYard?.code ??
|
||||
"—"}
|
||||
</Text>
|
||||
<Text fz={12} c="dimmed" mt={2}>
|
||||
<Group
|
||||
wrap="nowrap"
|
||||
gap={16}
|
||||
align="center"
|
||||
px={18}
|
||||
py={18}
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: "1px solid #E6ECF2",
|
||||
background: "#FBFCFD",
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Text fz={15} fw={700} c="#10202F">
|
||||
{selectedRoute?.originYard?.label ??
|
||||
selectedRoute?.originYard?.code ??
|
||||
"—"}
|
||||
</Text>
|
||||
<Text fz={12} c="#6B7C8E" mt={2}>
|
||||
Origin yard
|
||||
</Text>
|
||||
</Box>
|
||||
<MoveRight size={20} color="#0A6F4D" style={{ flexShrink: 0 }} />
|
||||
<Box>
|
||||
<Text fz={15} fw={700} c="#10202F">
|
||||
{selectedRoute?.destinationYard?.label ??
|
||||
selectedRoute?.destinationYard?.code ??
|
||||
"—"}
|
||||
</Text>
|
||||
<Text fz={12} c="#6B7C8E" mt={2}>
|
||||
Destination yard
|
||||
</Text>
|
||||
</Box>
|
||||
<Box style={{ flex: 1 }} />
|
||||
<Badge
|
||||
variant="light"
|
||||
color="teal"
|
||||
radius={8}
|
||||
styles={{ label: { fontSize: 12, fontWeight: 600 } }}
|
||||
>
|
||||
{contract.tradeDirection}
|
||||
</Text>
|
||||
</Paper>
|
||||
</Badge>
|
||||
</Group>
|
||||
)}
|
||||
</StepCard>
|
||||
|
||||
@@ -2322,16 +2377,6 @@ export default function GlCreateBookingForm() {
|
||||
even numbers. Add one more 20ft container or remove one — book{" "}
|
||||
{ft20Total + 1} or {ft20Total - 1} instead of {ft20Total}.
|
||||
</Alert>
|
||||
) : showErrors && !formValid ? (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertCircle size={16} />}
|
||||
mb="sm"
|
||||
>
|
||||
Fix the highlighted fields before reviewing the price.
|
||||
</Alert>
|
||||
) : partnerError ? (
|
||||
// The review button is disabled while the parent booking is
|
||||
// incomplete, so the click that would reveal the errors never
|
||||
@@ -2346,7 +2391,35 @@ export default function GlCreateBookingForm() {
|
||||
{partnerError}
|
||||
</Alert>
|
||||
) : null}
|
||||
<Group justify="flex-end">
|
||||
<Group justify="space-between" wrap="nowrap" gap="md">
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
{showErrors && !formValid && !oddBlocksSubmit && (
|
||||
<>
|
||||
<AlertCircle
|
||||
size={15}
|
||||
color="#C0392B"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
<Text fz={13} fw={500} c="#C0392B">
|
||||
Fix the highlighted fields to review the price.
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
completeBookingId
|
||||
? `/dashboard/clearance/${completeBookingId}`
|
||||
: "/dashboard/contracts/clearance",
|
||||
)
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Tooltip
|
||||
label={
|
||||
oddBlocksSubmit
|
||||
@@ -2376,6 +2449,7 @@ export default function GlCreateBookingForm() {
|
||||
</Button>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -493,6 +493,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`,
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
@@ -941,6 +941,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[]
|
||||
@@ -2245,11 +2291,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,
|
||||
@@ -2276,43 +2325,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",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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[]
|
||||
> => {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user