Files
edr-platform/apps/edr-freight-web/backoffice/src/components/trainScheduling/LogPassYardWorkModal.tsx
marshalyordanos d51bed5630 feat: Implement handling for partially loaded bookings in train scheduling
- Added  to manage decisions on partially loaded bookings during log-pass and dispatch actions.
- Enhanced  to track and manage bookings left behind when a train departs a yard.
- Updated  and  to pass necessary station data for handling partially loaded bookings.
- Modified  and  to account for customer-fault fees and ensure proper handling of credits.
- Introduced fault tracking in  interface to differentiate between customer and EDR faults.
2026-09-01 23:53:58 +03:00

739 lines
29 KiB
TypeScript

import {
Alert,
Button,
Checkbox,
Divider,
Group,
Loader,
Modal,
Stack,
Table,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
CheckCircle2,
Flag,
MapPin,
PackageCheck,
TrainFront,
} from "lucide-react";
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";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
import { useToast } from "@/hooks/use-toast";
import { api } from "@/services/api";
import type { TrackStation, YardWorkBookingRow } from "@/types/trainScheduling";
const parseError = (error: unknown, fallback: string) => {
const message = (error as { response?: { data?: { message?: string | string[] } } })
?.response?.data?.message;
if (Array.isArray(message)) return message.join("; ");
return message || (error as Error)?.message || fallback;
};
const fmtDate = (iso: string) => {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
};
/** DOMESTIC displays as "Intercity" — shared with the rest of the platform. */
const DIRECTION_LABELS: Record<string, string> = Freight.TRADE_DIRECTION_LABELS;
function DirectionChip({ direction }: { direction: string }) {
const tone = DIRECTION_TONE[direction] ?? { bg: T.surface3, fg: T.muted };
return (
<Chip bg={tone.bg} fg={tone.fg}>
{DIRECTION_LABELS[direction] ?? direction}
</Chip>
);
}
function SectionLabel({
icon,
title,
count,
}: {
icon: React.ReactNode;
title: string;
count: number;
}) {
return (
<Group gap={8} align="center">
<ThemeIcon size={28} radius={8} variant="light" color="edr-green">
{icon}
</ThemeIcon>
<Text fw={700} size="13.5px" c={T.text}>
{title}
</Text>
<Chip bg={T.surface3} fg={T.text2}>
{String(count)}
</Chip>
</Group>
);
}
/**
* Yard-work modal for the track page's "Log pass" step.
*
* A train runs A→B→C→D and bookings board/alight at any stop, so logging the
* pass at a yard is the moment its yard work happens: bookings destined here
* flip to ARRIVED (import/export) or COMPLETED (intercity) automatically the
* instant the pass is logged, and bookings boarding here become loadable —
* the server only accepts a load while the train's latest checkpoint is this
* yard. The modal therefore drives the sequence: log the pass first, then
* load anything that boards here (including cargo the operator forgot — it
* stays loadable until the next pass is logged).
*/
export function LogPassYardWorkModal({
opened,
onClose,
scheduleId,
station,
stations,
isFinal,
alreadyLogged,
}: {
opened: boolean;
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;
}) {
const { toast } = useToast();
const { user } = useAuth();
const canLoad = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.load);
const canUnload = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.unload);
const canLeave = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.update);
const [justLogged, setJustLogged] = useState(false);
// When the train was here — defaults to now, past allowed (recorded after the fact).
const [passAt, setPassAt] = useState<Date | null>(null);
useEffect(() => {
setJustLogged(false);
setPassAt(new Date());
}, [station?.sequenceNo, opened]);
const logged = alreadyLogged || justLogged;
const yardWorkQuery = useQuery(
api.trainScheduling.yardWork.queryOptions({
input: { scheduleId },
enabled: opened && Boolean(scheduleId),
}),
);
const recordCheckpoint = useMutation(
api.trainScheduling.recordCheckpoint.mutationOptions(),
);
const load = useMutation(api.trainScheduling.loadScheduleBooking.mutationOptions());
const unload = useMutation(api.trainScheduling.unloadScheduleBooking.mutationOptions());
// "Leave behind": the cargo is not on the train — unassign frees its wagons
// and returns the booking to the pool for a later schedule. Reversible (the
// booking can be re-assigned), so no extra confirm step.
const leave = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
const yard = yardWorkQuery.data?.yards.find((y) => y.yardId === station?.yardId);
const boarders: YardWorkBookingRow[] = yard?.toLoad ?? [];
const arrivals: YardWorkBookingRow[] = yard?.toUnload ?? [];
const pendingBoarders = boarders.filter((r) => !r.loadedAt);
// Loading/unloading time windows at this station: the server rejects booking
// load/unload until the matching window is started, so the buttons mirror it.
const workLog = station
? yardWorkQuery.data?.stationWorkLogs?.[station.yardId]
: undefined;
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(
{
id: scheduleId,
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"
: `Pass logged at ${station.label}`,
description: isFinal
? undefined
: arrivals.some((r) => r.canUnload)
? unloadingStarted
? "Bookings arriving here have been marked arrived."
: "Start unloading, then unload each arriving booking."
: undefined,
});
void yardWorkQuery.refetch();
},
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",
});
},
},
);
};
const doLoad = (row: YardWorkBookingRow) => {
load.mutate(
{ scheduleId, bookingId: row.id },
{
onSuccess: () => {
toast({
title: `${row.reference ?? "Booking"} loaded`,
description: `Cargo boarded the train at ${station?.label ?? "this yard"}.`,
});
void yardWorkQuery.refetch();
},
onError: (err) =>
toast({
title: "Could not load booking",
description: parseError(err, "Please try again"),
variant: "destructive",
}),
},
);
};
const doUnload = (row: YardWorkBookingRow) => {
unload.mutate(
{ scheduleId, bookingId: row.id },
{
onSuccess: () => {
toast({
title: `${row.reference ?? "Booking"} unloaded`,
description: `Cargo left the train at ${station?.label ?? "this yard"}.`,
});
void yardWorkQuery.refetch();
},
onError: (err) =>
toast({
title: "Could not unload booking",
description: parseError(err, "Please try again"),
variant: "destructive",
}),
},
);
};
const doLeave = (row: YardWorkBookingRow) => {
leave.mutate(
{ id: scheduleId, bookingId: row.id },
{
onSuccess: () => {
toast({
title: `${row.reference ?? "Booking"} left behind`,
description:
"Removed from this train — wagons freed, booking returned to the pool for a later schedule.",
});
void yardWorkQuery.refetch();
},
onError: (err) =>
toast({
title: "Could not leave booking behind",
description: parseError(err, "Please try again"),
variant: "destructive",
}),
},
);
};
const hasWork = boarders.length > 0 || arrivals.length > 0;
return (
<Modal
opened={opened}
onClose={onClose}
size="xl"
radius={18}
title={
<Group gap={8}>
{isFinal ? <Flag size={18} /> : <MapPin size={18} />}
<Text fw={700}>
{isFinal ? "Arrival" : "Yard work"} {station?.label ?? ""}
</Text>
{logged ? (
<Chip bg={T.brandDim} fg={T.brand}>
{isFinal ? "ARRIVED" : "PASS LOGGED"}
</Chip>
) : null}
</Group>
}
>
<Stack gap="md">
{yardWorkQuery.isLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" color="edr-green" />
</Group>
) : !hasWork ? (
<Alert color="gray" variant="light" radius="md" icon={<MapPin size={16} />}>
No bookings board or alight at this station.
</Alert>
) : (
<>
{/* ── Arriving here ─────────────────────────────────────────── */}
{arrivals.length > 0 ? (
<Stack gap="xs">
<SectionLabel
icon={<Flag size={14} />}
title="Arriving at this yard"
count={arrivals.length}
/>
{station ? (
<StationWorkControls
scheduleId={scheduleId}
yardId={station.yardId}
phase="unloading"
log={workLog?.unloading}
/>
) : null}
{!logged ? (
<Text size="xs" c="dimmed">
Log the pass, start unloading, then unload each booking below.
</Text>
) : !unloadingStarted && arrivals.some((r) => r.canUnload) ? (
<Text size="xs" c="dimmed">
Start unloading first bookings can only be unloaded inside a
started unloading window.
</Text>
) : null}
<Table.ScrollContainer minWidth={620}>
<Table
verticalSpacing={11}
highlightOnHover
styles={{
th: {
fontSize: 9.5,
fontWeight: 700,
letterSpacing: 0.7,
textTransform: "uppercase",
color: T.muted,
background: T.surface2,
},
}}
>
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Direction</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Arrived</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{arrivals.map((row) => (
<Table.Tr key={row.id}>
<Table.Td>
<Text size="12px" fw={600} style={{ fontFamily: T.mono }}>
{row.reference ?? row.id.slice(0, 8)}
</Text>
</Table.Td>
<Table.Td>
<Text size="12.5px" c={T.text2}>{row.customer}</Text>
</Table.Td>
<Table.Td>
<DirectionChip direction={row.tradeDirection} />
</Table.Td>
<Table.Td>
<BookingStatusBadge status={row.status} />
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">
{row.arrivedAt ? fmtDate(row.arrivedAt) : "—"}
</Text>
</Table.Td>
<Table.Td>
{row.canUnload ? (
<Tooltip
label={
!canUnload
? "You don't have permission to unload cargo"
: !logged
? "Log the pass first — the train must be at this yard"
: !unloadingStarted
? "Start unloading first"
: "Confirm cargo unloaded off the train"
}
>
<Button
size="compact-xs"
radius={8}
variant="light"
color="teal"
leftSection={<PackageCheck size={13} />}
disabled={!canUnload || !logged || !unloadingStarted}
loading={
unload.isPending &&
unload.variables?.bookingId === row.id
}
onClick={() => doUnload(row)}
>
Unload
</Button>
</Tooltip>
) : null}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</Stack>
) : null}
{arrivals.length > 0 && boarders.length > 0 ? <Divider /> : null}
{/* ── Boarding here ─────────────────────────────────────────── */}
{boarders.length > 0 ? (
<Stack gap="xs">
<SectionLabel
icon={<TrainFront size={14} />}
title="Boarding at this yard"
count={boarders.length}
/>
{station ? (
<StationWorkControls
scheduleId={scheduleId}
yardId={station.yardId}
phase="loading"
log={workLog?.loading}
/>
) : null}
{!logged && pendingBoarders.length > 0 ? (
<Text size="xs" c="dimmed">
Log the pass first the train must be at {station?.label} before
cargo can be loaded.
</Text>
) : !loadingStarted && pendingBoarders.length > 0 ? (
<Text size="xs" c="dimmed">
Start loading first bookings can only be loaded inside a started
loading window.
</Text>
) : null}
<Table.ScrollContainer minWidth={620}>
<Table
verticalSpacing={11}
highlightOnHover
styles={{
th: {
fontSize: 9.5,
fontWeight: 700,
letterSpacing: 0.7,
textTransform: "uppercase",
color: T.muted,
background: T.surface2,
},
}}
>
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Direction</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Loaded</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{boarders.map((row) => (
<Table.Tr key={row.id}>
<Table.Td>
<Group gap={6} wrap="nowrap">
<Text size="12px" fw={600} style={{ fontFamily: T.mono }}>
{row.reference ?? row.id.slice(0, 8)}
</Text>
{row.isGovernment ? (
<Chip bg={T.grapeDim} fg={T.grape}>
GOV
</Chip>
) : null}
</Group>
</Table.Td>
<Table.Td>
<Text size="12.5px" c={T.text2}>{row.customer}</Text>
</Table.Td>
<Table.Td>
<DirectionChip direction={row.tradeDirection} />
</Table.Td>
<Table.Td>
<BookingStatusBadge status={row.status} />
</Table.Td>
<Table.Td>
{row.loadedAt ? (
<Group gap={4} wrap="nowrap">
<CheckCircle2
size={13}
color="var(--mantine-color-edr-green-7)"
/>
<Text size="xs" c="dimmed">
{fmtDate(row.loadedAt)}
</Text>
</Group>
) : (
<Text size="xs" c="dimmed">
Not loaded
</Text>
)}
</Table.Td>
<Table.Td>
{!row.loadedAt ? (
<Group gap={6} wrap="nowrap">
<Tooltip
label={
!canLoad
? "You don't have permission to load cargo"
: !logged
? "Log the pass first — the train must be at this yard"
: !loadingStarted
? "Start loading first"
: !row.canLoad
? "Booking is not ready to load (payment pending)"
: "Confirm cargo loaded onto the train"
}
>
<Button
size="compact-xs"
radius={8}
color="edr-green"
leftSection={<PackageCheck size={13} />}
disabled={!canLoad || !logged || !loadingStarted || !row.canLoad}
loading={
load.isPending && load.variables?.bookingId === row.id
}
onClick={() => doLoad(row)}
>
Load
</Button>
</Tooltip>
<Tooltip
label={
row.isGovernment
? "Government bookings cannot be removed from a train"
: !canLeave
? "You don't have permission to remove bookings"
: "Cargo is not on the train — free its wagons and return the booking to the pool"
}
>
<Button
size="compact-xs"
radius={8}
variant="light"
color="red"
disabled={!canLeave || row.isGovernment}
loading={
leave.isPending && leave.variables?.bookingId === row.id
}
onClick={() => doLeave(row)}
>
Leave
</Button>
</Tooltip>
</Group>
) : null}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</Stack>
) : null}
</>
)}
{!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"}
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"
maw={320}
/>
) : null}
<Group justify="space-between" mt="xs">
<Text size="xs" c="dimmed">
{logged && pendingBoarders.length > 0
? `${pendingBoarders.length} booking${pendingBoarders.length === 1 ? "" : "s"} still to load before the next station.`
: ""}
</Text>
<Group gap="sm">
<Button variant="default" radius={9} onClick={onClose}>
Close
</Button>
{!logged ? (
// Arrival comes BEFORE unloading: the train is marked arrived
// whenever it physically gets there, and the unloading window
// opens afterwards. Bookings then unload per booking inside the
// started window (the buttons above enforce that).
<Button
radius={9}
color={isFinal ? "teal" : "edr-green"}
leftSection={isFinal ? <Flag size={15} /> : <MapPin size={15} />}
loading={recordCheckpoint.isPending}
onClick={doLogPass}
>
{isFinal
? `Mark arrived at ${station?.label ?? "destination"}`
: `Log pass at ${station?.label ?? "station"}`}
</Button>
) : null}
</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>
);
}