mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-04 02:03:39 +00:00
- 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.
739 lines
29 KiB
TypeScript
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>
|
|
);
|
|
}
|