mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
267 lines
9.1 KiB
TypeScript
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>
|
|
);
|
|
}
|