schedule logic

This commit is contained in:
Marshal
2026-06-10 09:22:57 +00:00
parent 0335555892
commit 088295d81f
23 changed files with 1766 additions and 319 deletions

View File

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

View File

@@ -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

View File

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