diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/CheckpointLogTable.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/CheckpointLogTable.tsx
new file mode 100644
index 000000000..1b3ff148e
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/CheckpointLogTable.tsx
@@ -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 (
+
+
+
+
+
+ No checkpoints yet
+
+
+ Each station the train passes will be logged here with its timestamp.
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ Station
+ Event
+ Time
+ Handling
+ Note
+
+
+
+
+ {checkpoints.map((cp) => {
+ const hours = handlingHours(cp);
+ const tone = KIND_TONE[cp.kind] ?? KIND_TONE.PASSED;
+ return (
+
+
+
+
+
+
+
+ {cp.label ?? `Station ${cp.sequenceNo}`}
+
+
+
+
+
+ {cp.kind}
+
+
+
+
+ {fmt(cp.occurredAt)}
+
+
+
+
+ {hours === null ? "—" : `${hours} h`}
+
+
+
+
+ {cp.note || "—"}
+
+
+
+ {onEdit ? (
+
+ }
+ onClick={() => onEdit(cp)}
+ >
+ Edit
+
+
+ ) : null}
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/CheckpointTimeModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/CheckpointTimeModal.tsx
index 1f8f915de..e6df245b0 100644
--- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/CheckpointTimeModal.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/CheckpointTimeModal.tsx
@@ -98,7 +98,7 @@ export function CheckpointTimeModal({
onClose={onClose}
centered
fullScreen={isSmallScreen}
- radius="lg"
+ radius={18}
title={
{icon}
@@ -137,10 +137,11 @@ export function CheckpointTimeModal({
radius="md"
/>
-
+ ) : isNext ? (
+ : }
+ loading={loggingSeq === station.sequenceNo}
+ onClick={() => onLogCheckpoint?.(station.sequenceNo)}
+ >
+ {isLast ? "Mark arrived" : "Log pass"}
+
+ ) : (
+
+ {isLast ? "Mark arrived" : "Log pass"}
+
+ )}
+
+
+ {hours !== null || checkpoint?.note ? (
+
+ {hours !== null ? (
+ <>
+
+
+ {hours} h handling
+
+ >
+ ) : null}
+ {hours !== null && checkpoint?.note ? (
+
+ ) : null}
+ {checkpoint?.note ? (
+
+ {checkpoint.note}
+
+ ) : null}
+
+ ) : null}
+
+ {/* the station's work windows, inline */}
+
+ {!isFirst ? (
+
+ ) : null}
+ {!isLast ? (
+
+ ) : null}
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/LogPassYardWorkModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/LogPassYardWorkModal.tsx
index 56da3f1ab..d09cf06c8 100644
--- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/LogPassYardWorkModal.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/LogPassYardWorkModal.tsx
@@ -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 = {
- IMPORT: "blue",
- EXPORT: "teal",
- DOMESTIC: "violet",
-};
-
/** DOMESTIC displays as "Intercity" — shared with the rest of the platform. */
const DIRECTION_LABELS: Record = Freight.TRADE_DIRECTION_LABELS;
function DirectionChip({ direction }: { direction: string }) {
+ const tone = DIRECTION_TONE[direction] ?? { bg: T.surface3, fg: T.muted };
return (
-
+
{DIRECTION_LABELS[direction] ?? direction}
-
+
);
}
@@ -72,15 +68,15 @@ function SectionLabel({
}) {
return (
-
+
{icon}
-
+
{title}
-
- {count}
-
+
+ {String(count)}
+
);
}
@@ -263,7 +259,7 @@ export function LogPassYardWorkModal({
opened={opened}
onClose={onClose}
size="xl"
- radius="lg"
+ radius={18}
title={
{isFinal ? : }
@@ -271,9 +267,9 @@ export function LogPassYardWorkModal({
{isFinal ? "Arrival" : "Yard work"} — {station?.label ?? ""}
{logged ? (
-
- {isFinal ? "Arrived" : "Pass logged"}
-
+
+ {isFinal ? "ARRIVED" : "PASS LOGGED"}
+
) : null}
}
@@ -316,7 +312,20 @@ export function LogPassYardWorkModal({
) : null}
-
+
Booking
@@ -331,12 +340,12 @@ export function LogPassYardWorkModal({
{arrivals.map((row) => (
-
+
{row.reference ?? row.id.slice(0, 8)}
- {row.customer}
+ {row.customer}
@@ -364,6 +373,7 @@ export function LogPassYardWorkModal({
>
}
@@ -417,7 +427,20 @@ export function LogPassYardWorkModal({
) : null}
-
+
Booking
@@ -433,18 +456,18 @@ export function LogPassYardWorkModal({
-
+
{row.reference ?? row.id.slice(0, 8)}
{row.isGovernment ? (
-
+
GOV
-
+
) : null}
- {row.customer}
+ {row.customer}
@@ -487,7 +510,8 @@ export function LogPassYardWorkModal({
>
}
disabled={!canLoad || !logged || !loadingStarted || !row.canLoad}
loading={
@@ -509,6 +533,7 @@ export function LogPassYardWorkModal({
>
-
+
Close
{!logged ? (
- 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).
+ : }
+ loading={recordCheckpoint.isPending}
+ onClick={doLogPass}
>
- : }
- 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"}`}
-
-
+ {isFinal
+ ? `Mark arrived at ${station?.label ?? "destination"}`
+ : `Log pass at ${station?.label ?? "station"}`}
+
) : null}
diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/StationWorkControls.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/StationWorkControls.tsx
index 96b524551..50e57b955 100644
--- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/StationWorkControls.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/StationWorkControls.tsx
@@ -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({
setOpened((o) => !o)}
>
@@ -100,6 +100,7 @@ function EditTimeButton({
{
@@ -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 (
-
-
- {title}
- {ended ? " done" : started ? " in progress" : " not started"}
-
+
+
{!started ? (
-
- }
- disabled={!canStart}
- loading={record.isPending}
- onClick={() => doRecord("start")}
+ <>
+
+
- Start {phase}
-
-
+ }
+ disabled={!canStart}
+ loading={record.isPending}
+ onClick={() => doRecord("start")}
+ styles={{ label: { color: track.brand, fontSize: 11.5 } }}
+ >
+ Start {phase}
+
+
+ >
) : (
<>
-
-
- {fmtTime(log!.startedAt!)} → {ended ? fmtTime(log!.endedAt!) : "…"} (
- {fmtElapsed(log!.startedAt!, log?.endedAt)})
-
- {log?.startedByName || log?.endedByName ? (
-
-
- {log?.endedByName ?? log?.startedByName}
-
-
- ) : null}
+
+ {fmtTime(log!.startedAt!)} → {ended ? fmtTime(log!.endedAt!) : "…"}
+
+
+ {fmtElapsed(log!.startedAt!, log?.endedAt)}
+
+ {who ? (
+
+
+ {who}
+
+
+ ) : null}
+
+
+
+ doRecord("start", at)}
+ saving={record.isPending}
+ />
+ {ended ? (
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 ? (
- doRecord("end", at)}
- saving={record.isPending}
- />
- ) : null}
-
- {!ended ? (
+ ) : (
}
+ radius={8}
+ variant="default"
+ leftSection={}
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}
- ) : null}
+ )}
>
)}
diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrackStatusCard.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrackStatusCard.tsx
new file mode 100644
index 000000000..7feec2c59
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrackStatusCard.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+ {trainNumber ?? "Train tracking"}
+
+
+ Train tracking
+
+
+ {direction ? (
+
+ {direction}
+
+ ) : null}
+
+
+
+
+
+ {Math.round(progressPct)}%
+
+
+ {reached}/{totalStations} stops
+
+
+ }
+ />
+
+
+
+
+ {status}
+
+
+
+ {stateLine}
+
+
+ {currentStation}
+
+
+
+
+
+
+
+
+ {origin ?? "—"}
+
+
+
+
+
+ {destination ?? "—"}
+
+
+
+
+
+ {stats.map((s, i) => {
+ const Icon = s.icon;
+ return (
+
+
+
+ {s.label}
+
+
+ {s.value}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/trackPrimitives.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/trackPrimitives.tsx
new file mode 100644
index 000000000..564fe4bcd
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/trackPrimitives.tsx
@@ -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 (
+
+ {children}
+
+ );
+}
+
+/** 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 (
+
+
+ {icon}
+
+
+
+ {title}
+
+
+ {hint}
+
+
+ {right}
+
+ );
+}
+
+/** 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 (
+
+
+
+ {phaseChipLabel(phase, state)}
+
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/trackTheme.ts b/apps/edr-freight-web/backoffice/src/components/trainScheduling/trackTheme.ts
new file mode 100644
index 000000000..f9eb97485
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/trackTheme.ts
@@ -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 = {
+ 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 = {
+ 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;
diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx
index 79d8f6c56..3d74c0fb5 100644
--- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx
@@ -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 | 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 (
-
-
- {icon}
-
-
-
- {label}
-
-
- {value}
-
-
-
- );
-}
-
-/** 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 (
-
-
- {icon}
-
-
-
- {title}
-
-
- {hint}
-
-
-
- );
-}
-
-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 (
-
-
-
-
-
+
+
+
);
}
const track = trackQuery.data;
if (!track || !scheduleId) {
return (
-
-
- Tracking data not found.
-
-
+
+ Tracking data not found.
+
);
}
@@ -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 (
-
-
+
+ {/* ── Top bar ── */}
+
}
- w="fit-content"
+ leftSection={}
>
Back to schedule
+
+
+ Train scheduling
+
+
+
+ {track.trainNumber ?? "Schedule"} · Tracking
+
+
+
{inTransit || arrived ? (
}
+ leftSection={}
loading={intercityMarshalling.isPending}
onClick={() => void openIntercityMarshalling()}
>
@@ -415,423 +327,239 @@ export default function TrainScheduleTrackPage() {
) : null}
- {/* ── Hero: gradient wash, route + a bold progress ring woven together ── */}
-
-
- {/* soft decorative glow, purely artistic */}
-
-
-
- {/* left — identity + route */}
-
-
-
-
-
-
-
-
- Train tracking
-
- {track.trainNumber ? (
-
- {track.trainNumber}
-
- ) : null}
- {track.direction ? (
-
- {track.direction}
-
- ) : null}
-
-
-
-
-
-
-
-
-
-
-
- {arrived
- ? "Journey complete"
- : inTransit
- ? `En route · ${currentStation}`
- : "Awaiting dispatch"}
-
-
-
-
-
- {/* right — progress ring, the artistic focal point */}
-
-
- {Math.round(clampedPct)}%
-
-
- {reached}/{totalStations} stops
-
-
- }
- />
-
-
-
- {/* glass meta strip below the wash */}
-
- } label="Current" value={currentStation} />
- }
- label="Departed"
- value={formatDateTime(track.actualDepartureAt)}
- />
- }
- label="Arrived"
- value={formatDateTime(track.actualArrivalAt)}
- />
- }
- label="Stations"
- value={`${reached} of ${totalStations}`}
- />
-
-
-
- {/* ── Route corridor ── */}
-
-
- }
- 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 */}
+
+
-
- {/* Cargo the operator forgot: boarders at the CURRENT station stay
- loadable until the next pass is logged. */}
- {currentStationObj && forgottenBoarders.length > 0 ? (
- }
- title={`${forgottenBoarders.length} booking${
- forgottenBoarders.length === 1 ? "" : "s"
- } at ${currentStationObj.label} not loaded yet`}
- >
-
-
- The train is at {currentStationObj.label} — cargo boarding here can
- still be loaded before the next station is logged.
+ {/* next action */}
+ {nextStation ? (
+
+
+
+ Next action
+
+
+ {`STOP ${reached + 1} OF ${totalStations}`}
+
+
+
+ {nextIsFinal
+ ? `Mark arrived at ${nextStation.label}`
+ : `Log pass at ${nextStation.label}`}
+
+
+ {nextIsFinal
+ ? "Marks the train arrived: remaining bookings arrive, assets are freed."
+ : "Logging the pass marks arriving bookings and unlocks loading for cargo boarding here."}
+
+
: }
+ loading={
+ recordCheckpoint.isPending &&
+ recordCheckpoint.variables?.payload.sequenceNo ===
+ nextStation.sequenceNo
+ }
+ onClick={() => handleLog(nextStation.sequenceNo)}
+ >
+ {nextIsFinal ? "Mark arrived" : "Log pass"}
+
+ }
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
-
+
+ ) : null}
+
+ {/* forgotten boarders */}
+ {currentStationObj && forgottenBoarders.length > 0 ? (
+
+
+
+
+ {forgottenBoarders.length} booking
+ {forgottenBoarders.length === 1 ? "" : "s"} not loaded
+
+
+
+ The train is at {currentStationObj.label} — cargo boarding here can still
+ be loaded before the next station is logged.
+
+
+ setYardModal({
+ station: currentStationObj,
+ isFinal:
+ currentStationObj.sequenceNo ===
+ track.stations[totalStations - 1]?.sequenceNo,
+ alreadyLogged: true,
+ })
+ }
+ >
+ Open yard work
+
+
) : null}
-
- {/* ── Loading / unloading windows per station ── */}
-
- }
- title="Loading & unloading windows"
- hint="Start and end each station's work window — times, duration and who recorded them"
- />
-
- {track.stations.map((s, i) => {
- const isFirst = i === 0;
- const isLast = i === track.stations.length - 1;
- const workLog = track.stationWorkLogs?.[s.yardId];
- return (
-
-
-
- {isLast ? : }
-
-
- {s.label}
-
- {isFirst ? (
-
- origin
-
- ) : null}
- {isLast ? (
-
- destination
-
- ) : null}
- {track.currentSequenceNo === s.sequenceNo ? (
-
- train here
-
- ) : null}
-
-
- {!isLast ? (
-
- ) : null}
- {!isFirst ? (
-
- ) : null}
-
-
- );
- })}
-
-
-
- {/* ── Checkpoint log ── */}
-
-
- }
- title="Checkpoint log"
- hint={`${track.checkpoints.length} event${
- track.checkpoints.length === 1 ? "" : "s"
- } recorded`}
- />
-
-
- {track.checkpoints.length === 0 ? (
-
-
-
-
-
- No checkpoints yet
-
-
- Each station the train passes will be logged here with its
- timestamp.
-
-
- ) : (
-
- {track.checkpoints.map((cp) => (
-
- ) : (
-
- )
- }
- title={
-
-
-
- {cp.label ?? `Station ${cp.sequenceNo}`}
+ {/* main column */}
+
+
+ }
+ 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={
+
+ {[
+ [T.brand, "Passed"],
+ [T.amber, "Active"],
+ [T.text3, "Upcoming"],
+ ].map(([color, label]) => (
+
+
+
+ {label}
-
- {cp.kind}
-
- {canEdit ? (
- }
- onClick={() => setEditModal(cp)}
- >
- Edit
-
- ) : null}
-
- }
- >
-
- {formatDateTime(cp.occurredAt)}
-
- {handlingHours(cp) !== null ? (
-
- Loading + unloading {handlingHours(cp)} h
-
- ) : null}
- {cp.note ? (
-
- {cp.note}
-
- ) : null}
-
- ))}
-
- )}
-
+ ))}
+
+ }
+ />
+
+
+
+
+ }
+ title="Checkpoint log"
+ hint="Raw event trail — every logged pass with its correction history"
+ right={
+
+ {`${track.checkpoints.length} EVENT${
+ track.checkpoints.length === 1 ? "" : "S"
+ }`}
+
+ }
+ />
+
+
+
+
-
+
);
}