Merge branch 'dev' into freight/nati-2

This commit is contained in:
Nathnael
2026-08-28 11:08:35 +00:00
93 changed files with 5301 additions and 2035 deletions

View File

@@ -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

View File

@@ -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>

View File

@@ -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>
);
})}

View File

@@ -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>
);
}

View File

@@ -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}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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`,

View File

@@ -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&apos;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>

View File

@@ -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>
);
}

View File

@@ -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}
>

View File

@@ -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",

View File

@@ -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. */

View File

@@ -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[]
> => {

View File

@@ -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. */