mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +00:00
schedule logic
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { isAxiosError } from "axios";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CalendarClock,
|
||||
CheckCircle2,
|
||||
Flag,
|
||||
MapPin,
|
||||
Navigation,
|
||||
Train,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Timeline,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
|
||||
import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack";
|
||||
import {
|
||||
RouteCorridor,
|
||||
StatTile,
|
||||
StatusPill,
|
||||
scheduleBrand,
|
||||
} from "@/components/trainScheduling/scheduleVisuals";
|
||||
import { useTrainTrack, useScheduleMutations } from "@/hooks/trainScheduling/useTrainScheduling";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
if (isAxiosError(error)) {
|
||||
const data = error.response?.data as Record<string, unknown> | undefined;
|
||||
const message = data?.message;
|
||||
if (Array.isArray(message)) return message.join(", ");
|
||||
if (typeof message === "string") return message;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
function formatDateTime(iso?: string | null) {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export default function TrainScheduleTrackPage() {
|
||||
const { scheduleId } = useParams<{ scheduleId: string }>();
|
||||
const { toast } = useToast();
|
||||
const trackQuery = useTrainTrack(scheduleId);
|
||||
const { recordCheckpoint } = useScheduleMutations(scheduleId);
|
||||
|
||||
if (trackQuery.isLoading) {
|
||||
return (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
const track = trackQuery.data;
|
||||
if (!track || !scheduleId) {
|
||||
return (
|
||||
<Text c="dimmed" py="xl">
|
||||
Tracking data not found.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const canLog = track.status === "DISPATCHED";
|
||||
const totalStations = track.stations.length;
|
||||
const reached = Math.min(track.currentSequenceNo + 1, totalStations);
|
||||
const progressLabel = `${reached} / ${totalStations}`;
|
||||
|
||||
const handleLog = (sequenceNo: number) => {
|
||||
const isFinal = sequenceNo === track.stations[totalStations - 1]?.sequenceNo;
|
||||
recordCheckpoint.mutate(
|
||||
{ id: scheduleId, payload: { sequenceNo } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: isFinal
|
||||
? "Train arrived — assets freed, readiness flipped"
|
||||
: "Checkpoint logged",
|
||||
});
|
||||
},
|
||||
onError: (err) =>
|
||||
toast({
|
||||
title: "Could not log checkpoint",
|
||||
description: parseError(err, "Please try again"),
|
||||
variant: "destructive",
|
||||
}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Button
|
||||
component={Link}
|
||||
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
w="fit-content"
|
||||
>
|
||||
Back to schedule
|
||||
</Button>
|
||||
|
||||
{/* Hero */}
|
||||
<Paper
|
||||
radius="xl"
|
||||
p="xl"
|
||||
style={{
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
background: scheduleBrand.heroGradient,
|
||||
boxShadow: scheduleBrand.shadow,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -90,
|
||||
right: -50,
|
||||
width: 280,
|
||||
height: 280,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(255,255,255,0.10)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
<Stack gap="lg" style={{ position: "relative" }}>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Group gap="md" align="flex-start" wrap="nowrap">
|
||||
<ThemeIcon size={56} radius="lg" variant="white" style={{ color: "var(--mantine-color-green-7)" }}>
|
||||
<Navigation size={28} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={6}>
|
||||
<Group gap="sm" align="center" wrap="wrap">
|
||||
<Title order={2} c="white" fw={700}>
|
||||
Track train
|
||||
</Title>
|
||||
{track.trainNumber ? (
|
||||
<Badge variant="white" c="green.8" radius="sm" style={{ fontWeight: 600 }}>
|
||||
{track.trainNumber}
|
||||
</Badge>
|
||||
) : null}
|
||||
{track.direction ? (
|
||||
<Badge variant="white" c="green.8" radius="sm">
|
||||
{track.direction}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Box maw={340}>
|
||||
<RouteCorridor onDark origin={track.origin} destination={track.destination} />
|
||||
</Box>
|
||||
<StatusPill status={track.status} />
|
||||
</Stack>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
||||
<StatTile onDark icon={Train} label="Progress" value={progressLabel} hint="stations reached" />
|
||||
<StatTile onDark icon={MapPin} label="Current" value={track.stations[Math.max(0, track.currentSequenceNo)]?.label ?? "—"} />
|
||||
<StatTile onDark icon={CalendarClock} label="Departed" value={formatDateTime(track.actualDepartureAt)} />
|
||||
<StatTile onDark icon={Flag} label="Arrived" value={formatDateTime(track.actualArrivalAt)} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Corridor */}
|
||||
<Paper radius="xl" p="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="md" align="center" wrap="nowrap">
|
||||
<ThemeIcon size={44} radius="md" variant="gradient" gradient={{ from: "green", to: "teal", deg: 135 }}>
|
||||
<Navigation size={22} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2}>
|
||||
<Title order={4} fw={700}>
|
||||
Route corridor
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{canLog
|
||||
? "Log the train passing each station; the final station marks arrival."
|
||||
: track.status === "ARRIVED"
|
||||
? "This train has arrived at its destination."
|
||||
: "Tracking becomes available once the train is dispatched."}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<RouteCorridorTrack
|
||||
stations={track.stations}
|
||||
currentSequenceNo={track.currentSequenceNo}
|
||||
checkpoints={track.checkpoints}
|
||||
canLog={canLog}
|
||||
loggingSeq={
|
||||
recordCheckpoint.isPending ? recordCheckpoint.variables?.payload.sequenceNo : null
|
||||
}
|
||||
onLogCheckpoint={handleLog}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Timeline */}
|
||||
<Paper radius="xl" p="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
||||
<Stack gap="md">
|
||||
<Title order={5} fw={700}>
|
||||
Checkpoint log
|
||||
</Title>
|
||||
{track.checkpoints.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No checkpoints logged yet.
|
||||
</Text>
|
||||
) : (
|
||||
<Timeline active={track.checkpoints.length} bulletSize={22} lineWidth={2} color="green">
|
||||
{track.checkpoints.map((cp) => (
|
||||
<Timeline.Item
|
||||
key={cp.id}
|
||||
bullet={cp.kind === "ARRIVED" ? <CheckCircle2 size={13} /> : <MapPin size={12} />}
|
||||
title={
|
||||
<Group gap="sm">
|
||||
<Text fw={600} size="sm">
|
||||
{cp.label ?? `Station ${cp.sequenceNo}`}
|
||||
</Text>
|
||||
<Badge
|
||||
size="xs"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color={cp.kind === "ARRIVED" ? "teal" : cp.kind === "DEPARTED" ? "blue" : "green"}
|
||||
>
|
||||
{cp.kind}
|
||||
</Badge>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatDateTime(cp.occurredAt)}
|
||||
</Text>
|
||||
{cp.note ? <Text size="xs">{cp.note}</Text> : null}
|
||||
</Timeline.Item>
|
||||
))}
|
||||
</Timeline>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Container as ContainerIcon,
|
||||
Eye,
|
||||
LayoutGrid,
|
||||
Navigation,
|
||||
Package,
|
||||
Route as RouteIcon,
|
||||
Send,
|
||||
@@ -771,17 +772,32 @@ export default function TrainScheduleV2DetailPage() {
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
{schedule.status !== "DISPATCHED" ? (
|
||||
<Button
|
||||
variant="white"
|
||||
c="green.8"
|
||||
radius="lg"
|
||||
size="sm"
|
||||
onClick={() => setMaintenanceOpen(true)}
|
||||
>
|
||||
Reschedule train
|
||||
</Button>
|
||||
) : null}
|
||||
<Group gap="sm">
|
||||
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
|
||||
<Button
|
||||
component={Link}
|
||||
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}/track`}
|
||||
variant="white"
|
||||
c="green.8"
|
||||
radius="lg"
|
||||
size="sm"
|
||||
leftSection={<Navigation size={16} />}
|
||||
>
|
||||
Track train
|
||||
</Button>
|
||||
) : null}
|
||||
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
|
||||
<Button
|
||||
variant="white"
|
||||
c="green.8"
|
||||
radius="lg"
|
||||
size="sm"
|
||||
onClick={() => setMaintenanceOpen(true)}
|
||||
>
|
||||
Reschedule train
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
||||
@@ -790,6 +806,13 @@ export default function TrainScheduleV2DetailPage() {
|
||||
icon={Train}
|
||||
label="Locomotive"
|
||||
value={schedule.trainSet?.locomotive?.code ?? "—"}
|
||||
hint={
|
||||
schedule.trainSet?.locomotive?.readiness === "EXPORT_READY"
|
||||
? "Export-ready"
|
||||
: schedule.trainSet?.locomotive?.readiness === "IMPORT_READY"
|
||||
? "Import-ready"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<StatTile
|
||||
onDark
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { ArrowRight, CalendarClock, Send, Train, Weight } from "lucide-react";
|
||||
import { ArrowRight, CalendarClock, Navigation, Send, Train, Weight } from "lucide-react";
|
||||
|
||||
import FleetToolbar from "@/components/fleet/FleetToolbar";
|
||||
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
|
||||
@@ -253,6 +253,21 @@ export default function TrainScheduleV2ListPage() {
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
{["DISPATCHED", "ARRIVED"].includes(row.original.status) ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="teal"
|
||||
size="compact-sm"
|
||||
leftSection={<Navigation size={14} />}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/dashboard/operations/train-scheduling-v2/${row.original.id}/track`,
|
||||
)
|
||||
}
|
||||
>
|
||||
Track
|
||||
</Button>
|
||||
) : null}
|
||||
{["DRAFT", "SCHEDULED"].includes(row.original.status) ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
@@ -493,6 +508,11 @@ export default function TrainScheduleV2ListPage() {
|
||||
`/dashboard/operations/train-scheduling-v2/${schedule.id}`,
|
||||
)
|
||||
}
|
||||
onTrack={() =>
|
||||
navigate(
|
||||
`/dashboard/operations/train-scheduling-v2/${schedule.id}/track`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
@@ -542,7 +562,9 @@ export default function TrainScheduleV2ListPage() {
|
||||
placeholder="Select locomotive"
|
||||
data={(locomotivesQuery.data ?? []).map((l) => ({
|
||||
value: l.id,
|
||||
label: `${l.code}${l.name ? ` — ${l.name}` : ""}`,
|
||||
label: `${l.code}${l.name ? ` — ${l.name}` : ""} · ${
|
||||
l.readiness === "EXPORT_READY" ? "Export-ready" : "Import-ready"
|
||||
}`,
|
||||
}))}
|
||||
value={locomotiveId || null}
|
||||
onChange={(v) => setLocomotiveId(v ?? "")}
|
||||
@@ -601,11 +623,14 @@ function MetricChip({
|
||||
function ScheduleCard({
|
||||
schedule,
|
||||
onOpen,
|
||||
onTrack,
|
||||
}: {
|
||||
schedule: TrainScheduleListItem;
|
||||
onOpen: () => void;
|
||||
onTrack: () => void;
|
||||
}) {
|
||||
const { day, time } = splitDate(schedule.scheduleDate);
|
||||
const canTrack = ["DISPATCHED", "ARRIVED"].includes(schedule.status);
|
||||
return (
|
||||
<Card
|
||||
radius="lg"
|
||||
@@ -667,20 +692,37 @@ function ScheduleCard({
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
color="green"
|
||||
size="sm"
|
||||
radius="md"
|
||||
fullWidth
|
||||
rightSection={<ArrowRight size={15} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpen();
|
||||
}}
|
||||
>
|
||||
Open schedule
|
||||
</Button>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Button
|
||||
variant="light"
|
||||
color="green"
|
||||
size="sm"
|
||||
radius="md"
|
||||
style={{ flex: 1 }}
|
||||
rightSection={<ArrowRight size={15} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpen();
|
||||
}}
|
||||
>
|
||||
Open schedule
|
||||
</Button>
|
||||
{canTrack ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="teal"
|
||||
size="sm"
|
||||
radius="md"
|
||||
leftSection={<Navigation size={15} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTrack();
|
||||
}}
|
||||
>
|
||||
Track
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user