mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 22:58:17 +00:00
tracking ui
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user