mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08:48:11 +00:00
Merge branch 'dev' into freight/nati-2
This commit is contained in:
@@ -36,6 +36,13 @@ export function DoCollectionDateFields({
|
||||
const outOfOrder =
|
||||
Boolean(value.vesselArrival && value.doCollected) && !doDatesComplete(value);
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const doMin =
|
||||
value.vesselArrival && value.vesselArrival > today
|
||||
? value.vesselArrival
|
||||
: today;
|
||||
|
||||
return (
|
||||
<Group grow align="flex-start" gap="sm" wrap="wrap">
|
||||
<DateInput
|
||||
@@ -45,7 +52,7 @@ export function DoCollectionDateFields({
|
||||
onChange={(v) =>
|
||||
onChange({ ...value, vesselArrival: v ? new Date(v) : null })
|
||||
}
|
||||
maxDate={new Date()}
|
||||
minDate={today}
|
||||
size="sm"
|
||||
required
|
||||
withAsterisk
|
||||
@@ -57,8 +64,7 @@ export function DoCollectionDateFields({
|
||||
onChange={(v) =>
|
||||
onChange({ ...value, doCollected: v ? new Date(v) : null })
|
||||
}
|
||||
minDate={value.vesselArrival ?? undefined}
|
||||
maxDate={new Date()}
|
||||
minDate={doMin}
|
||||
size="sm"
|
||||
required
|
||||
withAsterisk
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2055,9 +2110,10 @@ export default function GlCreateBookingForm() {
|
||||
? bulkErrors.quantity
|
||||
: undefined
|
||||
}
|
||||
onChange={(e) =>
|
||||
setBulk((b) => ({ ...b, cargoWeightTons: e.currentTarget.value }))
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setBulk((b) => ({ ...b, cargoWeightTons: value }));
|
||||
}}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
@@ -2074,9 +2130,10 @@ export default function GlCreateBookingForm() {
|
||||
? bulkErrors.quantity
|
||||
: undefined
|
||||
}
|
||||
onChange={(e) =>
|
||||
setBulk((b) => ({ ...b, itemCount: e.currentTarget.value }))
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setBulk((b) => ({ ...b, itemCount: value }));
|
||||
}}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
@@ -2091,12 +2148,10 @@ export default function GlCreateBookingForm() {
|
||||
step={1}
|
||||
value={bulk.requestedWagons}
|
||||
error={showErrors ? bulkErrors.wagons : undefined}
|
||||
onChange={(e) =>
|
||||
setBulk((b) => ({
|
||||
...b,
|
||||
requestedWagons: e.currentTarget.value,
|
||||
}))
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setBulk((b) => ({ ...b, requestedWagons: value }));
|
||||
}}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
@@ -2110,12 +2165,10 @@ export default function GlCreateBookingForm() {
|
||||
step={1}
|
||||
value={bulk.hazardousQuantity}
|
||||
error={showErrors ? bulkErrors.hazardous : undefined}
|
||||
onChange={(e) =>
|
||||
setBulk((b) => ({
|
||||
...b,
|
||||
hazardousQuantity: e.currentTarget.value,
|
||||
}))
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setBulk((b) => ({ ...b, hazardousQuantity: value }));
|
||||
}}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
@@ -2129,12 +2182,10 @@ export default function GlCreateBookingForm() {
|
||||
step={1}
|
||||
value={bulk.reeferQuantity}
|
||||
error={showErrors ? bulkErrors.reefer : undefined}
|
||||
onChange={(e) =>
|
||||
setBulk((b) => ({
|
||||
...b,
|
||||
reeferQuantity: e.currentTarget.value,
|
||||
}))
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setBulk((b) => ({ ...b, reeferQuantity: value }));
|
||||
}}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
@@ -2326,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
|
||||
@@ -2350,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
|
||||
@@ -2380,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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Box, Button, Group, Stack, Table, Text } from "@mantine/core";
|
||||
import { MapPin, Pencil } from "lucide-react";
|
||||
|
||||
import type { TrainCheckpoint } from "@/types/trainScheduling";
|
||||
import { handlingHours } from "./JourneySpine";
|
||||
import { Chip } from "./trackPrimitives";
|
||||
import { KIND_TONE, track } from "./trackTheme";
|
||||
|
||||
const fmt = (iso: string) =>
|
||||
new Date(iso).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
const TH = {
|
||||
fontSize: 9.5,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.7,
|
||||
color: track.muted,
|
||||
textTransform: "uppercase",
|
||||
} as const;
|
||||
|
||||
/** Raw event trail under the spine — every logged pass, with its correction. */
|
||||
export function CheckpointLogTable({
|
||||
checkpoints,
|
||||
onEdit,
|
||||
}: {
|
||||
checkpoints: TrainCheckpoint[];
|
||||
onEdit?: (checkpoint: TrainCheckpoint) => void;
|
||||
}) {
|
||||
if (checkpoints.length === 0) {
|
||||
return (
|
||||
<Stack
|
||||
align="center"
|
||||
gap="xs"
|
||||
py={42}
|
||||
mx={24}
|
||||
mb={24}
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: `1px solid ${track.borderSoft}`,
|
||||
background: track.surface2,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 999,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: track.brandDim,
|
||||
color: track.brand,
|
||||
}}
|
||||
>
|
||||
<MapPin size={22} />
|
||||
</Box>
|
||||
<Text size="13.5px" fw={700} c={track.text}>
|
||||
No checkpoints yet
|
||||
</Text>
|
||||
<Text size="12px" c={track.muted} ta="center" maw={320}>
|
||||
Each station the train passes will be logged here with its timestamp.
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table.ScrollContainer minWidth={820}>
|
||||
<Table verticalSpacing={13} horizontalSpacing={24} highlightOnHover>
|
||||
<Table.Thead style={{ background: track.surface2 }}>
|
||||
<Table.Tr>
|
||||
<Table.Th style={{ ...TH, width: 220 }}>Station</Table.Th>
|
||||
<Table.Th style={{ ...TH, width: 110 }}>Event</Table.Th>
|
||||
<Table.Th style={{ ...TH, width: 160 }}>Time</Table.Th>
|
||||
<Table.Th style={{ ...TH, width: 130 }}>Handling</Table.Th>
|
||||
<Table.Th style={TH}>Note</Table.Th>
|
||||
<Table.Th style={{ ...TH, width: 70 }} />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{checkpoints.map((cp) => {
|
||||
const hours = handlingHours(cp);
|
||||
const tone = KIND_TONE[cp.kind] ?? KIND_TONE.PASSED;
|
||||
return (
|
||||
<Table.Tr key={cp.id}>
|
||||
<Table.Td>
|
||||
<Group gap={9} wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 999,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: track.brandDim,
|
||||
color: track.brand,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<MapPin size={11} />
|
||||
</Box>
|
||||
<Text size="12.5px" fw={600} c={track.text}>
|
||||
{cp.label ?? `Station ${cp.sequenceNo}`}
|
||||
</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Chip bg={tone.bg} fg={tone.fg}>
|
||||
{cp.kind}
|
||||
</Chip>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="11.5px" c={track.text2} style={{ fontFamily: track.mono }}>
|
||||
{fmt(cp.occurredAt)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="11.5px" c={hours === null ? track.text3 : track.text2}>
|
||||
{hours === null ? "—" : `${hours} h`}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="11.5px" c={cp.note ? track.muted : track.text3}>
|
||||
{cp.note || "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{onEdit ? (
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
radius={8}
|
||||
variant="default"
|
||||
leftSection={<Pencil size={11} />}
|
||||
onClick={() => onEdit(cp)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</Group>
|
||||
) : null}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Divider, Group, Modal, SimpleGrid, Stack, Text, Textarea } from "@mantine/core";
|
||||
import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core";
|
||||
import { DateTimePicker } from "@mantine/dates";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -67,6 +67,10 @@ export function CheckpointTimeModal({
|
||||
const isSmallScreen = useMediaQuery("(max-width: 48em)");
|
||||
const [at, setAt] = useState<Date | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
// The four station-work stamps are no longer edited HERE — the track page's
|
||||
// "Loading & unloading windows" section owns start/end with its own
|
||||
// permissions. The modal still carries any existing stamps through
|
||||
// unchanged on submit, so editing a checkpoint never wipes them.
|
||||
const [handling, setHandling] = useState<HandlingState>(EMPTY_HANDLING);
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
@@ -94,7 +98,7 @@ export function CheckpointTimeModal({
|
||||
onClose={onClose}
|
||||
centered
|
||||
fullScreen={isSmallScreen}
|
||||
radius="lg"
|
||||
radius={18}
|
||||
title={
|
||||
<Group gap={8}>
|
||||
{icon}
|
||||
@@ -121,34 +125,6 @@ export function CheckpointTimeModal({
|
||||
radius="md"
|
||||
/>
|
||||
|
||||
<Divider
|
||||
label="Station work (optional)"
|
||||
labelPosition="left"
|
||||
styles={{ label: { fontWeight: 600 } }}
|
||||
/>
|
||||
<Text size="xs" c="dimmed" mt={-8}>
|
||||
Loading and unloading times for this stop. Total handling is unloading start to
|
||||
loading finish; the rest of the stay reports as other activity.
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
{HANDLING_FIELDS.map(([field, label]) => (
|
||||
<DateTimePicker
|
||||
key={field}
|
||||
label={label}
|
||||
value={handling[field]}
|
||||
onChange={(v) =>
|
||||
setHandling((prev) => ({ ...prev, [field]: v ? new Date(v) : null }))
|
||||
}
|
||||
maxDate={new Date()}
|
||||
dropdownType={isSmallScreen ? "modal" : "popover"}
|
||||
popoverProps={{ withinPortal: true }}
|
||||
valueFormat="DD MMM YYYY HH:mm"
|
||||
clearable
|
||||
radius="md"
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<Textarea
|
||||
label="Note"
|
||||
placeholder="Optional"
|
||||
@@ -161,10 +137,11 @@ export function CheckpointTimeModal({
|
||||
radius="md"
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={loading}>
|
||||
<Button variant="default" radius={9} onClick={onClose} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
radius={9}
|
||||
color={submitColor}
|
||||
loading={loading}
|
||||
disabled={!at}
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import { Box, Button, Group, Stack, Text } from "@mantine/core";
|
||||
import { Check, Flag, MapPin, Pencil, Timer } from "lucide-react";
|
||||
|
||||
import { StationWorkControls } from "@/components/trainScheduling/StationWorkControls";
|
||||
import type {
|
||||
StationWorkLog,
|
||||
TrackStation,
|
||||
TrainCheckpoint,
|
||||
} from "@/types/trainScheduling";
|
||||
import { Chip } from "./trackPrimitives";
|
||||
import { KIND_TONE, track } from "./trackTheme";
|
||||
|
||||
const NODE = 30;
|
||||
|
||||
/** Total handling at a stop: earliest start → latest finish. Null when unlogged. */
|
||||
export function handlingHours(cp: TrainCheckpoint): number | null {
|
||||
const starts = [cp.unloadingStartedAt, cp.loadingStartedAt]
|
||||
.filter((v): v is string => Boolean(v))
|
||||
.map((v) => new Date(v).getTime());
|
||||
const ends = [cp.loadingCompletedAt, cp.unloadingCompletedAt]
|
||||
.filter((v): v is string => Boolean(v))
|
||||
.map((v) => new Date(v).getTime());
|
||||
if (!starts.length || !ends.length) return null;
|
||||
return Math.round(((Math.max(...ends) - Math.min(...starts)) / 3_600_000) * 10) / 10;
|
||||
}
|
||||
|
||||
function fmt(iso?: string | null) {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export interface JourneySpineProps {
|
||||
scheduleId: string;
|
||||
stations: TrackStation[];
|
||||
currentSequenceNo: number;
|
||||
checkpoints: TrainCheckpoint[];
|
||||
stationWorkLogs?: Record<string, StationWorkLog>;
|
||||
/** True when the train is DISPATCHED and staff may log progress. */
|
||||
canLog: boolean;
|
||||
loggingSeq?: number | null;
|
||||
onLogCheckpoint?: (sequenceNo: number) => void;
|
||||
/** Present when logged legs may be corrected (dispatched or arrived). */
|
||||
onEditCheckpoint?: (checkpoint: TrainCheckpoint) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The journey: one vertical spine where every stop carries its pass time and
|
||||
* its loading/unloading windows together, so an operator reads a station's
|
||||
* whole story in one row instead of cross-referencing two lists.
|
||||
*/
|
||||
export function JourneySpine({
|
||||
scheduleId,
|
||||
stations,
|
||||
currentSequenceNo,
|
||||
checkpoints,
|
||||
stationWorkLogs,
|
||||
canLog,
|
||||
loggingSeq,
|
||||
onLogCheckpoint,
|
||||
onEditCheckpoint,
|
||||
}: JourneySpineProps) {
|
||||
const bySeq = new Map(checkpoints.map((c) => [c.sequenceNo, c]));
|
||||
const lastIndex = stations.length - 1;
|
||||
|
||||
return (
|
||||
<Stack gap={0} px={24} pt={6} pb={20}>
|
||||
{stations.map((station, index) => {
|
||||
const isLast = index === lastIndex;
|
||||
const isFirst = index === 0;
|
||||
const passed = station.sequenceNo <= currentSequenceNo;
|
||||
const isCurrent = station.sequenceNo === currentSequenceNo;
|
||||
const isNext = canLog && station.sequenceNo === currentSequenceNo + 1;
|
||||
const checkpoint = bySeq.get(station.sequenceNo);
|
||||
const workLog = stationWorkLogs?.[station.yardId];
|
||||
const hours = checkpoint ? handlingHours(checkpoint) : null;
|
||||
const kindTone = checkpoint ? KIND_TONE[checkpoint.kind] : null;
|
||||
|
||||
return (
|
||||
<Group
|
||||
key={station.yardId}
|
||||
gap={16}
|
||||
align="stretch"
|
||||
wrap="nowrap"
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{/* gutter: node + the line running to the next stop */}
|
||||
<Stack
|
||||
gap={0}
|
||||
align="center"
|
||||
style={{ width: NODE, flexShrink: 0, alignSelf: "stretch" }}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: NODE,
|
||||
height: NODE,
|
||||
borderRadius: 999,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
background: passed
|
||||
? track.brand
|
||||
: isNext
|
||||
? track.surface
|
||||
: track.surface2,
|
||||
border: `2px solid ${
|
||||
passed ? track.brand : isNext ? track.brand : track.border
|
||||
}`,
|
||||
color: passed ? "#FFFFFF" : isNext ? track.brand : track.text3,
|
||||
}}
|
||||
>
|
||||
{passed ? (
|
||||
<Check size={14} />
|
||||
) : isLast ? (
|
||||
<Flag size={14} />
|
||||
) : (
|
||||
<MapPin size={14} />
|
||||
)}
|
||||
</Box>
|
||||
{!isLast ? (
|
||||
<Box
|
||||
style={{
|
||||
width: 2,
|
||||
flex: 1,
|
||||
minHeight: 24,
|
||||
background: passed ? track.brand : track.border,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
{/* body */}
|
||||
<Stack gap={11} pt={2} pb={isLast ? 4 : 24} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap={10} align="center" wrap="wrap">
|
||||
<Text
|
||||
size="14.5px"
|
||||
fw={700}
|
||||
c={passed || isNext ? track.text : track.text2}
|
||||
>
|
||||
{station.label}
|
||||
</Text>
|
||||
{isFirst ? (
|
||||
<Chip bg={track.surface3} fg={track.muted}>
|
||||
ORIGIN
|
||||
</Chip>
|
||||
) : null}
|
||||
{isLast ? (
|
||||
<Chip bg={track.surface3} fg={track.muted}>
|
||||
DESTINATION
|
||||
</Chip>
|
||||
) : null}
|
||||
{isCurrent ? (
|
||||
<Chip bg={track.brand} fg="#FFFFFF">
|
||||
TRAIN HERE
|
||||
</Chip>
|
||||
) : null}
|
||||
{checkpoint && kindTone ? (
|
||||
<Chip bg={kindTone.bg} fg={kindTone.fg}>
|
||||
{checkpoint.kind}
|
||||
</Chip>
|
||||
) : null}
|
||||
|
||||
<Box style={{ flex: 1, minWidth: 0 }} />
|
||||
|
||||
{checkpoint ? (
|
||||
<Group gap={10} wrap="nowrap">
|
||||
<Text size="11.5px" c={track.text2} style={{ fontFamily: track.mono }}>
|
||||
{fmt(checkpoint.occurredAt)}
|
||||
</Text>
|
||||
{onEditCheckpoint ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
radius={8}
|
||||
variant="default"
|
||||
leftSection={<Pencil size={11} />}
|
||||
onClick={() => onEditCheckpoint(checkpoint)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
) : isNext ? (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
radius={9}
|
||||
color="edr-green"
|
||||
leftSection={isLast ? <Flag size={13} /> : <MapPin size={13} />}
|
||||
loading={loggingSeq === station.sequenceNo}
|
||||
onClick={() => onLogCheckpoint?.(station.sequenceNo)}
|
||||
>
|
||||
{isLast ? "Mark arrived" : "Log pass"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="compact-sm" radius={9} variant="default" disabled>
|
||||
{isLast ? "Mark arrived" : "Log pass"}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{hours !== null || checkpoint?.note ? (
|
||||
<Group gap={9} align="center" wrap="wrap">
|
||||
{hours !== null ? (
|
||||
<>
|
||||
<Timer size={12} color={track.text3} />
|
||||
<Text size="11.5px" c={track.muted}>
|
||||
{hours} h handling
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
{hours !== null && checkpoint?.note ? (
|
||||
<Box
|
||||
style={{
|
||||
width: 3,
|
||||
height: 3,
|
||||
borderRadius: 999,
|
||||
background: track.text3,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{checkpoint?.note ? (
|
||||
<Text size="11.5px" c={track.muted} style={{ flex: 1, minWidth: 0 }}>
|
||||
{checkpoint.note}
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
{/* the station's work windows, inline */}
|
||||
<Stack
|
||||
gap={8}
|
||||
p={14}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
background: isCurrent ? "rgba(228,245,239,0.5)" : track.surface2,
|
||||
border: `1px solid ${isCurrent ? "#B6E4D5" : track.borderSoft}`,
|
||||
}}
|
||||
>
|
||||
{!isFirst ? (
|
||||
<StationWorkControls
|
||||
scheduleId={scheduleId}
|
||||
yardId={station.yardId}
|
||||
phase="unloading"
|
||||
log={workLog?.unloading}
|
||||
/>
|
||||
) : null}
|
||||
{!isLast ? (
|
||||
<StationWorkControls
|
||||
scheduleId={scheduleId}
|
||||
yardId={station.yardId}
|
||||
phase="loading"
|
||||
log={workLog?.loading}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
@@ -26,6 +25,8 @@ import { Freight } from "@edr/types";
|
||||
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
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";
|
||||
@@ -44,20 +45,15 @@ const fmtDate = (iso: string) => {
|
||||
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
|
||||
};
|
||||
|
||||
const DIRECTION_COLORS: Record<string, string> = {
|
||||
IMPORT: "blue",
|
||||
EXPORT: "teal",
|
||||
DOMESTIC: "violet",
|
||||
};
|
||||
|
||||
/** 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 (
|
||||
<Badge size="sm" variant="light" color={DIRECTION_COLORS[direction] ?? "gray"}>
|
||||
<Chip bg={tone.bg} fg={tone.fg}>
|
||||
{DIRECTION_LABELS[direction] ?? direction}
|
||||
</Badge>
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,15 +68,15 @@ function SectionLabel({
|
||||
}) {
|
||||
return (
|
||||
<Group gap={8} align="center">
|
||||
<ThemeIcon size={26} radius="md" variant="light" color="edr-green">
|
||||
<ThemeIcon size={28} radius={8} variant="light" color="edr-green">
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
<Text fw={700} size="sm">
|
||||
<Text fw={700} size="13.5px" c={T.text}>
|
||||
{title}
|
||||
</Text>
|
||||
<Badge size="sm" variant="light" color="gray" radius="sm">
|
||||
{count}
|
||||
</Badge>
|
||||
<Chip bg={T.surface3} fg={T.text2}>
|
||||
{String(count)}
|
||||
</Chip>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -263,7 +259,7 @@ export function LogPassYardWorkModal({
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
radius="lg"
|
||||
radius={18}
|
||||
title={
|
||||
<Group gap={8}>
|
||||
{isFinal ? <Flag size={18} /> : <MapPin size={18} />}
|
||||
@@ -271,9 +267,9 @@ export function LogPassYardWorkModal({
|
||||
{isFinal ? "Arrival" : "Yard work"} — {station?.label ?? ""}
|
||||
</Text>
|
||||
{logged ? (
|
||||
<Badge size="sm" variant="light" color="edr-green" radius="sm">
|
||||
{isFinal ? "Arrived" : "Pass logged"}
|
||||
</Badge>
|
||||
<Chip bg={T.brandDim} fg={T.brand}>
|
||||
{isFinal ? "ARRIVED" : "PASS LOGGED"}
|
||||
</Chip>
|
||||
) : null}
|
||||
</Group>
|
||||
}
|
||||
@@ -316,7 +312,20 @@ export function LogPassYardWorkModal({
|
||||
</Text>
|
||||
) : null}
|
||||
<Table.ScrollContainer minWidth={620}>
|
||||
<Table verticalSpacing="xs" highlightOnHover>
|
||||
<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>
|
||||
@@ -331,12 +340,12 @@ export function LogPassYardWorkModal({
|
||||
{arrivals.map((row) => (
|
||||
<Table.Tr key={row.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>
|
||||
<Text size="12px" fw={600} style={{ fontFamily: T.mono }}>
|
||||
{row.reference ?? row.id.slice(0, 8)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{row.customer}</Text>
|
||||
<Text size="12.5px" c={T.text2}>{row.customer}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<DirectionChip direction={row.tradeDirection} />
|
||||
@@ -364,6 +373,7 @@ export function LogPassYardWorkModal({
|
||||
>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
radius={8}
|
||||
variant="light"
|
||||
color="teal"
|
||||
leftSection={<PackageCheck size={13} />}
|
||||
@@ -417,7 +427,20 @@ export function LogPassYardWorkModal({
|
||||
</Text>
|
||||
) : null}
|
||||
<Table.ScrollContainer minWidth={620}>
|
||||
<Table verticalSpacing="xs" highlightOnHover>
|
||||
<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>
|
||||
@@ -433,18 +456,18 @@ export function LogPassYardWorkModal({
|
||||
<Table.Tr key={row.id}>
|
||||
<Table.Td>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
<Text size="12px" fw={600} style={{ fontFamily: T.mono }}>
|
||||
{row.reference ?? row.id.slice(0, 8)}
|
||||
</Text>
|
||||
{row.isGovernment ? (
|
||||
<Badge size="xs" variant="light" color="grape">
|
||||
<Chip bg={T.grapeDim} fg={T.grape}>
|
||||
GOV
|
||||
</Badge>
|
||||
</Chip>
|
||||
) : null}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{row.customer}</Text>
|
||||
<Text size="12.5px" c={T.text2}>{row.customer}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<DirectionChip direction={row.tradeDirection} />
|
||||
@@ -487,7 +510,8 @@ export function LogPassYardWorkModal({
|
||||
>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
radius={8}
|
||||
color="edr-green"
|
||||
leftSection={<PackageCheck size={13} />}
|
||||
disabled={!canLoad || !logged || !loadingStarted || !row.canLoad}
|
||||
loading={
|
||||
@@ -509,6 +533,7 @@ export function LogPassYardWorkModal({
|
||||
>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
radius={8}
|
||||
variant="light"
|
||||
color="red"
|
||||
disabled={!canLeave || row.isGovernment}
|
||||
@@ -554,26 +579,25 @@ export function LogPassYardWorkModal({
|
||||
: ""}
|
||||
</Text>
|
||||
<Group gap="sm">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
<Button variant="default" radius={9} onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
{!logged ? (
|
||||
<Tooltip
|
||||
label="Start unloading first — arrival marks the remaining bookings arrived, so the unloading window must be open"
|
||||
disabled={!(isFinal && !unloadingStarted && arrivals.some((r) => r.canUnload))}
|
||||
// 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}
|
||||
>
|
||||
<Button
|
||||
color={isFinal ? "teal" : "edr-green"}
|
||||
leftSection={isFinal ? <Flag size={15} /> : <MapPin size={15} />}
|
||||
loading={recordCheckpoint.isPending}
|
||||
disabled={isFinal && !unloadingStarted && arrivals.some((r) => r.canUnload)}
|
||||
onClick={doLogPass}
|
||||
>
|
||||
{isFinal
|
||||
? `Mark arrived at ${station?.label ?? "destination"}`
|
||||
: `Log pass at ${station?.label ?? "station"}`}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{isFinal
|
||||
? `Mark arrived at ${station?.label ?? "destination"}`
|
||||
: `Log pass at ${station?.label ?? "station"}`}
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Popover,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { ActionIcon, Box, Button, Group, Popover, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { DateTimePicker } from "@mantine/dates";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Pencil, PlayCircle, StopCircle } from "lucide-react";
|
||||
@@ -18,6 +9,8 @@ import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api } from "@/services/api";
|
||||
import type { StationWorkPhaseLog } from "@/types/trainScheduling";
|
||||
import { Chip, PhaseChip, phaseState } from "./trackPrimitives";
|
||||
import { track } from "./trackTheme";
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
const message = (error as { response?: { data?: { message?: string | string[] } } })
|
||||
@@ -28,7 +21,14 @@ const parseError = (error: unknown, fallback: string) => {
|
||||
|
||||
const fmtTime = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
|
||||
return Number.isNaN(d.getTime())
|
||||
? iso
|
||||
: d.toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const fmtElapsed = (fromIso: string, toIso?: string | null) => {
|
||||
@@ -71,9 +71,9 @@ function EditTimeButton({
|
||||
<Popover.Target>
|
||||
<Tooltip label={disabled ? disabledReason : `Correct the ${label} time`}>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size={26}
|
||||
radius={7}
|
||||
variant="default"
|
||||
disabled={disabled}
|
||||
onClick={() => setOpened((o) => !o)}
|
||||
>
|
||||
@@ -100,6 +100,7 @@ function EditTimeButton({
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="edr-green"
|
||||
loading={saving}
|
||||
disabled={!draft}
|
||||
onClick={() => {
|
||||
@@ -179,80 +180,84 @@ export function StationWorkControls({
|
||||
);
|
||||
};
|
||||
|
||||
const title = phase === "loading" ? "Loading" : "Unloading";
|
||||
const started = Boolean(log?.startedAt);
|
||||
const ended = Boolean(log?.endedAt);
|
||||
const state = phaseState(log);
|
||||
const started = state !== "idle";
|
||||
const ended = state === "done";
|
||||
const who = log?.endedByName ?? log?.startedByName;
|
||||
|
||||
return (
|
||||
<Group gap="sm" wrap="wrap" align="center">
|
||||
<Badge variant="light" color={ended ? "gray" : started ? "edr-green" : "yellow"} radius="sm">
|
||||
{title}
|
||||
{ended ? " done" : started ? " in progress" : " not started"}
|
||||
</Badge>
|
||||
<Group gap={10} wrap="wrap" align="center" style={{ width: "100%" }}>
|
||||
<PhaseChip phase={phase} state={state} />
|
||||
|
||||
{!started ? (
|
||||
<Tooltip
|
||||
label={
|
||||
canStart
|
||||
? `Record the moment ${phase} work begins at this station`
|
||||
: `You don't have permission to start ${phase}`
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<PlayCircle size={14} />}
|
||||
disabled={!canStart}
|
||||
loading={record.isPending}
|
||||
onClick={() => doRecord("start")}
|
||||
<>
|
||||
<Box style={{ flex: 1, minWidth: 0 }} />
|
||||
<Tooltip
|
||||
label={
|
||||
canStart
|
||||
? `Record the moment ${phase} work begins at this station`
|
||||
: `You don't have permission to start ${phase}`
|
||||
}
|
||||
>
|
||||
Start {phase}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
radius={8}
|
||||
variant="default"
|
||||
leftSection={<PlayCircle size={12} color={track.brand} />}
|
||||
disabled={!canStart}
|
||||
loading={record.isPending}
|
||||
onClick={() => doRecord("start")}
|
||||
styles={{ label: { color: track.brand, fontSize: 11.5 } }}
|
||||
>
|
||||
Start {phase}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{fmtTime(log!.startedAt!)} → {ended ? fmtTime(log!.endedAt!) : "…"} (
|
||||
{fmtElapsed(log!.startedAt!, log?.endedAt)})
|
||||
</Text>
|
||||
{log?.startedByName || log?.endedByName ? (
|
||||
<Tooltip
|
||||
label={[
|
||||
log?.startedByName ? `Started by ${log.startedByName}` : null,
|
||||
log?.endedByName ? `Ended by ${log.endedByName}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
>
|
||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||
{log?.endedByName ?? log?.startedByName}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Text size="11px" c={track.text2} style={{ fontFamily: track.mono }}>
|
||||
{fmtTime(log!.startedAt!)} → {ended ? fmtTime(log!.endedAt!) : "…"}
|
||||
</Text>
|
||||
<Chip bg={track.surface} fg={track.text2} border={track.border}>
|
||||
{fmtElapsed(log!.startedAt!, log?.endedAt)}
|
||||
</Chip>
|
||||
{who ? (
|
||||
<Tooltip
|
||||
label={[
|
||||
log?.startedByName ? `Started by ${log.startedByName}` : null,
|
||||
log?.endedByName ? `Ended by ${log.endedByName}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
>
|
||||
<Text size="11px" c={track.muted}>
|
||||
{who}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
<Box style={{ flex: 1, minWidth: 0 }} />
|
||||
|
||||
<EditTimeButton
|
||||
label={`${phase} start`}
|
||||
value={log!.startedAt!}
|
||||
disabled={!canStart}
|
||||
disabledReason={`You don't have permission to edit the ${phase} start`}
|
||||
maxDate={log?.endedAt ? new Date(log.endedAt) : new Date()}
|
||||
onSave={(at) => doRecord("start", at)}
|
||||
saving={record.isPending}
|
||||
/>
|
||||
{ended ? (
|
||||
<EditTimeButton
|
||||
label={`${phase} start`}
|
||||
value={log!.startedAt!}
|
||||
disabled={!canStart}
|
||||
disabledReason={`You don't have permission to edit the ${phase} start`}
|
||||
maxDate={log?.endedAt ? new Date(log.endedAt) : new Date()}
|
||||
onSave={(at) => doRecord("start", at)}
|
||||
label={`${phase} end`}
|
||||
value={log!.endedAt!}
|
||||
disabled={!canEnd}
|
||||
disabledReason={`You don't have permission to edit the ${phase} end`}
|
||||
minDate={new Date(log!.startedAt!)}
|
||||
onSave={(at) => doRecord("end", at)}
|
||||
saving={record.isPending}
|
||||
/>
|
||||
{ended ? (
|
||||
<EditTimeButton
|
||||
label={`${phase} end`}
|
||||
value={log!.endedAt!}
|
||||
disabled={!canEnd}
|
||||
disabledReason={`You don't have permission to edit the ${phase} end`}
|
||||
minDate={new Date(log!.startedAt!)}
|
||||
onSave={(at) => doRecord("end", at)}
|
||||
saving={record.isPending}
|
||||
/>
|
||||
) : null}
|
||||
</Group>
|
||||
{!ended ? (
|
||||
) : (
|
||||
<Tooltip
|
||||
label={
|
||||
canEnd
|
||||
@@ -262,17 +267,21 @@ export function StationWorkControls({
|
||||
>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<StopCircle size={14} />}
|
||||
radius={8}
|
||||
variant="default"
|
||||
leftSection={<StopCircle size={12} color={track.amber} />}
|
||||
disabled={!canEnd}
|
||||
loading={record.isPending}
|
||||
onClick={() => doRecord("end")}
|
||||
styles={{
|
||||
root: { background: track.amberDim, borderColor: track.amberBorder },
|
||||
label: { color: track.amber, fontSize: 11.5 },
|
||||
}}
|
||||
>
|
||||
End {phase}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { Box, Group, RingProgress, Stack, Text } from "@mantine/core";
|
||||
import { ArrowRight, CircleDot, Flag, Navigation } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import { statusMeta } from "@/components/trainScheduling/scheduleVisuals";
|
||||
import { Chip } from "./trackPrimitives";
|
||||
import { track } from "./trackTheme";
|
||||
|
||||
export interface TrackStatValue {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Left-rail identity card: gradient cap (train number, progress ring, status,
|
||||
* current station) over the route strip and the stat list.
|
||||
*/
|
||||
export function TrackStatusCard({
|
||||
trainNumber,
|
||||
direction,
|
||||
status,
|
||||
progressPct,
|
||||
reached,
|
||||
totalStations,
|
||||
currentStation,
|
||||
stateLine,
|
||||
origin,
|
||||
destination,
|
||||
stats,
|
||||
}: {
|
||||
trainNumber?: string | null;
|
||||
direction?: string | null;
|
||||
status: string;
|
||||
progressPct: number;
|
||||
reached: number;
|
||||
totalStations: number;
|
||||
currentStation: string;
|
||||
stateLine: string;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
stats: TrackStatValue[];
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
background: track.surface,
|
||||
border: `1px solid ${track.border}`,
|
||||
borderRadius: 16,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Stack gap={18} p="22px 22px 20px" style={{ background: track.capGradient }}>
|
||||
<Group gap={12} align="center" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 13,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(255,255,255,0.18)",
|
||||
border: "1px solid rgba(255,255,255,0.36)",
|
||||
color: "white",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Navigation size={21} />
|
||||
</Box>
|
||||
<Stack gap={4} style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text fw={700} fz={19} c="white" lh={1.2} truncate>
|
||||
{trainNumber ?? "Train tracking"}
|
||||
</Text>
|
||||
<Text
|
||||
fz={9.5}
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: 1, color: "rgba(255,255,255,0.72)" }}
|
||||
>
|
||||
Train tracking
|
||||
</Text>
|
||||
</Stack>
|
||||
{direction ? (
|
||||
<Chip
|
||||
bg="rgba(255,255,255,0.16)"
|
||||
fg="#FFFFFF"
|
||||
border="rgba(255,255,255,0.36)"
|
||||
>
|
||||
{direction}
|
||||
</Chip>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
<Group gap={18} align="center" wrap="nowrap">
|
||||
<RingProgress
|
||||
size={104}
|
||||
thickness={9}
|
||||
roundCaps
|
||||
sections={[{ value: progressPct, color: "white" }]}
|
||||
rootColor="rgba(255,255,255,0.24)"
|
||||
label={
|
||||
<Stack gap={1} align="center">
|
||||
<Text fw={700} fz={23} lh={1} c="white">
|
||||
{Math.round(progressPct)}%
|
||||
</Text>
|
||||
<Text
|
||||
fz={8.5}
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: 0.7, color: "rgba(255,255,255,0.78)" }}
|
||||
>
|
||||
{reached}/{totalStations} stops
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<Stack gap={9} style={{ minWidth: 0, flex: 1 }}>
|
||||
<Box
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 7,
|
||||
padding: "6px 12px",
|
||||
borderRadius: 999,
|
||||
background: "white",
|
||||
width: "fit-content",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: 999,
|
||||
background: statusMeta(status).dot,
|
||||
}}
|
||||
/>
|
||||
<Text fz={10.5} fw={700} c={track.brandDark} style={{ letterSpacing: 0.6 }}>
|
||||
{status}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text
|
||||
fz={11.5}
|
||||
fw={600}
|
||||
style={{ letterSpacing: 0.4, color: "rgba(255,255,255,0.72)" }}
|
||||
>
|
||||
{stateLine}
|
||||
</Text>
|
||||
<Text fz={16} fw={700} c="white" truncate>
|
||||
{currentStation}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Group
|
||||
gap={10}
|
||||
px={20}
|
||||
py={14}
|
||||
wrap="nowrap"
|
||||
align="center"
|
||||
style={{
|
||||
background: track.surface2,
|
||||
borderBottom: `1px solid ${track.borderSoft}`,
|
||||
}}
|
||||
>
|
||||
<CircleDot size={14} color={track.brand} style={{ flexShrink: 0 }} />
|
||||
<Text size="12.5px" fw={600} c={track.text} truncate>
|
||||
{origin ?? "—"}
|
||||
</Text>
|
||||
<Box style={{ flex: 1 }} />
|
||||
<ArrowRight size={14} color={track.text3} style={{ flexShrink: 0 }} />
|
||||
<Box style={{ flex: 1 }} />
|
||||
<Text size="12.5px" fw={600} c={track.text} truncate>
|
||||
{destination ?? "—"}
|
||||
</Text>
|
||||
<Flag size={13} color={track.muted} style={{ flexShrink: 0 }} />
|
||||
</Group>
|
||||
|
||||
<Stack gap={0} px={20} pt={6} pb={14}>
|
||||
{stats.map((s, i) => {
|
||||
const Icon = s.icon;
|
||||
return (
|
||||
<Group
|
||||
key={s.label}
|
||||
gap={10}
|
||||
py={11}
|
||||
wrap="nowrap"
|
||||
align="center"
|
||||
style={i ? { borderTop: `1px solid ${track.borderSoft}` } : undefined}
|
||||
>
|
||||
<Icon size={15} color={track.muted} style={{ flexShrink: 0 }} />
|
||||
<Text size="12.5px" c={track.text2} style={{ flex: 1, minWidth: 0 }}>
|
||||
{s.label}
|
||||
</Text>
|
||||
<Text size="12.5px" fw={700} c={track.text} style={{ flexShrink: 0 }}>
|
||||
{s.value}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { Box, Group, Stack, Text } from "@mantine/core";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { StationWorkPhaseLog } from "@/types/trainScheduling";
|
||||
import { PHASE_TONE, track, type PhaseState } from "./trackTheme";
|
||||
|
||||
/** Small uppercase tag — the design's one chip shape, tinted per use. */
|
||||
export function Chip({
|
||||
children,
|
||||
bg,
|
||||
fg,
|
||||
border,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
bg: string;
|
||||
fg: string;
|
||||
border?: string;
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
component="span"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "4px 9px",
|
||||
borderRadius: 6,
|
||||
background: bg,
|
||||
border: border ? `1px solid ${border}` : undefined,
|
||||
color: fg,
|
||||
fontSize: 9.5,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.6,
|
||||
lineHeight: 1.4,
|
||||
whiteSpace: "nowrap",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Card header: tinted icon chip + title + one-line hint, optional right slot. */
|
||||
export function SectionHead({
|
||||
icon,
|
||||
title,
|
||||
hint,
|
||||
right,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
hint: string;
|
||||
right?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Group
|
||||
gap={13}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
px={24}
|
||||
py={18}
|
||||
style={{ borderBottom: `1px solid ${track.borderSoft}` }}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 11,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: track.brandDim,
|
||||
color: track.brand,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
<Stack gap={3} style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text fw={700} size="15px" c={track.text}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text size="12px" c={track.muted}>
|
||||
{hint}
|
||||
</Text>
|
||||
</Stack>
|
||||
{right}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Which of the three window states a phase log is in. */
|
||||
export function phaseState(log?: StationWorkPhaseLog | null): PhaseState {
|
||||
if (log?.endedAt) return "done";
|
||||
if (log?.startedAt) return "active";
|
||||
return "idle";
|
||||
}
|
||||
|
||||
export function phaseChipLabel(
|
||||
phase: "loading" | "unloading",
|
||||
state: PhaseState,
|
||||
) {
|
||||
const title = phase === "loading" ? "Loading" : "Unloading";
|
||||
const suffix =
|
||||
state === "done"
|
||||
? "done"
|
||||
: state === "active"
|
||||
? "in progress"
|
||||
: "not started";
|
||||
return `${title} ${suffix}`;
|
||||
}
|
||||
|
||||
export function PhaseChip({
|
||||
phase,
|
||||
state,
|
||||
}: {
|
||||
phase: "loading" | "unloading";
|
||||
state: PhaseState;
|
||||
}) {
|
||||
const tone = PHASE_TONE[state];
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "5px 10px",
|
||||
borderRadius: 7,
|
||||
background: tone.bg,
|
||||
width: 150,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 999,
|
||||
background: tone.fg,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Text size="10.5px" fw={700} c={tone.fg} style={{ lineHeight: 1.4 }}>
|
||||
{phaseChipLabel(phase, state)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Design tokens for the train-tracking surface, mirroring ui/scheule/track.pen.
|
||||
*
|
||||
* The rest of the scheduling pages key off `scheduleVisuals`/`freightBrand`;
|
||||
* tracking is its own light "work surface" palette, so the tokens live here
|
||||
* rather than widening the shared brand file. Brand green is darkened from the
|
||||
* shared #1B9E7A to #0E8C68 so label text clears AA contrast on white.
|
||||
*/
|
||||
export const track = {
|
||||
bg: "#F6F8FA",
|
||||
surface: "#FFFFFF",
|
||||
surface2: "#F4F7F9",
|
||||
surface3: "#E9EEF3",
|
||||
border: "#DCE4EC",
|
||||
borderSoft: "#E8EDF2",
|
||||
brand: "#0E8C68",
|
||||
brandDark: "#0A6B50",
|
||||
brandLight: "#12A87D",
|
||||
brandDim: "#E4F5EF",
|
||||
text: "#0F1D2B",
|
||||
text2: "#48606F",
|
||||
text3: "#9BAEBE",
|
||||
muted: "#6A8296",
|
||||
teal: "#0E8C82",
|
||||
tealDim: "#DFF3F1",
|
||||
blue: "#2563C9",
|
||||
blueDim: "#E4EDFB",
|
||||
amber: "#A66A08",
|
||||
amberDim: "#FDF2DC",
|
||||
amberBorder: "#E8C88C",
|
||||
amberText: "#8A6420",
|
||||
red: "#C43D3D",
|
||||
redDim: "#FBE9E9",
|
||||
grape: "#7C4BC4",
|
||||
grapeDim: "#F0E7FB",
|
||||
/** Status-cap wash on the left rail's identity card. */
|
||||
capGradient:
|
||||
"linear-gradient(115deg, #0A6B50 0%, #0E8C68 55%, #12A87D 100%)",
|
||||
mono: "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace",
|
||||
} as const;
|
||||
|
||||
/** Checkpoint-kind chip colors, keyed by TrainCheckpointKind. */
|
||||
export const KIND_TONE: Record<string, { bg: string; fg: string }> = {
|
||||
DEPARTED: { bg: track.blueDim, fg: track.blue },
|
||||
PASSED: { bg: track.brandDim, fg: track.brand },
|
||||
ARRIVED: { bg: track.tealDim, fg: track.teal },
|
||||
};
|
||||
|
||||
/** Loading/unloading window state chips. */
|
||||
export const PHASE_TONE = {
|
||||
done: { bg: track.surface3, fg: track.muted },
|
||||
active: { bg: track.amberDim, fg: track.amber },
|
||||
idle: { bg: track.surface2, fg: track.text3 },
|
||||
} as const;
|
||||
|
||||
export type PhaseState = keyof typeof PHASE_TONE;
|
||||
|
||||
/** Trade-direction chips in the yard-work tables. */
|
||||
export const DIRECTION_TONE: Record<string, { bg: string; fg: string }> = {
|
||||
IMPORT: { bg: track.blueDim, fg: track.blue },
|
||||
EXPORT: { bg: track.tealDim, fg: track.teal },
|
||||
DOMESTIC: { bg: track.grapeDim, fg: track.grape },
|
||||
};
|
||||
|
||||
export const cardStyle = {
|
||||
background: track.surface,
|
||||
border: `1px solid ${track.border}`,
|
||||
borderRadius: 16,
|
||||
} as const;
|
||||
@@ -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>
|
||||
|
||||
@@ -4,48 +4,31 @@ import { useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CalendarClock,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Flag,
|
||||
ListChecks,
|
||||
MapPin,
|
||||
Navigation,
|
||||
Package,
|
||||
PackageCheck,
|
||||
Pencil,
|
||||
Train,
|
||||
Route,
|
||||
TrainFront,
|
||||
} from "lucide-react";
|
||||
import { StationWorkControls } from "@/components/trainScheduling/StationWorkControls";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
RingProgress,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Timeline,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { Box, Button, Group, Loader, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { PageContainer } from "@/components/page";
|
||||
import { CheckpointTimeModal } from "@/components/trainScheduling/CheckpointTimeModal";
|
||||
import { CheckpointLogTable } from "@/components/trainScheduling/CheckpointLogTable";
|
||||
import { JourneySpine } from "@/components/trainScheduling/JourneySpine";
|
||||
import { LogPassYardWorkModal } from "@/components/trainScheduling/LogPassYardWorkModal";
|
||||
import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack";
|
||||
import { TrackStatusCard } from "@/components/trainScheduling/TrackStatusCard";
|
||||
import { Chip, SectionHead } from "@/components/trainScheduling/trackPrimitives";
|
||||
import { track as T } from "@/components/trainScheduling/trackTheme";
|
||||
import type {
|
||||
CheckpointHandlingTimes,
|
||||
TrackStation,
|
||||
TrainCheckpoint,
|
||||
} from "@/types/trainScheduling";
|
||||
import {
|
||||
RouteCorridor,
|
||||
StatusPill,
|
||||
scheduleBrand,
|
||||
} from "@/components/trainScheduling/scheduleVisuals";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
@@ -80,23 +63,6 @@ const pickHandling = (
|
||||
.filter(([, value]) => keepNulls || value !== null),
|
||||
);
|
||||
|
||||
/**
|
||||
* Total loading and unloading at a stop, the way the reports measure it:
|
||||
* earliest start to latest finish, so a stop that only loaded or only unloaded
|
||||
* still reads. Null when nothing was logged.
|
||||
*/
|
||||
const handlingHours = (cp: TrainCheckpoint): number | null => {
|
||||
const times = [cp.unloadingStartedAt, cp.loadingStartedAt]
|
||||
.filter((v): v is string => Boolean(v))
|
||||
.map((v) => new Date(v).getTime());
|
||||
const ends = [cp.loadingCompletedAt, cp.unloadingCompletedAt]
|
||||
.filter((v): v is string => Boolean(v))
|
||||
.map((v) => new Date(v).getTime());
|
||||
if (!times.length || !ends.length) return null;
|
||||
const hours = (Math.max(...ends) - Math.min(...times)) / 3_600_000;
|
||||
return Math.round(hours * 10) / 10;
|
||||
};
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
if (isAxiosError(error)) {
|
||||
const data = error.response?.data as Record<string, unknown> | undefined;
|
||||
@@ -117,85 +83,12 @@ function formatDateTime(iso?: string | null) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A single fact in the hero's glass meta strip — icon chip + uppercase label +
|
||||
* value, laid on the translucent panel over the gradient.
|
||||
*/
|
||||
function HeroStat({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 10,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(255,255,255,0.16)",
|
||||
border: "1px solid rgba(255,255,255,0.24)",
|
||||
color: "white",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
<Stack gap={1} style={{ minWidth: 0 }}>
|
||||
<Text
|
||||
size="10px"
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: 0.6, color: "rgba(255,255,255,0.72)" }}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" fw={700} c="white" truncate>
|
||||
{value}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Section header — icon chip + title + one-line hint. Shared by the cards. */
|
||||
function SectionHead({
|
||||
icon,
|
||||
title,
|
||||
hint,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
hint: string;
|
||||
}) {
|
||||
return (
|
||||
<Group gap="sm" align="center" wrap="nowrap">
|
||||
<ThemeIcon size={36} radius="md" variant="light" color="edr-green">
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text fw={800} size="sm">
|
||||
{title}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{hint}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
const CARD_STYLE = {
|
||||
borderColor: scheduleBrand.mutedBorder,
|
||||
boxShadow: scheduleBrand.shadowSm,
|
||||
} as const;
|
||||
const CARD = {
|
||||
background: T.surface,
|
||||
border: `1px solid ${T.border}`,
|
||||
borderRadius: 16,
|
||||
overflow: "hidden" as const,
|
||||
};
|
||||
|
||||
export default function TrainScheduleTrackPage() {
|
||||
const { scheduleId } = useParams<{ scheduleId: string }>();
|
||||
@@ -259,22 +152,18 @@ export default function TrainScheduleTrackPage() {
|
||||
|
||||
if (trackQuery.isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Group justify="center" py="xl">
|
||||
<Loader size="sm" color="edr-green" />
|
||||
</Group>
|
||||
</PageContainer>
|
||||
<Group justify="center" py="xl">
|
||||
<Loader size="sm" color="edr-green" />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
const track = trackQuery.data;
|
||||
if (!track || !scheduleId) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Text c="dimmed" py="xl">
|
||||
Tracking data not found.
|
||||
</Text>
|
||||
</PageContainer>
|
||||
<Text c="dimmed" py="xl" px="lg">
|
||||
Tracking data not found.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -386,27 +275,50 @@ export default function TrainScheduleTrackPage() {
|
||||
const forgottenBoarders =
|
||||
currentYard?.toLoad.filter((r) => !r.loadedAt) ?? [];
|
||||
|
||||
// The stop the operator acts on next — drives the left rail's action card.
|
||||
const nextStation = canLog
|
||||
? track.stations.find((s) => s.sequenceNo === track.currentSequenceNo + 1)
|
||||
: undefined;
|
||||
const nextIsFinal =
|
||||
nextStation?.sequenceNo === track.stations[totalStations - 1]?.sequenceNo;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Group justify="space-between" w="100%">
|
||||
<Box style={{ background: T.bg, minHeight: "100%" }}>
|
||||
{/* ── Top bar ── */}
|
||||
<Group
|
||||
gap={14}
|
||||
px={36}
|
||||
py={16}
|
||||
wrap="nowrap"
|
||||
align="center"
|
||||
style={{ background: T.surface, borderBottom: `1px solid ${T.border}` }}
|
||||
>
|
||||
<Button
|
||||
component={Link}
|
||||
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
variant="default"
|
||||
radius={9}
|
||||
size="compact-sm"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
w="fit-content"
|
||||
leftSection={<ArrowLeft size={15} />}
|
||||
>
|
||||
Back to schedule
|
||||
</Button>
|
||||
<Group gap={8} align="center" wrap="nowrap" visibleFrom="sm">
|
||||
<Text size="12.5px" c={T.muted}>
|
||||
Train scheduling
|
||||
</Text>
|
||||
<ChevronRight size={13} color={T.text3} />
|
||||
<Text size="12.5px" fw={600} c={T.text}>
|
||||
{track.trainNumber ?? "Schedule"} · Tracking
|
||||
</Text>
|
||||
</Group>
|
||||
<Box style={{ flex: 1 }} />
|
||||
{inTransit || arrived ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="lg"
|
||||
variant="default"
|
||||
radius={9}
|
||||
size="compact-sm"
|
||||
leftSection={<FileText size={16} />}
|
||||
leftSection={<FileText size={15} color={T.brand} />}
|
||||
loading={intercityMarshalling.isPending}
|
||||
onClick={() => void openIntercityMarshalling()}
|
||||
>
|
||||
@@ -415,423 +327,239 @@ export default function TrainScheduleTrackPage() {
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
{/* ── Hero: gradient wash, route + a bold progress ring woven together ── */}
|
||||
<Paper
|
||||
radius="lg"
|
||||
p={0}
|
||||
style={{ overflow: "hidden", boxShadow: scheduleBrand.shadow }}
|
||||
{/* ── Two-column work surface ── */}
|
||||
<Group
|
||||
align="flex-start"
|
||||
gap={28}
|
||||
px={36}
|
||||
pt={28}
|
||||
pb={56}
|
||||
wrap="wrap"
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
background: scheduleBrand.heroGradient,
|
||||
padding: "26px 28px",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{/* soft decorative glow, purely artistic */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -80,
|
||||
right: -60,
|
||||
width: 260,
|
||||
height: 260,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(255,255,255,0.10)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="flex-start"
|
||||
wrap="wrap"
|
||||
gap="xl"
|
||||
style={{ position: "relative" }}
|
||||
>
|
||||
{/* left — identity + route */}
|
||||
<Stack gap={14} style={{ minWidth: 260, flex: 1 }}>
|
||||
<Group gap="md" align="center" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 14,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(255,255,255,0.16)",
|
||||
border: "1px solid rgba(255,255,255,0.26)",
|
||||
color: "white",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Navigation size={26} />
|
||||
</Box>
|
||||
<Stack gap={6} style={{ minWidth: 0 }}>
|
||||
<Group gap="sm" align="center" wrap="wrap">
|
||||
<Title order={3} fw={800} c="white">
|
||||
Train tracking
|
||||
</Title>
|
||||
{track.trainNumber ? (
|
||||
<Badge
|
||||
variant="white"
|
||||
color="dark"
|
||||
radius="sm"
|
||||
styles={{ root: { color: freightBrand.primaryDark } }}
|
||||
>
|
||||
{track.trainNumber}
|
||||
</Badge>
|
||||
) : null}
|
||||
{track.direction ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
radius="sm"
|
||||
styles={{
|
||||
root: {
|
||||
color: "white",
|
||||
borderColor: "rgba(255,255,255,0.5)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{track.direction}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Box maw={380}>
|
||||
<RouteCorridor
|
||||
origin={track.origin}
|
||||
destination={track.destination}
|
||||
variant="compact"
|
||||
onDark
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Group gap="sm">
|
||||
<StatusPill status={track.status} size="md" />
|
||||
<Box
|
||||
px={12}
|
||||
py={5}
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
background: "rgba(255,255,255,0.16)",
|
||||
border: "1px solid rgba(255,255,255,0.24)",
|
||||
}}
|
||||
>
|
||||
<Text size="xs" fw={700} c="white" style={{ letterSpacing: 0.2 }}>
|
||||
{arrived
|
||||
? "Journey complete"
|
||||
: inTransit
|
||||
? `En route · ${currentStation}`
|
||||
: "Awaiting dispatch"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
{/* right — progress ring, the artistic focal point */}
|
||||
<RingProgress
|
||||
size={132}
|
||||
thickness={11}
|
||||
roundCaps
|
||||
sections={[{ value: clampedPct, color: "white" }]}
|
||||
rootColor="rgba(255,255,255,0.22)"
|
||||
label={
|
||||
<Stack gap={0} align="center">
|
||||
<Text fw={800} fz={26} lh={1} c="white">
|
||||
{Math.round(clampedPct)}%
|
||||
</Text>
|
||||
<Text
|
||||
size="10px"
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: 0.6, color: "rgba(255,255,255,0.8)" }}
|
||||
>
|
||||
{reached}/{totalStations} stops
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
{/* glass meta strip below the wash */}
|
||||
<Group
|
||||
justify="space-between"
|
||||
wrap="wrap"
|
||||
gap="lg"
|
||||
px={28}
|
||||
py="md"
|
||||
style={{
|
||||
background: freightBrand.primaryDark,
|
||||
borderTop: "1px solid rgba(255,255,255,0.12)",
|
||||
}}
|
||||
>
|
||||
<HeroStat icon={<MapPin size={16} />} label="Current" value={currentStation} />
|
||||
<HeroStat
|
||||
icon={<CalendarClock size={16} />}
|
||||
label="Departed"
|
||||
value={formatDateTime(track.actualDepartureAt)}
|
||||
/>
|
||||
<HeroStat
|
||||
icon={<Flag size={16} />}
|
||||
label="Arrived"
|
||||
value={formatDateTime(track.actualArrivalAt)}
|
||||
/>
|
||||
<HeroStat
|
||||
icon={<Train size={16} />}
|
||||
label="Stations"
|
||||
value={`${reached} of ${totalStations}`}
|
||||
/>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{/* ── Route corridor ── */}
|
||||
<Paper radius="lg" p="lg" withBorder style={CARD_STYLE}>
|
||||
<Stack gap="md">
|
||||
<SectionHead
|
||||
icon={<Navigation size={17} />}
|
||||
title="Route corridor"
|
||||
hint={
|
||||
canLog
|
||||
? "Log the train passing each station; the final station marks arrival."
|
||||
: arrived
|
||||
? "This train has arrived at its destination."
|
||||
: "Tracking becomes available once the train is dispatched."
|
||||
{/* left rail */}
|
||||
<Stack gap={16} style={{ width: 352, flexShrink: 0, flexGrow: 1, maxWidth: "100%" }}>
|
||||
<TrackStatusCard
|
||||
trainNumber={track.trainNumber}
|
||||
direction={track.direction}
|
||||
status={track.status}
|
||||
progressPct={clampedPct}
|
||||
reached={reached}
|
||||
totalStations={totalStations}
|
||||
currentStation={currentStation}
|
||||
stateLine={
|
||||
arrived
|
||||
? "Journey complete"
|
||||
: inTransit
|
||||
? "En route"
|
||||
: "Awaiting dispatch"
|
||||
}
|
||||
/>
|
||||
<RouteCorridorTrack
|
||||
stations={track.stations}
|
||||
currentSequenceNo={track.currentSequenceNo}
|
||||
checkpoints={track.checkpoints}
|
||||
canLog={canLog}
|
||||
loggingSeq={
|
||||
recordCheckpoint.isPending
|
||||
? recordCheckpoint.variables?.payload.sequenceNo
|
||||
: null
|
||||
}
|
||||
onLogCheckpoint={handleLog}
|
||||
onEditCheckpoint={canEdit ? setEditModal : undefined}
|
||||
origin={track.origin}
|
||||
destination={track.destination}
|
||||
stats={[
|
||||
{
|
||||
icon: CalendarClock,
|
||||
label: "Departed",
|
||||
value: formatDateTime(track.actualDepartureAt),
|
||||
},
|
||||
{
|
||||
icon: Flag,
|
||||
label: "Arrived",
|
||||
value: formatDateTime(track.actualArrivalAt),
|
||||
},
|
||||
{ icon: MapPin, label: "Current station", value: currentStation },
|
||||
{
|
||||
icon: TrainFront,
|
||||
label: "Stations reached",
|
||||
value: `${reached} of ${totalStations}`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Cargo the operator forgot: boarders at the CURRENT station stay
|
||||
loadable until the next pass is logged. */}
|
||||
{currentStationObj && forgottenBoarders.length > 0 ? (
|
||||
<Alert
|
||||
color="yellow"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<PackageCheck size={16} />}
|
||||
title={`${forgottenBoarders.length} booking${
|
||||
forgottenBoarders.length === 1 ? "" : "s"
|
||||
} at ${currentStationObj.label} not loaded yet`}
|
||||
>
|
||||
<Group justify="space-between" align="center" wrap="wrap" gap="sm">
|
||||
<Text size="sm">
|
||||
The train is at {currentStationObj.label} — cargo boarding here can
|
||||
still be loaded before the next station is logged.
|
||||
{/* next action */}
|
||||
{nextStation ? (
|
||||
<Stack gap={14} p={18} style={CARD}>
|
||||
<Group gap={9} align="center" wrap="nowrap">
|
||||
<Text
|
||||
size="9.5px"
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
c={T.muted}
|
||||
style={{ letterSpacing: 1 }}
|
||||
>
|
||||
Next action
|
||||
</Text>
|
||||
<Box style={{ flex: 1 }} />
|
||||
<Chip bg={T.surface3} fg={T.text2}>
|
||||
{`STOP ${reached + 1} OF ${totalStations}`}
|
||||
</Chip>
|
||||
</Group>
|
||||
<Text size="15px" fw={700} c={T.text} lh={1.3}>
|
||||
{nextIsFinal
|
||||
? `Mark arrived at ${nextStation.label}`
|
||||
: `Log pass at ${nextStation.label}`}
|
||||
</Text>
|
||||
<Text size="12px" c={T.text2} lh={1.45}>
|
||||
{nextIsFinal
|
||||
? "Marks the train arrived: remaining bookings arrive, assets are freed."
|
||||
: "Logging the pass marks arriving bookings and unlocks loading for cargo boarding here."}
|
||||
</Text>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius={9}
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="yellow"
|
||||
style={{ flex: 1 }}
|
||||
leftSection={nextIsFinal ? <Flag size={14} /> : <MapPin size={14} />}
|
||||
loading={
|
||||
recordCheckpoint.isPending &&
|
||||
recordCheckpoint.variables?.payload.sequenceNo ===
|
||||
nextStation.sequenceNo
|
||||
}
|
||||
onClick={() => handleLog(nextStation.sequenceNo)}
|
||||
>
|
||||
{nextIsFinal ? "Mark arrived" : "Log pass"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
radius={9}
|
||||
size="compact-sm"
|
||||
leftSection={<Package size={14} />}
|
||||
onClick={() =>
|
||||
setYardModal({
|
||||
station: currentStationObj,
|
||||
isFinal:
|
||||
currentStationObj.sequenceNo ===
|
||||
track.stations[totalStations - 1]?.sequenceNo,
|
||||
alreadyLogged: true,
|
||||
station: nextStation,
|
||||
isFinal: Boolean(nextIsFinal),
|
||||
alreadyLogged: false,
|
||||
})
|
||||
}
|
||||
>
|
||||
Open yard work
|
||||
Yard work
|
||||
</Button>
|
||||
</Group>
|
||||
</Alert>
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
{/* forgotten boarders */}
|
||||
{currentStationObj && forgottenBoarders.length > 0 ? (
|
||||
<Stack
|
||||
gap={11}
|
||||
p={16}
|
||||
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}>
|
||||
{forgottenBoarders.length} booking
|
||||
{forgottenBoarders.length === 1 ? "" : "s"} not loaded
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="11.5px" c={T.amberText} lh={1.45}>
|
||||
The train is at {currentStationObj.label} — cargo boarding here can still
|
||||
be loaded before the next station is logged.
|
||||
</Text>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
radius={9}
|
||||
variant="white"
|
||||
w="fit-content"
|
||||
styles={{
|
||||
root: { borderColor: T.amberBorder, border: `1px solid ${T.amberBorder}` },
|
||||
label: { color: T.amber, fontWeight: 700, fontSize: 12.5 },
|
||||
}}
|
||||
onClick={() =>
|
||||
setYardModal({
|
||||
station: currentStationObj,
|
||||
isFinal:
|
||||
currentStationObj.sequenceNo ===
|
||||
track.stations[totalStations - 1]?.sequenceNo,
|
||||
alreadyLogged: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
Open yard work
|
||||
</Button>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* ── Loading / unloading windows per station ── */}
|
||||
<Paper radius="lg" p="lg" withBorder style={CARD_STYLE}>
|
||||
<SectionHead
|
||||
icon={<Clock size={17} />}
|
||||
title="Loading & unloading windows"
|
||||
hint="Start and end each station's work window — times, duration and who recorded them"
|
||||
/>
|
||||
<Stack gap="sm" mt="md">
|
||||
{track.stations.map((s, i) => {
|
||||
const isFirst = i === 0;
|
||||
const isLast = i === track.stations.length - 1;
|
||||
const workLog = track.stationWorkLogs?.[s.yardId];
|
||||
return (
|
||||
<Paper
|
||||
key={s.yardId}
|
||||
withBorder
|
||||
radius="md"
|
||||
p="sm"
|
||||
style={{
|
||||
background:
|
||||
track.currentSequenceNo === s.sequenceNo
|
||||
? "var(--mantine-color-green-0)"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<Group gap={10} mb={6} wrap="nowrap">
|
||||
<ThemeIcon size={30} radius="xl" variant="light" color="edr-green">
|
||||
{isLast ? <Flag size={15} /> : <MapPin size={15} />}
|
||||
</ThemeIcon>
|
||||
<Text fw={700} size="sm">
|
||||
{s.label}
|
||||
</Text>
|
||||
{isFirst ? (
|
||||
<Badge size="xs" variant="light" color="edr-green">
|
||||
origin
|
||||
</Badge>
|
||||
) : null}
|
||||
{isLast ? (
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
destination
|
||||
</Badge>
|
||||
) : null}
|
||||
{track.currentSequenceNo === s.sequenceNo ? (
|
||||
<Badge size="xs" variant="filled" color="edr-green">
|
||||
train here
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Stack gap={6} pl={40}>
|
||||
{!isLast ? (
|
||||
<StationWorkControls
|
||||
scheduleId={scheduleId ?? ""}
|
||||
yardId={s.yardId}
|
||||
phase="loading"
|
||||
log={workLog?.loading}
|
||||
/>
|
||||
) : null}
|
||||
{!isFirst ? (
|
||||
<StationWorkControls
|
||||
scheduleId={scheduleId ?? ""}
|
||||
yardId={s.yardId}
|
||||
phase="unloading"
|
||||
log={workLog?.unloading}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* ── Checkpoint log ── */}
|
||||
<Paper radius="lg" p="lg" withBorder style={CARD_STYLE}>
|
||||
<Group justify="space-between" wrap="nowrap" mb="md">
|
||||
<SectionHead
|
||||
icon={<CheckCircle2 size={17} />}
|
||||
title="Checkpoint log"
|
||||
hint={`${track.checkpoints.length} event${
|
||||
track.checkpoints.length === 1 ? "" : "s"
|
||||
} recorded`}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{track.checkpoints.length === 0 ? (
|
||||
<Stack
|
||||
align="center"
|
||||
gap="xs"
|
||||
py={40}
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: `1px dashed ${scheduleBrand.mutedBorder}`,
|
||||
background: scheduleBrand.softSurface,
|
||||
}}
|
||||
>
|
||||
<ThemeIcon size={48} radius="xl" variant="light" color="edr-green">
|
||||
<MapPin size={22} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={700} c="gray.7">
|
||||
No checkpoints yet
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ta="center" maw={320}>
|
||||
Each station the train passes will be logged here with its
|
||||
timestamp.
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Timeline
|
||||
active={track.checkpoints.length}
|
||||
bulletSize={24}
|
||||
lineWidth={2}
|
||||
color="edr-green"
|
||||
>
|
||||
{track.checkpoints.map((cp) => (
|
||||
<Timeline.Item
|
||||
key={cp.id}
|
||||
bullet={
|
||||
cp.kind === "ARRIVED" ? (
|
||||
<CheckCircle2 size={13} />
|
||||
) : (
|
||||
<MapPin size={12} />
|
||||
)
|
||||
}
|
||||
title={
|
||||
<Group gap="sm" justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm">
|
||||
<Text fw={700} size="sm">
|
||||
{cp.label ?? `Station ${cp.sequenceNo}`}
|
||||
{/* main column */}
|
||||
<Stack gap={20} style={{ flex: 1, minWidth: 520 }}>
|
||||
<Box style={CARD}>
|
||||
<SectionHead
|
||||
icon={<Route size={17} />}
|
||||
title="Journey & station work"
|
||||
hint={
|
||||
canLog
|
||||
? "Every stop with its pass time and loading windows — the final station marks arrival."
|
||||
: arrived
|
||||
? "This train has arrived at its destination."
|
||||
: "Tracking becomes available once the train is dispatched."
|
||||
}
|
||||
right={
|
||||
<Group gap={12} wrap="nowrap" visibleFrom="md">
|
||||
{[
|
||||
[T.brand, "Passed"],
|
||||
[T.amber, "Active"],
|
||||
[T.text3, "Upcoming"],
|
||||
].map(([color, label]) => (
|
||||
<Group key={label} gap={5} wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: 999,
|
||||
background: color,
|
||||
}}
|
||||
/>
|
||||
<Text size="11px" fw={600} c={T.muted}>
|
||||
{label}
|
||||
</Text>
|
||||
<Badge
|
||||
size="xs"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color={
|
||||
cp.kind === "ARRIVED"
|
||||
? "teal"
|
||||
: cp.kind === "DEPARTED"
|
||||
? "blue"
|
||||
: "edr-green"
|
||||
}
|
||||
>
|
||||
{cp.kind}
|
||||
</Badge>
|
||||
</Group>
|
||||
{canEdit ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="gray"
|
||||
leftSection={<Pencil size={12} />}
|
||||
onClick={() => setEditModal(cp)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatDateTime(cp.occurredAt)}
|
||||
</Text>
|
||||
{handlingHours(cp) !== null ? (
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
Loading + unloading {handlingHours(cp)} h
|
||||
</Text>
|
||||
) : null}
|
||||
{cp.note ? (
|
||||
<Text size="xs" mt={2}>
|
||||
{cp.note}
|
||||
</Text>
|
||||
) : null}
|
||||
</Timeline.Item>
|
||||
))}
|
||||
</Timeline>
|
||||
)}
|
||||
</Paper>
|
||||
))}
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
<JourneySpine
|
||||
scheduleId={scheduleId}
|
||||
stations={track.stations}
|
||||
currentSequenceNo={track.currentSequenceNo}
|
||||
checkpoints={track.checkpoints}
|
||||
stationWorkLogs={track.stationWorkLogs}
|
||||
canLog={canLog}
|
||||
loggingSeq={
|
||||
recordCheckpoint.isPending
|
||||
? recordCheckpoint.variables?.payload.sequenceNo
|
||||
: null
|
||||
}
|
||||
onLogCheckpoint={handleLog}
|
||||
onEditCheckpoint={canEdit ? setEditModal : undefined}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box style={CARD}>
|
||||
<SectionHead
|
||||
icon={<ListChecks size={16} />}
|
||||
title="Checkpoint log"
|
||||
hint="Raw event trail — every logged pass with its correction history"
|
||||
right={
|
||||
<Chip bg={T.surface3} fg={T.text2}>
|
||||
{`${track.checkpoints.length} EVENT${
|
||||
track.checkpoints.length === 1 ? "" : "S"
|
||||
}`}
|
||||
</Chip>
|
||||
}
|
||||
/>
|
||||
<CheckpointLogTable
|
||||
checkpoints={track.checkpoints}
|
||||
onEdit={canEdit ? setEditModal : undefined}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<CheckpointTimeModal
|
||||
opened={logModal !== null}
|
||||
@@ -875,6 +603,6 @@ export default function TrainScheduleTrackPage() {
|
||||
isFinal={yardModal?.isFinal ?? false}
|
||||
alreadyLogged={yardModal?.alreadyLogged ?? false}
|
||||
/>
|
||||
</PageContainer>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -135,14 +135,8 @@ 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);
|
||||
// Loading is manual: dispatch decides the fate of every unloaded origin
|
||||
// boarder — checked = loaded and departs, unchecked = left behind (wagon
|
||||
// freed, booking back to the pool). Default unchecked; government bookings
|
||||
// cannot be removed from a train so they are forced on.
|
||||
const [dispatchLoadedIds, setDispatchLoadedIds] = useState<Set<string>>(new Set());
|
||||
const openDispatchConfirm = () => {
|
||||
setDispatchAt(new Date());
|
||||
setDispatchLoadedIds(new Set());
|
||||
setDispatchConfirmOpen(true);
|
||||
};
|
||||
const [switchTarget, setSwitchTarget] = useState<EligibleContainerBooking | null>(null);
|
||||
@@ -473,9 +467,8 @@ export default function TrainScheduleV2DetailPage() {
|
||||
// per yard from the track page's log-pass flow. Everything below is advisory.
|
||||
const hasDispatchWarnings =
|
||||
unassignedCount > 0 || unloadedCount > 0 || intercityNotLoadedCount > 0;
|
||||
// Unloaded boarders at the TRAIN's origin — the dispatch dialog's manual
|
||||
// load/leave list. Mirrors the API's unloadedOriginBoarderIds predicate
|
||||
// (plus government, which is shown but forced-loaded).
|
||||
// Unloaded boarders at the TRAIN's origin — all sent as loaded on dispatch.
|
||||
// Mirrors the API's unloadedOriginBoarderIds predicate (plus government).
|
||||
const originYardId = schedule.originStation?.id;
|
||||
const pendingOriginBoarders = dispatchBookings.filter(
|
||||
(b) =>
|
||||
@@ -489,26 +482,18 @@ export default function TrainScheduleV2DetailPage() {
|
||||
// Shipping-line bookings ride from accept on the credit ledger.
|
||||
(Boolean(b.shippingLineCompanyId) && b.status === "FULLY_EXECUTED")),
|
||||
);
|
||||
const dispatchLeftCount = pendingOriginBoarders.filter(
|
||||
(b) => !b.isGovernment && !dispatchLoadedIds.has(b.id),
|
||||
).length;
|
||||
// Origin loading time window: dispatch (which marks the ticked boarders
|
||||
// loaded) is server-rejected until "Start loading" was clicked for the
|
||||
// origin yard, so the button mirrors that gate.
|
||||
// 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.
|
||||
const originLoadingLog = originYardId
|
||||
? schedule.stationWorkLogs?.[originYardId]?.loading
|
||||
: undefined;
|
||||
const originLoadingStarted = Boolean(originLoadingLog?.startedAt);
|
||||
const originLoadingEnded = Boolean(originLoadingLog?.endedAt);
|
||||
const dispatchBoardersKept = pendingOriginBoarders.some(
|
||||
(b) => b.isGovernment || dispatchLoadedIds.has(b.id),
|
||||
);
|
||||
const dispatchNeedsLoadingStart = dispatchBoardersKept && !originLoadingStarted;
|
||||
// A train never departs mid-loading: once the window opened (or cargo is to
|
||||
// board), it must be ENDED before dispatch — same gate the server enforces.
|
||||
const dispatchNeedsLoadingEnd =
|
||||
(dispatchBoardersKept || originLoadingStarted) && !originLoadingEnded;
|
||||
const dispatchBlockedByLoading = dispatchNeedsLoadingStart || dispatchNeedsLoadingEnd;
|
||||
// Dispatch requires the origin's loading window to be COMPLETE (started AND
|
||||
// ended): not started → disabled, in progress → disabled, ended → active.
|
||||
// Same gate the server enforces.
|
||||
const dispatchBlockedByLoading = !originLoadingEnded;
|
||||
|
||||
const finalizeStep = hasContainerStep ? 3 : 2;
|
||||
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
|
||||
@@ -562,9 +547,9 @@ export default function TrainScheduleV2DetailPage() {
|
||||
id: scheduleId,
|
||||
payload: {
|
||||
...(dispatchAt ? { actualDepartureAt: dispatchAt.toISOString() } : {}),
|
||||
loadedBookingIds: pendingOriginBoarders
|
||||
.filter((b) => b.isGovernment || dispatchLoadedIds.has(b.id))
|
||||
.map((b) => b.id),
|
||||
// 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),
|
||||
},
|
||||
});
|
||||
await openMarshallingDocument({
|
||||
@@ -967,15 +952,11 @@ export default function TrainScheduleV2DetailPage() {
|
||||
phase="loading"
|
||||
log={originLoadingLog}
|
||||
/>
|
||||
{dispatchNeedsLoadingStart ? (
|
||||
{dispatchBlockedByLoading ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Start loading before dispatching — the ticked bookings are marked
|
||||
loaded at dispatch, which needs an open loading window.
|
||||
</Text>
|
||||
) : dispatchNeedsLoadingEnd ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
End the loading window before dispatching — a train never departs
|
||||
mid-loading.
|
||||
{originLoadingStarted
|
||||
? "End the loading window before dispatching — a train never departs mid-loading."
|
||||
: "Start and end the loading window before dispatching — dispatch needs a completed loading window."}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
@@ -1599,56 +1580,6 @@ export default function TrainScheduleV2DetailPage() {
|
||||
radius="md"
|
||||
/>
|
||||
|
||||
{pendingOriginBoarders.length > 0 ? (
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" fw={700}>
|
||||
Cargo boarding at {schedule.originStation?.label ?? "the origin yard"} —
|
||||
tick what was loaded
|
||||
</Text>
|
||||
{originYardId ? (
|
||||
<StationWorkControls
|
||||
scheduleId={scheduleId}
|
||||
yardId={originYardId}
|
||||
phase="loading"
|
||||
log={originLoadingLog}
|
||||
/>
|
||||
) : null}
|
||||
<Text size="xs" c="dimmed">
|
||||
Unticked bookings are left behind: removed from this train, their
|
||||
wagons freed, and the booking returned to the pool for a later
|
||||
schedule. The customer is notified.
|
||||
</Text>
|
||||
<Stack gap={6} mah={220} style={{ overflowY: "auto" }}>
|
||||
{pendingOriginBoarders.map((b) => (
|
||||
<Checkbox
|
||||
key={b.id}
|
||||
size="sm"
|
||||
checked={b.isGovernment || dispatchLoadedIds.has(b.id)}
|
||||
disabled={b.isGovernment}
|
||||
onChange={(e) => {
|
||||
const next = new Set(dispatchLoadedIds);
|
||||
if (e.currentTarget.checked) next.add(b.id);
|
||||
else next.delete(b.id);
|
||||
setDispatchLoadedIds(next);
|
||||
}}
|
||||
label={
|
||||
<Text size="sm" span>
|
||||
{b.reference ?? b.id.slice(0, 8)} — {b.customer ?? "Unknown customer"}
|
||||
{b.isGovernment ? " (government — always rides)" : ""}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
{dispatchLeftCount > 0 ? (
|
||||
<Text size="xs" c="orange.7" fw={600}>
|
||||
{dispatchLeftCount} booking{dispatchLeftCount === 1 ? "" : "s"} will
|
||||
be left behind and returned to the booking pool.
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
{hasDispatchWarnings ? (
|
||||
<Alert
|
||||
color="orange"
|
||||
@@ -1711,9 +1642,9 @@ export default function TrainScheduleV2DetailPage() {
|
||||
</Button>
|
||||
<Tooltip
|
||||
label={
|
||||
dispatchNeedsLoadingStart
|
||||
? "Start loading at the origin station first — dispatch marks the ticked bookings loaded"
|
||||
: "End the loading window at the origin station first — a train never departs mid-loading"
|
||||
originLoadingStarted
|
||||
? "End the loading window at the origin station first — a train never departs mid-loading"
|
||||
: "Start and end the loading window at the origin station first — dispatch needs a completed loading window"
|
||||
}
|
||||
disabled={!dispatchBlockedByLoading}
|
||||
>
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
FileUp,
|
||||
Flame,
|
||||
MapPin,
|
||||
MoveRight,
|
||||
Package,
|
||||
Receipt,
|
||||
Repeat,
|
||||
@@ -237,6 +239,19 @@ export default function NewShipmentPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. 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" {
|
||||
@@ -434,6 +449,7 @@ function NewShipmentBookingForm({
|
||||
contract.freightType === "CONTAINER" &&
|
||||
contract.equipmentReturn === "WITH_RETURN",
|
||||
unitOfMeasure: bulkUnitOfMeasure(contract),
|
||||
maxTonsPerWagon: bulkMaxTonsPerWagon(contract),
|
||||
// Intercity rides a passing train staff pick later — no date to choose.
|
||||
requiresDate: contract.tradeDirection !== "DOMESTIC",
|
||||
// Export completion locks onto a specific train — the pick is required
|
||||
@@ -688,20 +704,20 @@ function NewShipmentBookingForm({
|
||||
<Title
|
||||
order={1}
|
||||
fw={800}
|
||||
fz={26}
|
||||
fz={28}
|
||||
style={{ letterSpacing: "-0.01em" }}
|
||||
>
|
||||
{completeBookingId
|
||||
? isResubmit
|
||||
? "Change Your Booking"
|
||||
: "Complete Your Booking"
|
||||
? "Change shipment booking"
|
||||
: "Complete shipment booking"
|
||||
: "New Shipment Booking"}
|
||||
</Title>
|
||||
<Text size="sm" c="edr-muted" mt={4}>
|
||||
{completeBookingId
|
||||
? isResubmit
|
||||
? `Update the details below and pick a new shipment day, then resubmit your booking under contract ${contract.reference}.`
|
||||
: `Clearance is finalized — enter the cargo details and shipment day to complete your booking under contract ${contract.reference}.`
|
||||
: `Clearance is finalized. Enter cargo details and the binding shipment day to complete ${completeBooking?.reference ?? "this booking"} under contract ${contract.reference}.`
|
||||
: `Book a shipment against contract ${contract.reference}.`}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -791,17 +807,6 @@ function NewShipmentBookingForm({
|
||||
}}
|
||||
>
|
||||
<Box className="mx-auto max-w-4xl">
|
||||
{showValidationSummary ? (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertCircle size={16} />}
|
||||
mb="sm"
|
||||
>
|
||||
Fix the highlighted fields before reviewing the price.
|
||||
</Alert>
|
||||
) : null}
|
||||
{blockOdd20ft ? (
|
||||
<Alert
|
||||
color="red"
|
||||
@@ -823,16 +828,42 @@ function NewShipmentBookingForm({
|
||||
{`${ft20Total} is an odd number of 20ft containers — this booking will be paired with another customer's odd booking to share a wagon, or held until one is available.`}
|
||||
</Alert>
|
||||
) : null}
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
type="button"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Receipt size={16} />}
|
||||
onClick={handleReview}
|
||||
>
|
||||
{isResubmit ? "Change booking" : "Review price & book"}
|
||||
</Button>
|
||||
<Group justify="space-between" wrap="nowrap" gap="md">
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
{showValidationSummary && (
|
||||
<>
|
||||
<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
|
||||
type="button"
|
||||
variant="default"
|
||||
radius="md"
|
||||
onClick={() =>
|
||||
navigate(`/contracts/${contract.id}`)
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Receipt size={16} />}
|
||||
onClick={handleReview}
|
||||
>
|
||||
{isResubmit ? "Change booking" : "Review price & book"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -1212,15 +1243,45 @@ function RouteStep({
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Paper withBorder radius="md" p="md" style={{ borderColor: "#E6ECF2" }}>
|
||||
<Text fz={14} fw={600} c="#10202F">
|
||||
{routes[0]?.originYard?.label ?? "—"} →{" "}
|
||||
{routes[0]?.destinationYard?.label ?? "—"}
|
||||
</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">
|
||||
{routes[0]?.originYard?.label ?? "—"}
|
||||
</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">
|
||||
{routes[0]?.destinationYard?.label ?? "—"}
|
||||
</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>
|
||||
);
|
||||
@@ -1300,8 +1361,16 @@ function ScheduleStep({
|
||||
const selectedTrainId = form.watch("trainScheduleId");
|
||||
const isExportPick =
|
||||
contract.tradeDirection === "EXPORT" && Boolean(completeBookingId);
|
||||
const requestedWagonsValue = form.watch("requestedWagons");
|
||||
const wagonsEstimate = useMemo(() => {
|
||||
if (contract.freightType !== "CONTAINER") return undefined;
|
||||
// NUMBER_OF_WAGONS bulk states its wagon count outright — pass it through
|
||||
// so the train picker sizes fits/free against the real need instead of
|
||||
// falling back to the server's tonnage estimate.
|
||||
if (contract.freightType !== "CONTAINER") {
|
||||
if (bulkUnitOfMeasure(contract) !== "NUMBER_OF_WAGONS") return undefined;
|
||||
const wagons = Math.floor(Number(requestedWagonsValue || 0));
|
||||
return wagons >= 1 ? wagons : undefined;
|
||||
}
|
||||
const lines = containerLines ?? [];
|
||||
const ft20 = lines
|
||||
.filter((l) => l.containerSize === "20ft")
|
||||
@@ -1311,7 +1380,7 @@ function ScheduleStep({
|
||||
.reduce((s, l) => s + Number(l.quantity || 0), 0);
|
||||
const wagons = Math.ceil(ft20 / 2) + ft40;
|
||||
return wagons > 0 ? wagons : undefined;
|
||||
}, [contract.freightType, containerLines]);
|
||||
}, [contract, containerLines, requestedWagonsValue]);
|
||||
const exportTrainsQuery = useQuery({
|
||||
...api.bookings.getExportTrains.queryOptions({
|
||||
input: {
|
||||
|
||||
@@ -25,6 +25,13 @@ export interface ShipmentValidationContext {
|
||||
*/
|
||||
withReturnService?: boolean;
|
||||
unitOfMeasure?: "PER_TON" | "PER_ITEM" | "NUMBER_OF_WAGONS";
|
||||
/**
|
||||
* NUMBER_OF_WAGONS: the most tons one wagon of this cargo may carry. The
|
||||
* requested count must spread the tonnage no heavier than this, or the
|
||||
* server rejects the booking (assertWagonShareFits). Undefined when the
|
||||
* cargo type has no wagon type configured — the check then falls away.
|
||||
*/
|
||||
maxTonsPerWagon?: number | null;
|
||||
/**
|
||||
* Intercity (DOMESTIC) shipments ride a passing import/export train that
|
||||
* staff pick later, so no shipment day is chosen. Defaults to true.
|
||||
@@ -312,6 +319,23 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
|
||||
path: ["requestedWagons"],
|
||||
message: "Enter the number of wagons needed (at least 1).",
|
||||
});
|
||||
} else {
|
||||
// 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.
|
||||
const tons = Number(data.cargoWeightTons || 0);
|
||||
const maxPerWagon = Number(ctx.maxTonsPerWagon || 0);
|
||||
if (tons > 0 && maxPerWagon > 0 && tons / wagons > maxPerWagon) {
|
||||
refineCtx.addIssue({
|
||||
code: "custom",
|
||||
path: ["requestedWagons"],
|
||||
message:
|
||||
`${tons} tons across ${wagons} wagon${wagons === 1 ? "" : "s"} loads ` +
|
||||
`${Math.round((tons / wagons) * 1000) / 1000}T per wagon, but a wagon of ` +
|
||||
`this cargo carries at most ${maxPerWagon}T — request at least ` +
|
||||
`${Math.ceil(tons / maxPerWagon)} wagons.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user