mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-06 20:35:03 +00:00
Merge pull request #1472 from Tria-plc/freight_feature/usermanagement
feat: Implement handling for partially loaded bookings in train sched…
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
@@ -24,6 +25,11 @@ import { useEffect, useState } from "react";
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import {
|
||||
PartiallyLoadedDecisionModal,
|
||||
parsePartiallyLoaded,
|
||||
type PartiallyLoadedPayload,
|
||||
} from "@/components/trainScheduling/PartiallyLoadedDecisionModal";
|
||||
import { StationWorkControls } from "@/components/trainScheduling/StationWorkControls";
|
||||
import { Chip } from "./trackPrimitives";
|
||||
import { DIRECTION_TONE, track as T } from "./trackTheme";
|
||||
@@ -98,6 +104,7 @@ export function LogPassYardWorkModal({
|
||||
onClose,
|
||||
scheduleId,
|
||||
station,
|
||||
stations,
|
||||
isFinal,
|
||||
alreadyLogged,
|
||||
}: {
|
||||
@@ -105,6 +112,8 @@ export function LogPassYardWorkModal({
|
||||
onClose: () => void;
|
||||
scheduleId: string;
|
||||
station: TrackStation | null;
|
||||
/** Whole corridor — used to find the yard the train has just left. */
|
||||
stations: TrackStation[];
|
||||
isFinal: boolean;
|
||||
/** True when opened for the current station (pass already logged). */
|
||||
alreadyLogged: boolean;
|
||||
@@ -151,6 +160,33 @@ export function LogPassYardWorkModal({
|
||||
const loadingStarted = Boolean(workLog?.loading?.startedAt);
|
||||
const unloadingStarted = Boolean(workLog?.unloading?.startedAt);
|
||||
|
||||
// The yard the train is LEAVING by logging this pass. Its boarders have had
|
||||
// their last chance to load, so this is where the leave-behind decision is
|
||||
// made. The origin (seq 0) belongs to dispatch, so there is nothing before it.
|
||||
const departedStation =
|
||||
station && station.sequenceNo > 0
|
||||
? stations.find((s) => s.sequenceNo === station.sequenceNo - 1)
|
||||
: undefined;
|
||||
const departedYard = departedStation
|
||||
? yardWorkQuery.data?.yards.find((y) => y.yardId === departedStation.yardId)
|
||||
: undefined;
|
||||
const departedPendingBoarders: YardWorkBookingRow[] = (departedYard?.toLoad ?? []).filter(
|
||||
(r) => !r.loadedAt,
|
||||
);
|
||||
// Ticked = rides on. Seeded to everyone each time the modal opens on a new
|
||||
// station, so the default is the historic "nobody is left behind".
|
||||
const [departedRidingIds, setDepartedRidingIds] = useState<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
setDepartedRidingIds(new Set(departedPendingBoarders.map((r) => r.id)));
|
||||
// Re-seed when the station changes or the rows finish loading.
|
||||
}, [station?.sequenceNo, opened, departedPendingBoarders.length]);
|
||||
const departedLeftBehind = departedPendingBoarders.filter(
|
||||
(r) => !r.isGovernment && !departedRidingIds.has(r.id),
|
||||
);
|
||||
// Set when the pass is rejected because a booking at the departed yard is
|
||||
// part-loaded — drives the EDR-fault / customer-fault decision.
|
||||
const [partialGate, setPartialGate] = useState<PartiallyLoadedPayload | null>(null);
|
||||
|
||||
const doLogPass = () => {
|
||||
if (!station) return;
|
||||
recordCheckpoint.mutate(
|
||||
@@ -159,11 +195,30 @@ export function LogPassYardWorkModal({
|
||||
payload: {
|
||||
sequenceNo: station.sequenceNo,
|
||||
...(passAt ? { occurredAt: passAt.toISOString() } : {}),
|
||||
// Logging THIS station means the train left the previous one, so the
|
||||
// cargo that boarded back there has had its last chance to load.
|
||||
// Only the ticked ones ride on; the rest are unassigned and returned
|
||||
// to the pool. Government bookings always ride — the server refuses
|
||||
// to unassign them.
|
||||
...(departedYard
|
||||
? {
|
||||
loadedBookingIds: departedPendingBoarders
|
||||
.filter((r) => r.isGovernment || departedRidingIds.has(r.id))
|
||||
.map((r) => r.id),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setJustLogged(true);
|
||||
if (departedLeftBehind.length) {
|
||||
toast({
|
||||
title: `${departedLeftBehind.length} booking${departedLeftBehind.length === 1 ? "" : "s"} left behind at ${departedYard?.yard ?? "the previous yard"}`,
|
||||
description:
|
||||
"Removed from this train — wagons freed, bookings returned to the pool for a later schedule.",
|
||||
});
|
||||
}
|
||||
toast({
|
||||
title: isFinal
|
||||
? "Train arrived — remaining bookings marked arrived, assets freed"
|
||||
@@ -178,12 +233,21 @@ export function LogPassYardWorkModal({
|
||||
});
|
||||
void yardWorkQuery.refetch();
|
||||
},
|
||||
onError: (err) =>
|
||||
onError: (err) => {
|
||||
// A part-loaded booking at the departed yard blocks the pass until
|
||||
// its never-loaded wagons are cut — offer the fault decision instead
|
||||
// of a dead-end error.
|
||||
const gate = parsePartiallyLoaded(err);
|
||||
if (gate) {
|
||||
setPartialGate(gate);
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: "Could not log checkpoint",
|
||||
description: parseError(err, "Please try again"),
|
||||
variant: "destructive",
|
||||
}),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -558,6 +622,66 @@ export function LogPassYardWorkModal({
|
||||
</>
|
||||
)}
|
||||
|
||||
{!logged && departedStation && departedPendingBoarders.length > 0 ? (
|
||||
<Stack
|
||||
gap={10}
|
||||
p={14}
|
||||
style={{
|
||||
background: T.amberDim,
|
||||
border: `1px solid ${T.amberBorder}`,
|
||||
borderRadius: 14,
|
||||
}}
|
||||
>
|
||||
<Group gap={9} align="center" wrap="nowrap">
|
||||
<PackageCheck size={16} color={T.amber} style={{ flexShrink: 0 }} />
|
||||
<Text size="13px" fw={700} c={T.amber}>
|
||||
{departedPendingBoarders.length} booking
|
||||
{departedPendingBoarders.length === 1 ? "" : "s"} not loaded at{" "}
|
||||
{departedStation.label}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="11.5px" c={T.amberText} lh={1.45}>
|
||||
Logging this pass means the train has left {departedStation.label}. Untick
|
||||
anything that never made it onto the train — it is removed and returned to the
|
||||
booking pool.
|
||||
</Text>
|
||||
{departedPendingBoarders.map((r) => (
|
||||
<Group key={r.id} justify="space-between" wrap="nowrap">
|
||||
<Checkbox
|
||||
checked={r.isGovernment || departedRidingIds.has(r.id)}
|
||||
disabled={r.isGovernment || !canLeave}
|
||||
onChange={(e) => {
|
||||
const next = new Set(departedRidingIds);
|
||||
if (e.currentTarget.checked) next.add(r.id);
|
||||
else next.delete(r.id);
|
||||
setDepartedRidingIds(next);
|
||||
}}
|
||||
label={
|
||||
<Text size="12.5px">
|
||||
{r.reference ?? r.id}
|
||||
{r.isGovernment ? (
|
||||
<Text span size="11px" c={T.muted}>
|
||||
{" "}
|
||||
· government, cannot be removed
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
<Text size="11px" c={T.muted}>
|
||||
{r.customer}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
{departedLeftBehind.length ? (
|
||||
<Text size="11.5px" fw={700} c={T.amber}>
|
||||
{departedLeftBehind.length} booking
|
||||
{departedLeftBehind.length === 1 ? "" : "s"} will be removed from this train.
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
{!logged ? (
|
||||
<DateTimePicker
|
||||
label={isFinal ? "Arrival time" : "Time at station"}
|
||||
@@ -602,6 +726,13 @@ export function LogPassYardWorkModal({
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
{/* Part-loaded gate. Cutting the wagons does NOT log the pass — the
|
||||
operator confirms the pass again once the consist is clean. */}
|
||||
<PartiallyLoadedDecisionModal
|
||||
payload={partialGate}
|
||||
onClose={() => setPartialGate(null)}
|
||||
onResolved={() => void yardWorkQuery.refetch()}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Radio,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { AlertTriangle, PackageX } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api } from "@/services/api";
|
||||
|
||||
/**
|
||||
* One partially-loaded booking, as the server reports it in the
|
||||
* PARTIALLY_LOADED_BOOKINGS error payload.
|
||||
*/
|
||||
export interface PartiallyLoadedBooking {
|
||||
bookingId: string;
|
||||
reference: string;
|
||||
loadedWagons: number;
|
||||
totalWagons: number;
|
||||
unloadedAllocationIds: string[];
|
||||
}
|
||||
|
||||
export interface PartiallyLoadedPayload {
|
||||
scheduleId: string;
|
||||
boardingYardId: string;
|
||||
yardLabel: string | null;
|
||||
action: string;
|
||||
bookings: PartiallyLoadedBooking[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the structured `partiallyLoaded` block off a rejected dispatch or
|
||||
* log-pass. Returns null for every other error so callers can fall through to
|
||||
* their normal toast.
|
||||
*/
|
||||
export function parsePartiallyLoaded(
|
||||
error: unknown,
|
||||
): PartiallyLoadedPayload | null {
|
||||
const data = (
|
||||
error as {
|
||||
response?: {
|
||||
data?: { code?: string; partiallyLoaded?: PartiallyLoadedPayload };
|
||||
};
|
||||
}
|
||||
)?.response?.data;
|
||||
if (data?.code !== "PARTIALLY_LOADED_BOOKINGS") return null;
|
||||
return data.partiallyLoaded ?? null;
|
||||
}
|
||||
|
||||
type Fault = "EDR" | "CUSTOMER";
|
||||
|
||||
/**
|
||||
* The gate a partly-loaded booking hits at dispatch or log-pass.
|
||||
*
|
||||
* A booking with some wagons loaded and some never loaded can neither ride
|
||||
* (the empty wagons would leave as ghosts) nor be left behind (unassigning
|
||||
* would strand cargo physically on the train). So the operator decides here:
|
||||
* cut the never-loaded wagons at EDR's fault (no fee, rebookable credit) or
|
||||
* the customer's (cancellation fee invoiced) — or block, and go finish loading.
|
||||
*
|
||||
* Cancelling does NOT then log the pass. The modal closes, the caller refetches,
|
||||
* and the operator clicks their action again with the gate cleared — two
|
||||
* deliberate commits rather than one compound one.
|
||||
*/
|
||||
export function PartiallyLoadedDecisionModal({
|
||||
payload,
|
||||
onClose,
|
||||
onResolved,
|
||||
}: {
|
||||
payload: PartiallyLoadedPayload | null;
|
||||
onClose: () => void;
|
||||
/** Cut succeeded — refetch, so the retry sees the cleared gate. */
|
||||
onResolved: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [fault, setFault] = useState<Fault | null>(null);
|
||||
const [reason, setReason] = useState("");
|
||||
const cancelRemaining = useMutation(
|
||||
api.trainScheduling.cancelRemainingWagons.mutationOptions(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFault(null);
|
||||
setReason("");
|
||||
}, [payload?.boardingYardId, payload?.bookings.length]);
|
||||
|
||||
if (!payload) return null;
|
||||
|
||||
const { bookings, yardLabel, action } = payload;
|
||||
const totalUnloaded = bookings.reduce(
|
||||
(n, b) => n + b.unloadedAllocationIds.length,
|
||||
0,
|
||||
);
|
||||
const where = yardLabel ? ` at ${yardLabel}` : "";
|
||||
|
||||
const submit = async () => {
|
||||
if (!fault) return;
|
||||
const edrFault = fault === "EDR";
|
||||
try {
|
||||
// Cut every partly-loaded booking's never-loaded wagons under the one
|
||||
// decision — they are all stuck behind the same gate for the same reason.
|
||||
for (const b of bookings) {
|
||||
await cancelRemaining.mutateAsync({
|
||||
bookingId: b.bookingId,
|
||||
scheduleId: payload.scheduleId,
|
||||
reason: reason.trim(),
|
||||
edrFault,
|
||||
wagonAllocationIds: b.unloadedAllocationIds,
|
||||
});
|
||||
}
|
||||
toast({
|
||||
title: `${totalUnloaded} wagon${totalUnloaded === 1 ? "" : "s"} cancelled`,
|
||||
description: edrFault
|
||||
? "EDR's fault — no fee charged; the credit is rebookable."
|
||||
: "Customer's fault — the cancellation fee was invoiced; the credit is rebookable.",
|
||||
});
|
||||
onResolved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
const message = (
|
||||
err as { response?: { data?: { message?: string | string[] } } }
|
||||
)?.response?.data?.message;
|
||||
toast({
|
||||
title: "Cancellation failed",
|
||||
description: Array.isArray(message)
|
||||
? message.join("; ")
|
||||
: message || (err as Error)?.message || "Please try again",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened
|
||||
onClose={onClose}
|
||||
centered
|
||||
radius="lg"
|
||||
size="lg"
|
||||
title={
|
||||
<Group gap={8}>
|
||||
<PackageX size={18} />
|
||||
<Text fw={700}>Partly loaded — a decision is needed</Text>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
color="orange"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertTriangle size={18} />}
|
||||
title={`Cannot ${action}${where}`}
|
||||
>
|
||||
<Text size="sm">
|
||||
A booking with some wagons loaded and some never loaded can neither
|
||||
ride nor be removed — the loaded cargo is physically on the train.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Stack gap={8}>
|
||||
{bookings.map((b) => (
|
||||
<Paper key={b.bookingId} withBorder radius="md" p="sm">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text fw={700} size="sm">
|
||||
{b.reference}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{b.loadedWagons} of {b.totalWagons} wagons loaded
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{b.unloadedAllocationIds.length} wagon
|
||||
{b.unloadedAllocationIds.length === 1 ? "" : "s"} never loaded —
|
||||
to be cancelled. The {b.loadedWagons} loaded wagon
|
||||
{b.loadedWagons === 1 ? "" : "s"} stay on the train.
|
||||
</Text>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Radio.Group
|
||||
label={`What happens to the ${totalUnloaded} never-loaded wagon${totalUnloaded === 1 ? "" : "s"}?`}
|
||||
value={fault ?? ""}
|
||||
onChange={(v) => setFault(v as Fault)}
|
||||
>
|
||||
<Stack gap={8} mt={8}>
|
||||
<Radio
|
||||
value="EDR"
|
||||
label="Cancel — EDR's fault"
|
||||
description="Wagon shortage, yard problem. No fee charged; the credit is rebookable."
|
||||
/>
|
||||
<Radio
|
||||
value="CUSTOMER"
|
||||
label="Cancel — customer's fault"
|
||||
description="Cargo not ready, no-show. The cancellation fee is invoiced; the credit is rebookable."
|
||||
/>
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
|
||||
{fault ? (
|
||||
<Textarea
|
||||
label="Reason"
|
||||
placeholder="Why are these wagons not riding?"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.currentTarget.value)}
|
||||
minRows={2}
|
||||
required
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Text size="xs" c="dimmed">
|
||||
Cancelling does not {action} — you will confirm that separately once
|
||||
the wagons are cut.
|
||||
</Text>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" radius="md" onClick={onClose}>
|
||||
Block — go finish loading
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
radius="md"
|
||||
disabled={!fault || !reason.trim()}
|
||||
loading={cancelRemaining.isPending}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
Cancel {totalUnloaded} wagon{totalUnloaded === 1 ? "" : "s"}
|
||||
{fault === "EDR"
|
||||
? " (no fee)"
|
||||
: fault === "CUSTOMER"
|
||||
? " (fee applies)"
|
||||
: ""}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -641,6 +641,7 @@ export default function TrainScheduleTrackPage() {
|
||||
onClose={() => setYardModal(null)}
|
||||
scheduleId={scheduleId}
|
||||
station={yardModal?.station ?? null}
|
||||
stations={track.stations}
|
||||
isFinal={yardModal?.isFinal ?? false}
|
||||
alreadyLogged={yardModal?.alreadyLogged ?? false}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
@@ -64,6 +65,11 @@ import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel"
|
||||
import { ScheduleWagonYardPanel } from "@/components/trainScheduling/ScheduleWagonYardPanel";
|
||||
import { LegLoadBoardPanel } from "@/components/trainScheduling/LegLoadBoardPanel";
|
||||
import { LoadEmptyContainersModal } from "@/components/trainScheduling/LoadEmptyContainersModal";
|
||||
import {
|
||||
PartiallyLoadedDecisionModal,
|
||||
parsePartiallyLoaded,
|
||||
type PartiallyLoadedPayload,
|
||||
} from "@/components/trainScheduling/PartiallyLoadedDecisionModal";
|
||||
import MergeScheduleTrainModal from "@/components/trainScheduling/MergeScheduleTrainModal";
|
||||
import ScheduleHistoryPanel from "@/components/trainScheduling/ScheduleHistoryPanel";
|
||||
import { StationWorkControls } from "@/components/trainScheduling/StationWorkControls";
|
||||
@@ -135,10 +141,12 @@ export default function TrainScheduleV2DetailPage() {
|
||||
// Actual departure — staff often dispatch on paper first and record it later,
|
||||
// so the time is picked (defaults to now when the dialog opens).
|
||||
const [dispatchAt, setDispatchAt] = useState<Date | null>(null);
|
||||
const openDispatchConfirm = () => {
|
||||
setDispatchAt(new Date());
|
||||
setDispatchConfirmOpen(true);
|
||||
};
|
||||
// Set when dispatch is rejected because a booking is part-loaded; drives the
|
||||
// EDR-fault / customer-fault decision modal.
|
||||
const [partialGate, setPartialGate] = useState<PartiallyLoadedPayload | null>(null);
|
||||
// Log-pass / arrive confirmation for the dispatched leg of the workflow.
|
||||
const [passConfirmOpen, setPassConfirmOpen] = useState(false);
|
||||
const [passAt, setPassAt] = useState<Date | null>(null);
|
||||
const [switchTarget, setSwitchTarget] = useState<EligibleContainerBooking | null>(null);
|
||||
const [visualization3DOpen, setVisualization3DOpen] = useState(false);
|
||||
const [loadEmptiesOpen, setLoadEmptiesOpen] = useState(false);
|
||||
@@ -155,6 +163,23 @@ export default function TrainScheduleV2DetailPage() {
|
||||
refetchInterval: 300_000,
|
||||
}),
|
||||
);
|
||||
// Journey state for the dispatched leg of the workflow: the corridor stops,
|
||||
// which one the train has reached, and each yard's loading/unloading windows.
|
||||
// Only a rolling train has a journey, so it stays idle until then.
|
||||
const trackQuery = useQuery(
|
||||
api.trainScheduling.trainTrack.queryOptions({
|
||||
input: { id: scheduleId ?? "" },
|
||||
enabled: Boolean(scheduleId),
|
||||
}),
|
||||
);
|
||||
// Which bookings board/alight at each yard — drives the loading gate on the
|
||||
// log-pass button (a yard with cargo to load must finish its window first).
|
||||
const yardWorkQuery = useQuery(
|
||||
api.trainScheduling.yardWork.queryOptions({
|
||||
input: { scheduleId: scheduleId ?? "" },
|
||||
enabled: Boolean(scheduleId),
|
||||
}),
|
||||
);
|
||||
// One-row 60s heartbeat: refetch the (expensive) full detail only when the
|
||||
// schedule row actually changed — same freshness as polling the detail
|
||||
// itself, at a fraction of the server cost.
|
||||
@@ -256,6 +281,9 @@ export default function TrainScheduleV2DetailPage() {
|
||||
const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
|
||||
const switchGov = useMutation(api.trainScheduling.switchGovernmentBooking.mutationOptions());
|
||||
const dispatch = useMutation(api.trainScheduling.dispatchSchedule.mutationOptions());
|
||||
const recordCheckpoint = useMutation(
|
||||
api.trainScheduling.recordCheckpoint.mutationOptions(),
|
||||
);
|
||||
const downloadMarshalling = useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
@@ -503,6 +531,28 @@ export default function TrainScheduleV2DetailPage() {
|
||||
// Shipping-line bookings ride from accept on the credit ledger.
|
||||
(Boolean(b.shippingLineCompanyId) && b.status === "FULLY_EXECUTED")),
|
||||
);
|
||||
// A slot can carry several loads of the same booking, so count DISTINCT
|
||||
// slots per booking — the operator is being told how much steel is freed.
|
||||
const slotIdsByBookingId = new Map<string, Set<string>>();
|
||||
for (const slot of schedule.trainSet?.wagons ?? []) {
|
||||
for (const alloc of slot.allocations ?? []) {
|
||||
if (!alloc.bookingId) continue;
|
||||
const slots = slotIdsByBookingId.get(alloc.bookingId) ?? new Set<string>();
|
||||
slots.add(slot.id);
|
||||
slotIdsByBookingId.set(alloc.bookingId, slots);
|
||||
}
|
||||
}
|
||||
const wagonsOf = (bookingId: string) => slotIdsByBookingId.get(bookingId)?.size ?? 0;
|
||||
|
||||
const openDispatchConfirm = () => {
|
||||
setDispatchAt(new Date());
|
||||
setDispatchConfirmOpen(true);
|
||||
};
|
||||
// Everything unloaded at the origin comes off the train on dispatch.
|
||||
// Government bookings can never be shed: the server refuses to unassign them.
|
||||
const leftBehind = pendingOriginBoarders.filter((b) => !b.isGovernment);
|
||||
const leftBehindWagons = leftBehind.reduce((n, b) => n + wagonsOf(b.id), 0);
|
||||
|
||||
// Origin loading time window: dispatch (which marks the boarders loaded)
|
||||
// is server-rejected until "Start loading" was clicked for the origin
|
||||
// yard, so the button mirrors that gate.
|
||||
@@ -516,6 +566,89 @@ export default function TrainScheduleV2DetailPage() {
|
||||
// Same gate the server enforces.
|
||||
const dispatchBlockedByLoading = !originLoadingEnded;
|
||||
|
||||
// ── Journey leg: log pass / mark arrived ────────────────────────────────
|
||||
// Once the train is rolling, the workflow's last step drives the corridor
|
||||
// instead of dispatch. The stop being logged is the one AFTER the train's
|
||||
// current position; the last stop on the route is the arrival.
|
||||
const track = trackQuery.data;
|
||||
const trackStations = track?.stations ?? [];
|
||||
const isRolling = schedule.status === "DISPATCHED";
|
||||
const nextStation = isRolling
|
||||
? trackStations.find((st) => st.sequenceNo === (track?.currentSequenceNo ?? 0) + 1)
|
||||
: undefined;
|
||||
const nextIsFinal =
|
||||
Boolean(nextStation) &&
|
||||
nextStation?.sequenceNo === trackStations[trackStations.length - 1]?.sequenceNo;
|
||||
// Same permission the track page gates its checkpoint actions on.
|
||||
const canLogPass =
|
||||
isRolling && hasPermission(authUser, FREIGHT_PERMS.trainScheduling.update);
|
||||
// Loading gate. Logging a pass means the train LEAVES the yard it is standing
|
||||
// at, so cargo boarding there must have finished loading first — an open (or
|
||||
// never-opened) loading window at a yard with boarders blocks the button.
|
||||
// Unloading never blocks: cargo alighting here can be taken off after the
|
||||
// pass is recorded, and the final arrival is what opens that window at all.
|
||||
const currentStation = isRolling
|
||||
? trackStations.find((st) => st.sequenceNo === (track?.currentSequenceNo ?? 0))
|
||||
: undefined;
|
||||
const currentYardWork = currentStation
|
||||
? yardWorkQuery.data?.yards.find((y) => y.yardId === currentStation.yardId)
|
||||
: undefined;
|
||||
const boardersHere = (currentYardWork?.toLoad ?? []).filter((r) => !r.loadedAt);
|
||||
const currentLoadingLog = currentStation
|
||||
? track?.stationWorkLogs?.[currentStation.yardId]?.loading
|
||||
: undefined;
|
||||
// Only a yard that actually has cargo to load can be blocked by its window.
|
||||
const passBlockedByLoading =
|
||||
boardersHere.length > 0 && !currentLoadingLog?.endedAt;
|
||||
const passBlockReason = !passBlockedByLoading
|
||||
? null
|
||||
: currentLoadingLog?.startedAt
|
||||
? `End the loading window at ${currentStation?.label ?? "this yard"} — the train cannot leave mid-loading.`
|
||||
: `Start and end the loading window at ${currentStation?.label ?? "this yard"} — ${boardersHere.length} booking(s) board here.`;
|
||||
|
||||
const openPassConfirm = () => {
|
||||
setPassAt(new Date());
|
||||
setPassConfirmOpen(true);
|
||||
};
|
||||
|
||||
const runLogPass = async () => {
|
||||
if (!nextStation) return;
|
||||
setPassConfirmOpen(false);
|
||||
try {
|
||||
await recordCheckpoint.mutateAsync({
|
||||
id: scheduleId,
|
||||
payload: {
|
||||
sequenceNo: nextStation.sequenceNo,
|
||||
...(passAt ? { occurredAt: passAt.toISOString() } : {}),
|
||||
},
|
||||
});
|
||||
toast({
|
||||
title: nextIsFinal
|
||||
? `Train arrived at ${nextStation.label}`
|
||||
: `Pass logged at ${nextStation.label}`,
|
||||
description: nextIsFinal
|
||||
? "Remaining bookings are marked arrived and the assets are freed."
|
||||
: "The train's position has moved to this yard.",
|
||||
});
|
||||
void trackQuery.refetch();
|
||||
void yardWorkQuery.refetch();
|
||||
void detailQuery.refetch();
|
||||
} catch (err) {
|
||||
// A part-loaded booking blocks the pass until its never-loaded wagons are
|
||||
// cut — hand over the fault decision rather than a dead-end error.
|
||||
const gate = parsePartiallyLoaded(err);
|
||||
if (gate) {
|
||||
setPartialGate(gate);
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: nextIsFinal ? "Could not mark arrived" : "Could not log pass",
|
||||
description: parseError(err, "Please try again"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const finalizeStep = hasContainerStep ? 3 : 2;
|
||||
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
|
||||
const canPrintMarshalling =
|
||||
@@ -572,17 +705,34 @@ export default function TrainScheduleV2DetailPage() {
|
||||
id: scheduleId,
|
||||
payload: {
|
||||
...(dispatchAt ? { actualDepartureAt: dispatchAt.toISOString() } : {}),
|
||||
// No per-booking ticking in the dispatch dialog: every pending origin
|
||||
// boarder rides — none are left behind at dispatch time.
|
||||
loadedBookingIds: pendingOriginBoarders.map((b) => b.id),
|
||||
// Dispatch never loads cargo — loading is recorded in the yard, per
|
||||
// booking. Anything still unloaded when the train leaves did not make
|
||||
// it aboard: the server unassigns it (wagons freed, booking back in
|
||||
// the pool). Government bookings are exempt and ride regardless.
|
||||
loadedBookingIds: pendingOriginBoarders
|
||||
.filter((b) => b.isGovernment)
|
||||
.map((b) => b.id),
|
||||
},
|
||||
});
|
||||
if (leftBehind.length) {
|
||||
toast({
|
||||
title: `${leftBehind.length} booking${leftBehind.length === 1 ? "" : "s"} removed from the train`,
|
||||
description: `Never loaded at the origin — ${leftBehindWagons} wagon${leftBehindWagons === 1 ? "" : "s"} freed. The bookings are back in the pool and can be allocated to another train or cancelled.`,
|
||||
});
|
||||
}
|
||||
await openMarshallingDocument({
|
||||
title: "Train dispatched",
|
||||
successDescription: "Marshalling document generated for the dispatched train.",
|
||||
errorTitle: "Train dispatched, but document could not open",
|
||||
});
|
||||
} catch (err) {
|
||||
// A part-loaded booking blocks dispatch until its never-loaded wagons are
|
||||
// cut — hand the operator the fault decision instead of a dead error.
|
||||
const gate = parsePartiallyLoaded(err);
|
||||
if (gate) {
|
||||
setPartialGate(gate);
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: "Dispatch failed",
|
||||
description: parseError(err, "Could not dispatch"),
|
||||
@@ -705,8 +855,10 @@ export default function TrainScheduleV2DetailPage() {
|
||||
{
|
||||
key: "finalize",
|
||||
icon: CheckCircle2,
|
||||
title: "Dispatch",
|
||||
subtitle: "Review the consist & dispatch",
|
||||
title: isRolling ? "Journey" : "Dispatch",
|
||||
subtitle: isRolling
|
||||
? "Log each pass, then mark arrived"
|
||||
: "Review the consist & dispatch",
|
||||
complete: finalizeComplete,
|
||||
},
|
||||
];
|
||||
@@ -958,14 +1110,51 @@ export default function TrainScheduleV2DetailPage() {
|
||||
<CheckCircle2 size={22} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2}>
|
||||
<Text fw={600}>Ready to depart</Text>
|
||||
<Text fw={600}>
|
||||
{isRolling
|
||||
? nextIsFinal
|
||||
? "Final leg"
|
||||
: `In transit — at ${currentStation?.label ?? "the corridor"}`
|
||||
: "Ready to depart"}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Dispatch begins rail movement and notifies the yard.
|
||||
{isRolling
|
||||
? nextIsFinal
|
||||
? "Marking arrived ends the journey and frees the locomotive and wagons."
|
||||
: "Logging the pass moves the train to the next yard and settles its cargo there."
|
||||
: "Dispatch begins rail movement and notifies the yard."}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
{originYardId ? (
|
||||
{/* Mid-route loading/unloading is recorded on the TRACKING page, per
|
||||
yard — only the origin's window lives here (below), because dispatch
|
||||
is the action this page owns. What stays is the read-only reason the
|
||||
pass button is held, so the blocker is explainable without
|
||||
duplicating the controls. */}
|
||||
{isRolling && currentStation && passBlockedByLoading ? (
|
||||
<Alert
|
||||
color="orange"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertTriangle size={18} />}
|
||||
title={`Loading is not finished at ${currentStation.label}`}
|
||||
>
|
||||
<Text size="xs">
|
||||
{boardersHere.length} booking(s) board here, so the train cannot leave until
|
||||
the loading window is closed. Start and end it on the{" "}
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}/track`}
|
||||
fw={600}
|
||||
>
|
||||
tracking page
|
||||
</Anchor>
|
||||
.
|
||||
</Text>
|
||||
</Alert>
|
||||
) : null}
|
||||
{!isRolling && originYardId ? (
|
||||
<Paper p="md" radius="lg" withBorder>
|
||||
<Stack gap={6}>
|
||||
<Text fw={600} size="sm">
|
||||
@@ -1000,7 +1189,35 @@ export default function TrainScheduleV2DetailPage() {
|
||||
Dispatch train
|
||||
</Button>
|
||||
) : null}
|
||||
{!canDispatch ? (
|
||||
{/* The train is rolling: the same slot now drives the corridor. */}
|
||||
{canLogPass && nextStation ? (
|
||||
<Tooltip
|
||||
label={passBlockReason ?? ""}
|
||||
disabled={!passBlockedByLoading}
|
||||
withArrow
|
||||
multiline
|
||||
w={280}
|
||||
>
|
||||
<div>
|
||||
<Button
|
||||
color="edr-green"
|
||||
size="md"
|
||||
radius="md"
|
||||
leftSection={
|
||||
nextIsFinal ? <CheckCircle2 size={18} /> : <Navigation size={18} />
|
||||
}
|
||||
loading={recordCheckpoint.isPending}
|
||||
disabled={passBlockedByLoading}
|
||||
onClick={openPassConfirm}
|
||||
>
|
||||
{nextIsFinal
|
||||
? `Mark arrived at ${nextStation.label}`
|
||||
: `Log pass at ${nextStation.label}`}
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{!canDispatch && !(canLogPass && nextStation) ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No actions available for this schedule status.
|
||||
</Text>
|
||||
@@ -1636,6 +1853,25 @@ export default function TrainScheduleV2DetailPage() {
|
||||
radius="md"
|
||||
/>
|
||||
|
||||
{leftBehind.length > 0 ? (
|
||||
<Alert
|
||||
color="orange"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertTriangle size={18} />}
|
||||
title={`${leftBehind.length} booking${leftBehind.length === 1 ? "" : "s"} will be removed from this train`}
|
||||
>
|
||||
<Text size="xs">
|
||||
Never loaded at the origin, so {leftBehind.length === 1 ? "it is" : "they are"}{" "}
|
||||
not aboard. Dispatch frees {leftBehindWagons} wagon
|
||||
{leftBehindWagons === 1 ? "" : "s"} and returns{" "}
|
||||
{leftBehind.length === 1 ? "the booking" : "them"} to the pool, ready to be
|
||||
allocated to another train or cancelled. Load cargo from the yard workspace
|
||||
before dispatching if it should ride.
|
||||
</Text>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{hasDispatchWarnings ? (
|
||||
<Alert
|
||||
color="orange"
|
||||
@@ -1717,6 +1953,62 @@ export default function TrainScheduleV2DetailPage() {
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
{/* Log pass / arrival — confirmation only, with the recorded time. */}
|
||||
<Modal
|
||||
opened={passConfirmOpen}
|
||||
onClose={() => setPassConfirmOpen(false)}
|
||||
centered
|
||||
radius="lg"
|
||||
title={
|
||||
<Group gap={8}>
|
||||
{nextIsFinal ? <CheckCircle2 size={18} /> : <Navigation size={18} />}
|
||||
<Text fw={700}>
|
||||
{nextIsFinal ? "Mark the train arrived?" : "Log the pass?"}
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{nextIsFinal
|
||||
? `Recording arrival at ${nextStation?.label ?? "the destination"} ends the journey: remaining bookings are marked arrived and the locomotive and wagons are freed.`
|
||||
: `Recording the pass at ${nextStation?.label ?? "the next yard"} moves the train there. Cargo destined for that yard alights, and cargo boarding there becomes loadable.`}
|
||||
</Text>
|
||||
|
||||
<DateTimePicker
|
||||
label={nextIsFinal ? "Arrival time" : "Time at station"}
|
||||
description="Defaults to now — pick an earlier time if you are recording after the fact."
|
||||
value={passAt}
|
||||
onChange={(v) => setPassAt(v ? new Date(v) : null)}
|
||||
maxDate={new Date()}
|
||||
valueFormat="DD MMM YYYY HH:mm"
|
||||
clearable={false}
|
||||
radius="md"
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" radius="md" onClick={() => setPassConfirmOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
loading={recordCheckpoint.isPending}
|
||||
onClick={() => void runLogPass()}
|
||||
>
|
||||
{nextIsFinal ? "Mark arrived" : "Log pass"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* Part-loaded gate. Cutting the wagons does NOT dispatch — the operator
|
||||
confirms dispatch again once the consist is clean. */}
|
||||
<PartiallyLoadedDecisionModal
|
||||
payload={partialGate}
|
||||
onClose={() => setPartialGate(null)}
|
||||
onResolved={() => void detailQuery.refetch()}
|
||||
/>
|
||||
{visualization3DOpen ? (
|
||||
<Train3DVisualization schedule={schedule} onClose={() => setVisualization3DOpen(false)} />
|
||||
) : null}
|
||||
|
||||
@@ -139,8 +139,17 @@ export function WagonCancellationCard({
|
||||
// rows this booking opened itself can be paid/withdrawn/rebooked from here.
|
||||
const rows = data?.items ?? [];
|
||||
const ownRows = rows.filter((r) => r.bookingId === booking.id);
|
||||
const openRow = ownRows.find((r) => r.status === "FEE_PENDING");
|
||||
const creditRow = ownRows.find((r) => r.status === "CREDIT_AVAILABLE");
|
||||
// A cancellation owes its fee whenever a customer-fault fee is still
|
||||
// unsettled. FEE_PENDING is the customer-requested flow (cut applies at
|
||||
// payment); an AT-LOADING cut applies immediately and jumps straight to
|
||||
// CREDIT_AVAILABLE with its invoice left open — so status alone would hide
|
||||
// the fee and offer a "no further payment needed" rebook on money still owed.
|
||||
const owesFee = (r: (typeof ownRows)[number]) =>
|
||||
r.fault === "CUSTOMER" && Number(r.feeAmount ?? 0) > 0 && !r.feePaidAt;
|
||||
const openRow = ownRows.find((r) => r.status === "FEE_PENDING" || owesFee(r));
|
||||
const creditRow = ownRows.find(
|
||||
(r) => r.status === "CREDIT_AVAILABLE" && !owesFee(r),
|
||||
);
|
||||
|
||||
const feePay = useFeeInvoicePayment(booking.id);
|
||||
|
||||
@@ -237,7 +246,14 @@ export function WagonCancellationCard({
|
||||
toast.error(apiErrorMessage(e, "Could not rebook the wagons. Please try again.")),
|
||||
});
|
||||
|
||||
if (!eligible) return null;
|
||||
// Showing the card is NOT the same as allowing a new cut. A partial cancel at
|
||||
// loading leaves the kept wagons to ride, so the booking moves on to
|
||||
// IN_TRANSIT/ARRIVED/COMPLETED while its cancellation still owes a fee and
|
||||
// holds a rebookable credit. Gating on PAID/CANCELLED hid exactly that case —
|
||||
// the customer saw neither the cancelled wagons nor the fee they owe. Any
|
||||
// booking that HAS cancellation rows keeps the card, whatever its status;
|
||||
// `canRequest` still limits NEW cuts to a live PAID booking.
|
||||
if (!eligible && !ownRows.length) return null;
|
||||
if (!canRequest && !ownRows.length) return null;
|
||||
|
||||
return (
|
||||
@@ -265,8 +281,10 @@ export function WagonCancellationCard({
|
||||
{fmtMoney(openRow.feeAmount, openRow.feeCurrency)}
|
||||
</Text>
|
||||
. The cancelled wagons have left the train. Pay the fee to unlock
|
||||
the rebooking credit. The request cannot be withdrawn from here —
|
||||
if it was a mistake, contact EDR staff.
|
||||
the rebooking credit — the {Number(openRow.wagonsCancelled)} cancelled
|
||||
wagon(s) can then be rebooked on a coming train day. The request
|
||||
cannot be withdrawn from here — if it was a mistake, contact EDR
|
||||
staff.
|
||||
</Alert>
|
||||
<Group gap={8}>
|
||||
<Button
|
||||
|
||||
@@ -459,7 +459,15 @@ export default function BookingsListPage() {
|
||||
const creditByBooking = useMemo(() => {
|
||||
const m = new Map<string, WagonCancellation>();
|
||||
for (const r of myCancellations?.items ?? []) {
|
||||
if (r.status === "CREDIT_AVAILABLE" && !m.has(r.bookingId)) m.set(r.bookingId, r);
|
||||
// A customer-fault cut invoices a fee. An at-loading cut applies at once
|
||||
// and opens the credit with that invoice still OPEN, so CREDIT_AVAILABLE
|
||||
// alone never means the fee was settled — offering "Rebook" here would
|
||||
// let the customer redeem the wagons without ever paying. Those rows fall
|
||||
// through to the row's Pay button instead (the fee is on my-payables).
|
||||
const owesFee =
|
||||
r.fault === "CUSTOMER" && Number(r.feeAmount ?? 0) > 0 && !r.feePaidAt;
|
||||
if (r.status === "CREDIT_AVAILABLE" && !owesFee && !m.has(r.bookingId))
|
||||
m.set(r.bookingId, r);
|
||||
}
|
||||
return m;
|
||||
}, [myCancellations]);
|
||||
|
||||
@@ -282,6 +282,8 @@ export interface WagonCancellation {
|
||||
feeCurrency: string;
|
||||
feeInvoiceId?: string | null;
|
||||
feePaidAt?: string | null;
|
||||
/** Who caused the cut: CUSTOMER pays a fee, EDR never does. */
|
||||
fault?: "CUSTOMER" | "EDR" | null;
|
||||
status: WagonCancellationStatus;
|
||||
reason?: string | null;
|
||||
rebookedAt?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user