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:
@@ -38,6 +38,7 @@ import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
||||
import TrainsPage from "./pages/trains/TrainsPage";
|
||||
import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage";
|
||||
import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage";
|
||||
import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage";
|
||||
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
|
||||
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
|
||||
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
||||
@@ -259,6 +260,10 @@ const App = () => {
|
||||
path="operations/train-scheduling-v2/:scheduleId"
|
||||
element={<TrainScheduleV2DetailPage />}
|
||||
/>
|
||||
<Route
|
||||
path="operations/train-scheduling-v2/:scheduleId/track"
|
||||
element={<TrainScheduleTrackPage />}
|
||||
/>
|
||||
<Route path="routes" element={<RoutesPage />} />
|
||||
<Route path="locomotives" element={<FleetResourcePage />} />
|
||||
<Route path="trains" element={<FleetResourcePage />} />
|
||||
|
||||
@@ -2,19 +2,34 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { isAxiosError } from "axios";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Radio,
|
||||
RingProgress,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Stepper,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Container as ContainerIcon,
|
||||
Eye,
|
||||
Flame,
|
||||
LayoutGrid,
|
||||
Package,
|
||||
Route as RouteIcon,
|
||||
Train,
|
||||
Wallet,
|
||||
Weight,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
useAvailableLocomotives,
|
||||
@@ -46,10 +61,16 @@ import { shouldShowContainerPlacementStep } from "./schedulingContainerStep.util
|
||||
import { FleetAvailabilitySummary } from "./FleetAvailabilitySummary";
|
||||
import { ScheduleBookingsStep } from "./ScheduleBookingsStep";
|
||||
import { PreviewSummary, ScheduleWarningsAlert } from "./ScheduleWarningsAlert";
|
||||
import { SchedulingWorkflowHeader } from "./SchedulingWorkflowHeader";
|
||||
import { schedulingWorkflow } from "./schedulingWorkflow.styles";
|
||||
import { SchedulingStatusBadge } from "./ScheduleStatusBadge";
|
||||
import { FreightTypeBadge, SchedulingStatusBadge } from "./ScheduleStatusBadge";
|
||||
import {
|
||||
RouteCorridor,
|
||||
StatTile,
|
||||
StatusPill,
|
||||
scheduleBrand,
|
||||
} from "./scheduleVisuals";
|
||||
import { TrainCompositionDiagram } from "./TrainCompositionDiagram";
|
||||
import { WagonPlanGrid } from "./WagonPlanGrid";
|
||||
import { WorkflowRail, WorkflowStep } from "./WorkflowStep";
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
if (isAxiosError(error)) {
|
||||
@@ -157,13 +178,6 @@ export function AllocateBookingWizard({
|
||||
const previewFreightType = previewResult?.summary?.freightMode as FreightType | undefined;
|
||||
const finalizeStep = hasContainerStep ? 3 : 2;
|
||||
|
||||
const stepLabels = [
|
||||
"Bookings",
|
||||
"Wagon plan",
|
||||
...(hasContainerStep ? ["Containers"] : []),
|
||||
"Finalize",
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
setActiveStep(0);
|
||||
@@ -216,6 +230,21 @@ export function AllocateBookingWizard({
|
||||
[routesQuery.data],
|
||||
);
|
||||
|
||||
const displayWagonPlan = useMemo(() => {
|
||||
const savedWagons = assignedSchedule?.trainSet?.wagons ?? [];
|
||||
const physicalBySeq = new Map(
|
||||
savedWagons.map((w) => [w.sequenceNo, w.physicalWagonNumber ?? null]),
|
||||
);
|
||||
if (previewResult?.wagonPlan?.length) {
|
||||
return previewResult.wagonPlan.map((slot) => ({
|
||||
...slot,
|
||||
physicalWagonNumber: physicalBySeq.get(slot.sequenceNo) ?? null,
|
||||
}));
|
||||
}
|
||||
if (savedWagons.length) return savedWagons;
|
||||
return [];
|
||||
}, [previewResult?.wagonPlan, assignedSchedule?.trainSet?.wagons]);
|
||||
|
||||
const ensureSchedule = async (): Promise<string> => {
|
||||
if (scheduleMode === "existing" && selectedScheduleId) return selectedScheduleId;
|
||||
if (!routeId || !scheduleDate || !locomotiveId) {
|
||||
@@ -357,305 +386,602 @@ export function AllocateBookingWizard({
|
||||
}
|
||||
};
|
||||
|
||||
const amount = Number(booking.totalAmount);
|
||||
const containers = booking.bookingContainers ?? [];
|
||||
const containerCount = containers.reduce((sum, c) => sum + Number(c.quantity ?? 0), 0);
|
||||
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
const holdCountdown = formatCountdown(booking.holdExpiresAt);
|
||||
|
||||
const stepDescription =
|
||||
activeStep === 0
|
||||
? "Select & preview"
|
||||
: activeStep === 1
|
||||
? "Allocations"
|
||||
: hasContainerStep && activeStep === 2
|
||||
? "Map units"
|
||||
: "Depart";
|
||||
const containerComplete =
|
||||
hasContainerStep &&
|
||||
containerUnits.length > 0 &&
|
||||
validateLocalPlacements(containerUnits, containerPlacements).length === 0;
|
||||
|
||||
const stepIcon =
|
||||
activeStep === 0
|
||||
? "package"
|
||||
: activeStep === 1
|
||||
? "layout"
|
||||
: hasContainerStep && activeStep === 2
|
||||
? "container"
|
||||
: "check";
|
||||
const stepsMeta = [
|
||||
{
|
||||
key: "bookings",
|
||||
icon: Package,
|
||||
title: "Bookings",
|
||||
subtitle: "Select cargo & preview the plan",
|
||||
complete: Boolean(previewResult) || Boolean(assignedSchedule),
|
||||
},
|
||||
{
|
||||
key: "wagon",
|
||||
icon: LayoutGrid,
|
||||
title: "Wagon plan",
|
||||
subtitle: "Review generated allocations",
|
||||
complete: displayWagonPlan.length > 0,
|
||||
},
|
||||
...(hasContainerStep
|
||||
? [
|
||||
{
|
||||
key: "container",
|
||||
icon: ContainerIcon,
|
||||
title: "Containers",
|
||||
subtitle: "Map units to wagon slots",
|
||||
complete: containerComplete,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: "finalize",
|
||||
icon: CheckCircle2,
|
||||
title: "Finalize",
|
||||
subtitle: "Lock the plan & dispatch",
|
||||
complete: allocationComplete,
|
||||
},
|
||||
];
|
||||
const completedCount = stepsMeta.filter((s) => s.complete).length;
|
||||
const progressPct = Math.round((completedCount / stepsMeta.length) * 100);
|
||||
const toggleStep = (i: number) => setActiveStep((cur) => (cur === i ? -1 : i));
|
||||
|
||||
const renderStepRightSlot = (key: string) => {
|
||||
if (key === "bookings") {
|
||||
if (previewResult) {
|
||||
return (
|
||||
<Badge variant="light" color={previewResult.valid ? "green" : "red"} radius="sm">
|
||||
{previewResult.valid ? "Plan valid" : "Has issues"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return allBookingIds.length ? (
|
||||
<Badge variant="light" color="green" radius="sm">
|
||||
{allBookingIds.length} selected
|
||||
</Badge>
|
||||
) : null;
|
||||
}
|
||||
if (key === "wagon" && displayWagonPlan.length) {
|
||||
return (
|
||||
<Badge variant="light" color="green" radius="sm">
|
||||
{displayWagonPlan.length} wagons
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (key === "container" && containerUnits.length) {
|
||||
return (
|
||||
<Badge variant="light" color={containerComplete ? "green" : "yellow"} radius="sm">
|
||||
{containerUnits.length} units
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (key === "finalize" && allocationComplete) {
|
||||
return <StatusPill status="SCHEDULED" />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderStepBody = (key: string) => {
|
||||
if (key === "bookings") {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Paper p="md" radius="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
||||
<Stack gap="sm">
|
||||
<Text fw={600} size="sm">
|
||||
Train schedule
|
||||
</Text>
|
||||
<Radio.Group
|
||||
value={scheduleMode}
|
||||
onChange={(v) => setScheduleMode(v as "existing" | "new")}
|
||||
>
|
||||
<Group gap="lg">
|
||||
<Radio value="existing" label="Use existing draft schedule" />
|
||||
<Radio value="new" label="Create new schedule" />
|
||||
</Group>
|
||||
</Radio.Group>
|
||||
|
||||
{scheduleMode === "existing" ? (
|
||||
<Select
|
||||
label="Draft schedule"
|
||||
data={matchingSchedules.map((s) => ({
|
||||
value: s.id,
|
||||
label: `${s.routeName ?? "Schedule"} · ${new Date(s.scheduleDate).toLocaleDateString()} · ${s.freightType ?? "MIXED"}`,
|
||||
}))}
|
||||
value={selectedScheduleId}
|
||||
onChange={setSelectedScheduleId}
|
||||
searchable
|
||||
/>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
<Select
|
||||
label="Route"
|
||||
data={activeRoutes.map((r) => ({ value: r.id, label: r.name }))}
|
||||
value={routeId || null}
|
||||
onChange={(v) => setRouteId(v ?? "")}
|
||||
searchable
|
||||
/>
|
||||
<Select
|
||||
label="Locomotive"
|
||||
data={(locomotivesQuery.data ?? []).map((l) => ({
|
||||
value: l.id,
|
||||
label: `${l.code} · ${
|
||||
l.readiness === "EXPORT_READY" ? "Export-ready" : "Import-ready"
|
||||
}`,
|
||||
}))}
|
||||
value={locomotiveId || null}
|
||||
onChange={(v) => setLocomotiveId(v ?? "")}
|
||||
searchable
|
||||
/>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{holdCountdown ? (
|
||||
<Text size="xs" c={holdCountdown.includes("expired") ? "red" : "yellow.8"}>
|
||||
Hold window: {holdCountdown}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<ScheduleBookingsStep
|
||||
assignedBookings={(assignedSchedule?.bookings ?? []).map((b) => ({
|
||||
id: b.id,
|
||||
reference: b.reference ?? b.id.slice(0, 8),
|
||||
weightTons: b.weightTons,
|
||||
}))}
|
||||
eligibleItems={eligibleQuery.data?.items ?? []}
|
||||
eligibleLoading={eligibleQuery.isLoading}
|
||||
selectedIds={allBookingIds}
|
||||
onSelectionChange={(ids) => {
|
||||
setExtraBookingIds(ids.filter((id) => id !== booking.id));
|
||||
}}
|
||||
freightType={bookingFreightType}
|
||||
/>
|
||||
|
||||
<Group
|
||||
align="center"
|
||||
justify="space-between"
|
||||
wrap="wrap"
|
||||
gap="md"
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
label="Force assign (bypass hold / overweight warnings)"
|
||||
checked={forceAssign}
|
||||
onChange={(e) => setForceAssign(e.currentTarget.checked)}
|
||||
size="sm"
|
||||
/>
|
||||
<Button
|
||||
color="green"
|
||||
radius="md"
|
||||
leftSection={<Eye size={16} />}
|
||||
loading={preview.isPending}
|
||||
onClick={handlePreview}
|
||||
>
|
||||
Preview plan
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{previewResult ? (
|
||||
<Stack gap="sm">
|
||||
<ScheduleWarningsAlert
|
||||
violations={previewResult.violations}
|
||||
warnings={previewResult.warnings}
|
||||
/>
|
||||
<FleetAvailabilitySummary
|
||||
fleetAvailability={previewResult.fleetAvailability}
|
||||
deferredBookings={previewResult.deferredBookings}
|
||||
/>
|
||||
<PreviewSummary summary={previewResult.summary} />
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (key === "wagon") {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{!displayWagonPlan.length && !previewResult ? (
|
||||
<Paper p="md" radius="lg" withBorder bg="gray.0">
|
||||
<Text size="sm" c="dimmed">
|
||||
Run a preview from the Bookings step to generate the wagon plan.
|
||||
</Text>
|
||||
</Paper>
|
||||
) : null}
|
||||
{reschedulePlan?.displaced.length ? (
|
||||
<Paper p="md" radius="lg" withBorder style={{ borderColor: "var(--mantine-color-orange-2)", background: "var(--mantine-color-orange-0)" }}>
|
||||
<Stack gap="sm">
|
||||
<Text fw={600} size="sm" c="orange.8">
|
||||
Government preempt — bookings to displace
|
||||
</Text>
|
||||
{reschedulePlan.displaced.map((b) => (
|
||||
<Text key={b.id} size="sm">
|
||||
{b.reference} (priority {b.priorityScore})
|
||||
</Text>
|
||||
))}
|
||||
<Checkbox
|
||||
label="I confirm displacing the bookings listed above"
|
||||
checked={confirmPreempt}
|
||||
onChange={(e) => setConfirmPreempt(e.currentTarget.checked)}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : null}
|
||||
<ScheduleWarningsAlert
|
||||
violations={previewResult?.violations}
|
||||
warnings={previewResult?.warnings}
|
||||
/>
|
||||
<FleetAvailabilitySummary
|
||||
fleetAvailability={previewResult?.fleetAvailability}
|
||||
deferredBookings={previewResult?.deferredBookings}
|
||||
/>
|
||||
<WagonPlanGrid
|
||||
wagonPlan={displayWagonPlan}
|
||||
freightType={previewFreightType ?? bookingFreightType}
|
||||
/>
|
||||
<Group>
|
||||
{!hasContainerStep ? (
|
||||
<Button
|
||||
color="green"
|
||||
radius="md"
|
||||
loading={assign.isPending || create.isPending}
|
||||
onClick={handleAssign}
|
||||
>
|
||||
Assign bookings
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
color="green"
|
||||
radius="md"
|
||||
rightSection={<ContainerIcon size={16} />}
|
||||
onClick={() => setActiveStep(2)}
|
||||
>
|
||||
Continue to containers
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="default" radius="md" onClick={handlePreview}>
|
||||
Refresh preview
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (key === "container") {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{!containerUnits.length ? (
|
||||
<Paper p="md" radius="lg" withBorder bg="gray.0">
|
||||
<Text size="sm" c="dimmed">
|
||||
Run preview from the Bookings step to load container units for numbering.
|
||||
</Text>
|
||||
</Paper>
|
||||
) : (
|
||||
<ContainerPlacementGrid
|
||||
units={containerUnits}
|
||||
containerSlots={containerSlots}
|
||||
placements={containerPlacements}
|
||||
onChange={setContainerPlacements}
|
||||
/>
|
||||
)}
|
||||
<Group>
|
||||
<Button
|
||||
color="green"
|
||||
radius="md"
|
||||
loading={assign.isPending || create.isPending}
|
||||
onClick={handleAssign}
|
||||
>
|
||||
Assign bookings
|
||||
</Button>
|
||||
<Button variant="default" radius="md" onClick={() => setActiveStep(finalizeStep)}>
|
||||
Skip to finalize
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// finalize
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{displayWagonPlan.length || assignedSchedule?.trainSet?.wagons?.length ? (
|
||||
<TrainCompositionDiagram
|
||||
locomotive={assignedSchedule?.trainSet?.locomotive}
|
||||
wagons={
|
||||
assignedSchedule?.trainSet?.wagons?.length
|
||||
? assignedSchedule.trainSet.wagons
|
||||
: displayWagonPlan
|
||||
}
|
||||
freightType={previewFreightType ?? bookingFreightType}
|
||||
trainNumber={assignedSchedule?.trainNumber}
|
||||
totalLengthMeters={assignedSchedule?.trainSet?.totalLengthMeters}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{allocationComplete ? (
|
||||
<Paper
|
||||
p="lg"
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{ background: scheduleBrand.softSurface, borderColor: scheduleBrand.mutedBorder }}
|
||||
>
|
||||
<Group gap="md" align="flex-start" wrap="nowrap">
|
||||
<ThemeIcon size={44} radius="md" variant="light" color="green">
|
||||
<CheckCircle2 size={22} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2} style={{ flex: 1 }}>
|
||||
<Text fw={700} size="lg">
|
||||
Allocation complete
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Booking {booking.reference} is scheduled on train{" "}
|
||||
<Text span fw={600} c="green.7">
|
||||
{assignedSchedule?.trainSet?.locomotive?.code ?? "—"}
|
||||
</Text>
|
||||
.
|
||||
</Text>
|
||||
<Group mt="sm">
|
||||
<Button
|
||||
color="green"
|
||||
radius="md"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
if (assignedSchedule?.id) {
|
||||
navigate(
|
||||
`/dashboard/operations/train-scheduling-v2/${assignedSchedule.id}`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
View schedule
|
||||
</Button>
|
||||
<Button variant="default" radius="md" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : (
|
||||
<>
|
||||
<Paper
|
||||
p="lg"
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{ background: scheduleBrand.softSurface, borderColor: scheduleBrand.mutedBorder }}
|
||||
>
|
||||
<Group gap="md" align="flex-start" wrap="nowrap">
|
||||
<ThemeIcon size={44} radius="md" variant="light" color="green">
|
||||
<CheckCircle2 size={22} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2}>
|
||||
<Text fw={600}>Ready to finalize</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Finalizing locks the plan, moves the schedule to{" "}
|
||||
<Text span fw={600} c="green.7">
|
||||
SCHEDULED
|
||||
</Text>
|
||||
, and completes the booking allocation.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
<Group>
|
||||
<Button
|
||||
color="green"
|
||||
size="md"
|
||||
radius="md"
|
||||
leftSection={<CheckCircle2 size={18} />}
|
||||
loading={finalize.isPending}
|
||||
onClick={handleFinalize}
|
||||
>
|
||||
Finalize schedule
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Text fw={600}>Allocate booking — {booking.reference}</Text>}
|
||||
withCloseButton
|
||||
size="90%"
|
||||
radius="xl"
|
||||
radius="lg"
|
||||
centered
|
||||
styles={{ content: { maxWidth: 1200 } }}
|
||||
padding="lg"
|
||||
styles={{ content: { maxWidth: 1200 }, body: { paddingTop: 8 } }}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<SchedulingWorkflowHeader
|
||||
title="Allocation workflow"
|
||||
subtitle={`${booking.reference} · ${booking.originYard?.name ?? "Origin"} → ${booking.destinationYard?.name ?? "Destination"}`}
|
||||
activeStep={activeStep}
|
||||
totalSteps={stepLabels.length}
|
||||
stepLabel={stepLabels[activeStep] ?? ""}
|
||||
stepDescription={stepDescription}
|
||||
stepIcon={stepIcon}
|
||||
/>
|
||||
|
||||
<Stepper
|
||||
active={activeStep}
|
||||
onStepClick={setActiveStep}
|
||||
color={schedulingWorkflow.stepper.color}
|
||||
iconSize={schedulingWorkflow.stepper.iconSize}
|
||||
size={schedulingWorkflow.stepper.size}
|
||||
{/* Hero */}
|
||||
<Paper
|
||||
radius="xl"
|
||||
p="xl"
|
||||
style={{
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
background: scheduleBrand.heroGradient,
|
||||
boxShadow: scheduleBrand.shadow,
|
||||
}}
|
||||
>
|
||||
<Stepper.Step label="Bookings" description="Select & preview">
|
||||
<Stack gap="md" mt="lg">
|
||||
<Card withBorder padding="md" radius="xl">
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600}>{booking.reference}</Text>
|
||||
<SchedulingStatusBadge status={booking.schedulingStatus} />
|
||||
<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)" }}
|
||||
>
|
||||
<Train size={28} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={6}>
|
||||
<Group gap="sm" align="center" wrap="wrap">
|
||||
<Title order={2} c="white" fw={700}>
|
||||
Allocate {booking.reference}
|
||||
</Title>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{booking.freightType} · {booking.cargoTotalWeightVgm}T
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{booking.originYard?.name ?? "Origin"} →{" "}
|
||||
{booking.destinationYard?.name ?? "Destination"}
|
||||
</Text>
|
||||
{booking.freightType === "CONTAINER" && booking.bookingContainers?.length ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{booking.bookingContainers.map((c) => `${c.quantity}× container`).join(", ")}
|
||||
</Text>
|
||||
) : null}
|
||||
{holdCountdown ? (
|
||||
<Text size="sm" c={holdCountdown.includes("expired") ? "red" : "yellow"}>
|
||||
Hold window: {holdCountdown}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Paper p="md" radius="xl" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text fw={600} size="sm">
|
||||
Train schedule
|
||||
</Text>
|
||||
<Radio.Group
|
||||
value={scheduleMode}
|
||||
onChange={(v) => setScheduleMode(v as "existing" | "new")}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Radio value="existing" label="Use existing draft schedule" />
|
||||
<Radio value="new" label="Create new schedule" />
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
|
||||
{scheduleMode === "existing" ? (
|
||||
<Select
|
||||
label="Draft schedule"
|
||||
data={matchingSchedules.map((s) => ({
|
||||
value: s.id,
|
||||
label: `${s.routeName ?? "Schedule"} · ${new Date(s.scheduleDate).toLocaleDateString()} · ${s.freightType ?? "MIXED"}`,
|
||||
}))}
|
||||
value={selectedScheduleId}
|
||||
onChange={setSelectedScheduleId}
|
||||
searchable
|
||||
<Box maw={360}>
|
||||
<RouteCorridor
|
||||
onDark
|
||||
origin={booking.originYard?.name ?? booking.originYard?.label}
|
||||
destination={
|
||||
booking.destinationYard?.name ?? booking.destinationYard?.label
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
<Select
|
||||
label="Route"
|
||||
data={activeRoutes.map((r) => ({ value: r.id, label: r.name }))}
|
||||
value={routeId || null}
|
||||
onChange={(v) => setRouteId(v ?? "")}
|
||||
searchable
|
||||
/>
|
||||
<Select
|
||||
label="Locomotive"
|
||||
data={(locomotivesQuery.data ?? []).map((l) => ({
|
||||
value: l.id,
|
||||
label: l.code,
|
||||
}))}
|
||||
value={locomotiveId || null}
|
||||
onChange={(v) => setLocomotiveId(v ?? "")}
|
||||
searchable
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
<Group gap="sm" align="center">
|
||||
<FreightTypeBadge freightType={booking.freightType} />
|
||||
{booking.schedulingStatus ? (
|
||||
<SchedulingStatusBadge status={booking.schedulingStatus} />
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<ScheduleBookingsStep
|
||||
assignedBookings={(assignedSchedule?.bookings ?? []).map((b) => ({
|
||||
id: b.id,
|
||||
reference: b.reference ?? b.id.slice(0, 8),
|
||||
weightTons: b.weightTons,
|
||||
}))}
|
||||
eligibleItems={eligibleQuery.data?.items ?? []}
|
||||
eligibleLoading={eligibleQuery.isLoading}
|
||||
selectedIds={allBookingIds}
|
||||
onSelectionChange={(ids) => {
|
||||
setExtraBookingIds(ids.filter((id) => id !== booking.id));
|
||||
}}
|
||||
freightType={bookingFreightType}
|
||||
/>
|
||||
|
||||
<Group align="center" wrap="wrap">
|
||||
<Button loading={preview.isPending} onClick={handlePreview}>
|
||||
Preview plan
|
||||
</Button>
|
||||
<Checkbox
|
||||
label="Force assign (bypass hold/overweight warnings)"
|
||||
checked={forceAssign}
|
||||
onChange={(e) => setForceAssign(e.currentTarget.checked)}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{previewResult ? (
|
||||
<Stack gap="sm">
|
||||
<ScheduleWarningsAlert
|
||||
violations={previewResult.violations}
|
||||
warnings={previewResult.warnings}
|
||||
/>
|
||||
<FleetAvailabilitySummary
|
||||
fleetAvailability={previewResult.fleetAvailability}
|
||||
deferredBookings={previewResult.deferredBookings}
|
||||
/>
|
||||
<PreviewSummary summary={previewResult.summary} />
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step label="Wagon plan" description="Allocations">
|
||||
<Stack gap="md" mt="lg">
|
||||
<ScheduleWarningsAlert
|
||||
violations={previewResult?.violations}
|
||||
warnings={previewResult?.warnings}
|
||||
/>
|
||||
{reschedulePlan?.displaced.length ? (
|
||||
<Card withBorder padding="md" radius="xl">
|
||||
<Stack gap="sm">
|
||||
<Text fw={600} size="sm" c="orange">
|
||||
Government preempt — bookings to displace
|
||||
</Text>
|
||||
{reschedulePlan.displaced.map((b) => (
|
||||
<Text key={b.id} size="sm">
|
||||
{b.reference} (priority {b.priorityScore})
|
||||
</Text>
|
||||
))}
|
||||
<Checkbox
|
||||
label="I confirm displacing the bookings listed above"
|
||||
checked={confirmPreempt}
|
||||
onChange={(e) => setConfirmPreempt(e.currentTarget.checked)}
|
||||
<Badge
|
||||
size="lg"
|
||||
radius="sm"
|
||||
variant="white"
|
||||
c={previewResult.valid ? "green.8" : "red.7"}
|
||||
leftSection={
|
||||
<Box
|
||||
w={8}
|
||||
h={8}
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
background: previewResult.valid
|
||||
? "var(--mantine-color-green-6)"
|
||||
: "var(--mantine-color-red-6)",
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
Preview {previewResult.valid ? "valid" : "has issues"}
|
||||
</Badge>
|
||||
) : null}
|
||||
<PreviewSummary summary={previewResult?.summary} />
|
||||
<FleetAvailabilitySummary
|
||||
fleetAvailability={previewResult?.fleetAvailability}
|
||||
deferredBookings={previewResult?.deferredBookings}
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
||||
<StatTile
|
||||
onDark
|
||||
icon={Wallet}
|
||||
label="Total value"
|
||||
value={`${booking.paymentCurrency} ${amount.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
})}`}
|
||||
hint={booking.paymentStatus}
|
||||
/>
|
||||
<WagonPlanGrid
|
||||
wagonPlan={previewResult?.wagonPlan ?? []}
|
||||
freightType={previewFreightType ?? bookingFreightType}
|
||||
<StatTile onDark icon={Weight} label="Cargo weight" value={`${weight} T`} hint="VGM total" />
|
||||
<StatTile
|
||||
onDark
|
||||
icon={ContainerIcon}
|
||||
label="Containers"
|
||||
value={containerCount || "—"}
|
||||
hint={`${containers.length} line${containers.length === 1 ? "" : "s"}`}
|
||||
/>
|
||||
<Group>
|
||||
{!hasContainerStep ? (
|
||||
<Button color="teal" loading={assign.isPending || create.isPending} onClick={handleAssign}>
|
||||
Assign bookings
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="light" onClick={() => setActiveStep(2)}>
|
||||
Continue to containers
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="default" onClick={handlePreview}>
|
||||
Refresh preview
|
||||
</Button>
|
||||
<StatTile
|
||||
onDark
|
||||
icon={Flame}
|
||||
label="Priority"
|
||||
value={booking.priorityScore ?? 0}
|
||||
hint={booking.tradeDirection}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Workflow */}
|
||||
<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 }}
|
||||
>
|
||||
<RouteIcon size={22} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2}>
|
||||
<Title order={4} fw={700}>
|
||||
Allocation workflow
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{completedCount} of {stepsMeta.length} steps complete · expand any step
|
||||
to edit
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
<RingProgress
|
||||
size={64}
|
||||
thickness={6}
|
||||
roundCaps
|
||||
sections={[{ value: progressPct, color: "green" }]}
|
||||
label={
|
||||
<Text ta="center" size="xs" fw={700} c="green.7">
|
||||
{progressPct}%
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{hasContainerStep ? (
|
||||
<Stepper.Step label="Containers" description="Map units">
|
||||
<Stack gap="md" mt="lg">
|
||||
{!containerUnits.length ? (
|
||||
<Paper p="md" radius="xl" withBorder bg="gray.0">
|
||||
<Text size="sm" c="dimmed">
|
||||
Run preview from the Bookings step to load container units for numbering.
|
||||
</Text>
|
||||
</Paper>
|
||||
) : (
|
||||
<ContainerPlacementGrid
|
||||
units={containerUnits}
|
||||
containerSlots={containerSlots}
|
||||
placements={containerPlacements}
|
||||
onChange={setContainerPlacements}
|
||||
/>
|
||||
)}
|
||||
<Group>
|
||||
<Button color="teal" loading={assign.isPending || create.isPending} onClick={handleAssign}>
|
||||
Assign bookings
|
||||
</Button>
|
||||
<Button variant="light" onClick={() => setActiveStep(finalizeStep)}>
|
||||
Skip to finalize
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
) : null}
|
||||
|
||||
<Stepper.Step label="Finalize" description="Depart">
|
||||
<Stack gap="md" mt="lg">
|
||||
{allocationComplete ? (
|
||||
<Paper p="lg" radius="xl" withBorder bg="teal.0">
|
||||
<Stack gap="md" align="center">
|
||||
<CheckCircle2 size={40} color="var(--mantine-color-teal-7)" />
|
||||
<Text fw={700} size="lg">
|
||||
Allocation complete
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
Booking {booking.reference} is scheduled on train{" "}
|
||||
{assignedSchedule?.trainSet?.locomotive?.code ?? "—"}.
|
||||
</Text>
|
||||
<Group>
|
||||
<Button
|
||||
color="teal"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
if (assignedSchedule?.id) {
|
||||
navigate(
|
||||
`/dashboard/operations/train-scheduling-v2/${assignedSchedule.id}`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
View schedule
|
||||
</Button>
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : (
|
||||
<>
|
||||
<Paper p="md" radius="xl" withBorder bg="gray.0">
|
||||
<Text size="sm" c="dimmed">
|
||||
Finalize moves the schedule to SCHEDULED and completes the booking
|
||||
allocation.
|
||||
</Text>
|
||||
</Paper>
|
||||
<Group>
|
||||
<Button color="teal" loading={finalize.isPending} onClick={handleFinalize}>
|
||||
Finalize schedule
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
</Stepper>
|
||||
<WorkflowRail>
|
||||
{stepsMeta.map((step, index) => (
|
||||
<WorkflowStep
|
||||
key={step.key}
|
||||
index={index}
|
||||
icon={step.icon}
|
||||
title={step.title}
|
||||
subtitle={step.subtitle}
|
||||
state={
|
||||
activeStep === index
|
||||
? "active"
|
||||
: step.complete
|
||||
? "complete"
|
||||
: "upcoming"
|
||||
}
|
||||
open={activeStep === index}
|
||||
onToggle={() => toggleStep(index)}
|
||||
rightSlot={renderStepRightSlot(step.key)}
|
||||
>
|
||||
{renderStepBody(step.key)}
|
||||
</WorkflowStep>
|
||||
))}
|
||||
</WorkflowRail>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { Fragment } from "react";
|
||||
import { Badge, Box, Button, Group, Stack, Text } from "@mantine/core";
|
||||
import { Check, Flag, MapPin, Train } from "lucide-react";
|
||||
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
import type { TrainCheckpoint, TrackStation } from "@/types/trainScheduling";
|
||||
|
||||
export interface RouteCorridorTrackProps {
|
||||
stations: TrackStation[];
|
||||
/** Highest sequenceNo reached so far (−1 = not yet departed). */
|
||||
currentSequenceNo: number;
|
||||
checkpoints: TrainCheckpoint[];
|
||||
/** True when the train is DISPATCHED and staff may log progress. */
|
||||
canLog: boolean;
|
||||
loggingSeq?: number | null;
|
||||
onLogCheckpoint?: (sequenceNo: number) => void;
|
||||
}
|
||||
|
||||
const COLUMN_WIDTH = 150;
|
||||
const PASSED = freightBrand.primary;
|
||||
const UPCOMING = "var(--mantine-color-gray-3)";
|
||||
|
||||
function railColor(active: boolean) {
|
||||
return active ? PASSED : UPCOMING;
|
||||
}
|
||||
|
||||
export function RouteCorridorTrack({
|
||||
stations,
|
||||
currentSequenceNo,
|
||||
checkpoints,
|
||||
canLog,
|
||||
loggingSeq,
|
||||
onLogCheckpoint,
|
||||
}: RouteCorridorTrackProps) {
|
||||
const bySeq = new Map(checkpoints.map((c) => [c.sequenceNo, c]));
|
||||
const lastIndex = stations.length - 1;
|
||||
|
||||
return (
|
||||
<Box style={{ overflowX: "auto", paddingBottom: 4 }}>
|
||||
<Group
|
||||
gap={0}
|
||||
wrap="nowrap"
|
||||
align="flex-start"
|
||||
style={{ minWidth: stations.length * COLUMN_WIDTH }}
|
||||
>
|
||||
{stations.map((station, index) => {
|
||||
const passed = station.sequenceNo <= currentSequenceNo;
|
||||
const isCurrent = station.sequenceNo === currentSequenceNo;
|
||||
const isFinal = index === lastIndex;
|
||||
const isNext = canLog && station.sequenceNo === currentSequenceNo + 1;
|
||||
const checkpoint = bySeq.get(station.sequenceNo);
|
||||
// left rail solid once this node is reached; right rail solid once the next node is reached
|
||||
const leftActive = station.sequenceNo <= currentSequenceNo;
|
||||
const rightActive = station.sequenceNo + 1 <= currentSequenceNo;
|
||||
|
||||
return (
|
||||
<Fragment key={station.sequenceNo}>
|
||||
<Stack gap={6} align="center" style={{ width: COLUMN_WIDTH, flexShrink: 0 }}>
|
||||
{/* rail + node */}
|
||||
<Box style={{ position: "relative", height: 44, width: "100%" }}>
|
||||
{index > 0 && (
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 21,
|
||||
left: 0,
|
||||
width: "50%",
|
||||
height: 3,
|
||||
borderRadius: 2,
|
||||
background: railColor(leftActive),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{index < lastIndex && (
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 21,
|
||||
left: "50%",
|
||||
width: "50%",
|
||||
height: 3,
|
||||
borderRadius: 2,
|
||||
background: railColor(rightActive),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* train marker hovering over the current node */}
|
||||
{isCurrent && (
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -8,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
color: freightBrand.primaryDark,
|
||||
}}
|
||||
>
|
||||
<Train size={18} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* node */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 12,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: passed ? PASSED : "white",
|
||||
border: `2px solid ${
|
||||
passed
|
||||
? PASSED
|
||||
: isNext
|
||||
? freightBrand.primaryLight
|
||||
: "var(--mantine-color-gray-4)"
|
||||
}`,
|
||||
boxShadow: isCurrent ? `0 0 0 4px ${freightBrand.ring}` : "none",
|
||||
color: "white",
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{passed ? (
|
||||
<Check size={13} />
|
||||
) : isFinal ? (
|
||||
<Flag size={12} color="var(--mantine-color-gray-5)" />
|
||||
) : (
|
||||
<MapPin size={12} color="var(--mantine-color-gray-5)" />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* label */}
|
||||
<Stack gap={0} align="center" style={{ minWidth: 0, padding: "0 6px" }}>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={passed ? 700 : 600}
|
||||
ta="center"
|
||||
lineClamp={2}
|
||||
c={passed ? "green.8" : "dimmed"}
|
||||
>
|
||||
{station.label}
|
||||
</Text>
|
||||
{index === 0 ? (
|
||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||
Origin
|
||||
</Badge>
|
||||
) : isFinal ? (
|
||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||
Destination
|
||||
</Badge>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
{/* checkpoint time or action */}
|
||||
{checkpoint ? (
|
||||
<Text size="10px" c="dimmed" ta="center">
|
||||
{new Date(checkpoint.occurredAt).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Text>
|
||||
) : isNext ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
radius="md"
|
||||
color={isFinal ? "teal" : "green"}
|
||||
variant={isFinal ? "filled" : "light"}
|
||||
loading={loggingSeq === station.sequenceNo}
|
||||
onClick={() => onLogCheckpoint?.(station.sequenceNo)}
|
||||
>
|
||||
{isFinal ? "Mark arrived" : "Log pass"}
|
||||
</Button>
|
||||
) : (
|
||||
<Box style={{ height: 22 }} />
|
||||
)}
|
||||
</Stack>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -47,6 +47,7 @@ export const QUERY_KEYS = {
|
||||
stations: () => ["train-scheduling", "stations"] as const,
|
||||
schedules: () => ["train-scheduling", "schedules"] as const,
|
||||
scheduleById: (id: string) => ["train-scheduling", "schedule", id] as const,
|
||||
track: (id: string) => ["train-scheduling", "track", id] as const,
|
||||
},
|
||||
|
||||
FLEET: {
|
||||
|
||||
@@ -160,6 +160,8 @@ export const URL_CONSTANTS = {
|
||||
PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`,
|
||||
FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`,
|
||||
DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`,
|
||||
CHECKPOINTS: (id: string) => `/train-scheduling/schedules/${id}/checkpoints`,
|
||||
ARRIVE: (id: string) => `/train-scheduling/schedules/${id}/arrive`,
|
||||
RESCHEDULE_PREVIEW: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/reschedule/preview`,
|
||||
RESCHEDULE_EXECUTE: (id: string) =>
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
CreateTrainSchedulePayload,
|
||||
FreightType,
|
||||
PinWagonsPayload,
|
||||
RecordCheckpointPayload,
|
||||
TrainScheduleFilters,
|
||||
TrainSchedulePreviewPayload,
|
||||
} from "@/types/trainScheduling";
|
||||
@@ -41,6 +42,13 @@ export const useAvailableLocomotives = () =>
|
||||
queryFn: () => trainSchedulingService.getAvailableLocomotives(),
|
||||
});
|
||||
|
||||
export const useTrainTrack = (id: string | undefined) =>
|
||||
useQuery({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.track(id ?? ""),
|
||||
queryFn: () => trainSchedulingService.getTrack(id!),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
|
||||
export const useScheduleMutations = (scheduleId?: string) => {
|
||||
const qc = useQueryClient();
|
||||
|
||||
@@ -52,6 +60,9 @@ export const useScheduleMutations = (scheduleId?: string) => {
|
||||
void qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(scheduleId),
|
||||
});
|
||||
void qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.track(scheduleId),
|
||||
});
|
||||
}
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.BOOKINGS.ROOT });
|
||||
};
|
||||
@@ -118,5 +129,28 @@ export const useScheduleMutations = (scheduleId?: string) => {
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { create, preview, assign, unassign, pin, finalize, dispatch, cancel, invalidate };
|
||||
const recordCheckpoint = useMutation({
|
||||
mutationFn: ({ id, payload }: { id: string; payload: RecordCheckpointPayload }) =>
|
||||
trainSchedulingService.recordCheckpoint(id, payload),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const arrive = useMutation({
|
||||
mutationFn: (id: string) => trainSchedulingService.arriveSchedule(id),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return {
|
||||
create,
|
||||
preview,
|
||||
assign,
|
||||
unassign,
|
||||
pin,
|
||||
finalize,
|
||||
dispatch,
|
||||
cancel,
|
||||
recordCheckpoint,
|
||||
arrive,
|
||||
invalidate,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -8,12 +8,14 @@ import type {
|
||||
FreightType,
|
||||
LocomotiveRecord,
|
||||
PinWagonsPayload,
|
||||
RecordCheckpointPayload,
|
||||
TrainScheduleDetail,
|
||||
TrainScheduleFilters,
|
||||
TrainScheduleListItem,
|
||||
TrainSchedulePreviewPayload,
|
||||
TrainSchedulePreviewResponse,
|
||||
TrainSchedulingGlobalRules,
|
||||
TrainTrackResponse,
|
||||
YardOption,
|
||||
} from '@/types/trainScheduling';
|
||||
|
||||
@@ -137,6 +139,32 @@ export const trainSchedulingService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
getTrack: async (scheduleId: string): Promise<TrainTrackResponse> => {
|
||||
const response = await client.get<TrainTrackResponse>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.CHECKPOINTS(scheduleId),
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
recordCheckpoint: async (
|
||||
scheduleId: string,
|
||||
payload: RecordCheckpointPayload,
|
||||
): Promise<TrainTrackResponse> => {
|
||||
const response = await client.post<TrainTrackResponse>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.CHECKPOINTS(scheduleId),
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
arriveSchedule: async (scheduleId: string): Promise<TrainScheduleDetail> => {
|
||||
const response = await client.post<TrainScheduleDetail>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.ARRIVE(scheduleId),
|
||||
{},
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
cancelSchedule: async (
|
||||
id: string,
|
||||
freightType: FreightType = "CONTAINER",
|
||||
|
||||
@@ -127,6 +127,8 @@ export interface TrainSchedulePreviewResponse {
|
||||
containerSlotSequenceNos?: number[];
|
||||
}
|
||||
|
||||
export type Readiness = "IMPORT_READY" | "EXPORT_READY";
|
||||
|
||||
export interface LocomotiveRecord {
|
||||
id: string;
|
||||
code: string;
|
||||
@@ -134,6 +136,7 @@ export interface LocomotiveRecord {
|
||||
maxPullWeightTons: number;
|
||||
maxTrainLengthMeters: number;
|
||||
status: "AVAILABLE" | "ASSIGNED" | "MAINTENANCE" | "OUT_OF_SERVICE";
|
||||
readiness?: Readiness | null;
|
||||
locomotiveType?: "DIESEL" | "ELECTRIC";
|
||||
}
|
||||
|
||||
@@ -150,6 +153,7 @@ export interface TrainScheduleListItem {
|
||||
id: string;
|
||||
code: string;
|
||||
name?: string | null;
|
||||
readiness?: Readiness | null;
|
||||
}
|
||||
| null;
|
||||
wagonCount: number;
|
||||
@@ -197,6 +201,7 @@ export interface TrainScheduleDetail {
|
||||
scheduledDepartureDate: string;
|
||||
scheduledArrivalDate?: string | null;
|
||||
actualDepartureAt?: string | null;
|
||||
actualArrivalAt?: string | null;
|
||||
originStation?: {
|
||||
id: string;
|
||||
label?: string;
|
||||
@@ -218,6 +223,7 @@ export interface TrainScheduleDetail {
|
||||
code: string;
|
||||
name?: string | null;
|
||||
status: string;
|
||||
readiness?: Readiness | null;
|
||||
maxPullWeightTons: number;
|
||||
maxTrainLengthMeters?: number;
|
||||
} | null;
|
||||
@@ -249,6 +255,46 @@ export interface TrainScheduleDetail {
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
export type TrainCheckpointKind = "DEPARTED" | "PASSED" | "ARRIVED";
|
||||
|
||||
export interface TrackStation {
|
||||
sequenceNo: number;
|
||||
yardId: string;
|
||||
label: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface TrainCheckpoint {
|
||||
id: string;
|
||||
sequenceNo: number;
|
||||
yardId: string;
|
||||
label: string | null;
|
||||
kind: TrainCheckpointKind;
|
||||
occurredAt: string;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export interface TrainTrackResponse {
|
||||
scheduleId: string;
|
||||
status: TrainScheduleStatus | string;
|
||||
direction?: string | null;
|
||||
trainNumber?: string | null;
|
||||
actualDepartureAt?: string | null;
|
||||
actualArrivalAt?: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
stations: TrackStation[];
|
||||
currentSequenceNo: number;
|
||||
checkpoints: TrainCheckpoint[];
|
||||
}
|
||||
|
||||
export interface RecordCheckpointPayload {
|
||||
sequenceNo: number;
|
||||
kind?: TrainCheckpointKind;
|
||||
occurredAt?: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface TrainScheduleFilters {
|
||||
originStationId?: string;
|
||||
destinationStationId?: string;
|
||||
|
||||
Reference in New Issue
Block a user