Files
edr-platform/apps/edr-freight-web/backoffice/src/components/trainScheduling/JourneySpine.tsx
2026-08-27 07:45:32 +00:00

267 lines
9.1 KiB
TypeScript

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