mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +00:00
Intercity bookings ride the wagons freed by earlier unloads (e.g. import containers uncoupled at Dire Dawa). loadBooking now auto-allocates a DOMESTIC booking onto on-train slots whose cargo has all departed — greedy in consist order by capacity, container numbers copied for the marshalling tally. Falls back to unallocated load when nothing is free.
1447 lines
51 KiB
TypeScript
1447 lines
51 KiB
TypeScript
import {
|
|
Alert,
|
|
Badge,
|
|
Box,
|
|
Button,
|
|
Checkbox,
|
|
Group,
|
|
List,
|
|
Loader,
|
|
Modal,
|
|
Paper,
|
|
RingProgress,
|
|
Stack,
|
|
Tabs,
|
|
Text,
|
|
ThemeIcon,
|
|
Title,
|
|
} from "@mantine/core";
|
|
import { isAxiosError } from "axios";
|
|
import {
|
|
AlertTriangle,
|
|
ArrowLeft,
|
|
CalendarClock,
|
|
CheckCircle2,
|
|
Clock,
|
|
Container as ContainerIcon,
|
|
Eye,
|
|
FileText,
|
|
History as HistoryIcon,
|
|
LayoutGrid,
|
|
Navigation,
|
|
Package,
|
|
PackageCheck,
|
|
Route as RouteIcon,
|
|
Ruler,
|
|
Send,
|
|
Train,
|
|
Weight,
|
|
Workflow as WorkflowIcon,
|
|
} from "lucide-react";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { Link, useParams } from "react-router-dom";
|
|
|
|
import { KpiStrip, PageContainer } from "@/components/page";
|
|
import {
|
|
autoFillPlacements,
|
|
mergePlacementsWithSaved,
|
|
placementsFromScheduleWagons,
|
|
validateLocalPlacements,
|
|
} from "@/components/trainScheduling/containerPlacement.util";
|
|
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
|
|
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
|
|
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
|
|
import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel";
|
|
import ScheduleHistoryPanel from "@/components/trainScheduling/ScheduleHistoryPanel";
|
|
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
|
|
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
|
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
|
// import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
|
|
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
|
|
import { SwitchGovernmentBookingModal } from "@/components/trainScheduling/SwitchGovernmentBookingModal";
|
|
import { ScheduleWorkspacePanel } from "@/components/trainScheduling/ScheduleWorkspacePanel";
|
|
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
|
import {
|
|
RouteCorridor,
|
|
SegmentOccupancyStrip,
|
|
StatusPill,
|
|
scheduleBrand,
|
|
} from "@/components/trainScheduling/scheduleVisuals";
|
|
import {
|
|
PreviewSummary,
|
|
ScheduleWarningsAlert,
|
|
} from "@/components/trainScheduling/ScheduleWarningsAlert";
|
|
import { Train3DVisualization } from "@/components/trainScheduling/Train3DVisualization";
|
|
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
|
import { TrainConsistView } from "@/components/trainScheduling/compositionEditor";
|
|
import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid";
|
|
import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep";
|
|
import { openPdfBlob } from "@/components/warehouses/pdf";
|
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket";
|
|
import { api } from "@/services/api";
|
|
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import type {
|
|
ContainerPlacement,
|
|
EligibleContainerBooking,
|
|
FreightType,
|
|
TrainSchedulePreviewResponse,
|
|
} from "@/types/trainScheduling";
|
|
|
|
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;
|
|
const violations = data?.violations;
|
|
if (Array.isArray(violations)) return violations.join(", ");
|
|
}
|
|
return fallback;
|
|
};
|
|
|
|
export default function TrainScheduleV2DetailPage() {
|
|
const { scheduleId } = useParams<{ scheduleId: string }>();
|
|
const { toast } = useToast();
|
|
const [activeStep, setActiveStep] = useState(0);
|
|
const [selectedBookingIds, setSelectedBookingIds] = useState<string[]>([]);
|
|
const [forceAssign, setForceAssign] = useState(false);
|
|
const [previewResult, setPreviewResult] = useState<TrainSchedulePreviewResponse | null>(null);
|
|
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
|
|
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
|
|
const [windowSettingsOpen, setWindowSettingsOpen] = useState(false);
|
|
const [gatepassSecuredAt, setGatepassSecuredAt] = useState("");
|
|
const [gatepassReference, setGatepassReference] = useState("");
|
|
const [gatepassFileUrl, setGatepassFileUrl] = useState("");
|
|
const [gatepassNotes, setGatepassNotes] = useState("");
|
|
const [dispatchConfirmOpen, setDispatchConfirmOpen] = useState(false);
|
|
const [switchTarget, setSwitchTarget] = useState<EligibleContainerBooking | null>(null);
|
|
const [visualization3DOpen, setVisualization3DOpen] = useState(false);
|
|
const autoPreviewedRef = useRef(false);
|
|
|
|
const detailQuery = useQuery(
|
|
api.trainScheduling.scheduleDetail.queryOptions({
|
|
input: { id: scheduleId ?? "" },
|
|
enabled: Boolean(scheduleId),
|
|
// Live phase updates come from the booking-window socket (PHASE pushes
|
|
// invalidate this query); 60s is the self-heal net for a missed emit so
|
|
// the workspace countdown never freezes on an expired phase.
|
|
refetchInterval: 60_000,
|
|
}),
|
|
);
|
|
useBookingWindowSocket(Boolean(scheduleId));
|
|
const schedule = detailQuery.data;
|
|
const freightType: FreightType | undefined = schedule?.freightType as FreightType | undefined;
|
|
const isDjiboutiPort = (value?: string | null) =>
|
|
["DJIBOUTI", "DORALEH", "DMP", "DCT", "NAGAD"].some((token) =>
|
|
(value ?? "").toUpperCase().includes(token),
|
|
);
|
|
const gatepassApplies = Boolean(
|
|
schedule &&
|
|
((schedule.direction === "IMPORT" &&
|
|
isDjiboutiPort(`${schedule.originStation?.code ?? ""} ${schedule.originStation?.label ?? ""}`)) ||
|
|
(schedule.direction === "EXPORT" &&
|
|
isDjiboutiPort(`${schedule.destinationStation?.code ?? ""} ${schedule.destinationStation?.label ?? ""}`))),
|
|
);
|
|
const gatepassQuery = useQuery({
|
|
queryKey: ["train-scheduling", "gatepass", scheduleId],
|
|
queryFn: () => trainSchedulingService.getImportDjiboutiOperation(scheduleId as string),
|
|
enabled: Boolean(scheduleId && gatepassApplies),
|
|
});
|
|
const gatepassSecured = gatepassQuery.data?.gatepassStatus === "SECURED";
|
|
const secureGatepass = useMutation({
|
|
mutationFn: () =>
|
|
trainSchedulingService.grantImportDjiboutiGatepass(scheduleId as string, {
|
|
securedAt: gatepassSecuredAt ? new Date(gatepassSecuredAt).toISOString() : undefined,
|
|
reference: gatepassReference.trim() || undefined,
|
|
fileUrl: gatepassFileUrl.trim() || undefined,
|
|
notes: gatepassNotes.trim() || undefined,
|
|
}),
|
|
onSuccess: () => {
|
|
toast({ title: "Gate pass secured" });
|
|
void gatepassQuery.refetch();
|
|
},
|
|
onError: (error) => {
|
|
toast({
|
|
title: "Gate pass failed",
|
|
description: parseError(error, "Could not secure gate pass"),
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
|
|
// Superseded by the Workspace tab Load/Unload toggle — see commented
|
|
// "Import loading confirmation" card below.
|
|
// const importLoadingQuery = useQuery(
|
|
// api.trainScheduling.importLoadingBookings.queryOptions({
|
|
// input: { id: scheduleId ?? "" },
|
|
// enabled: Boolean(scheduleId && schedule?.direction === "IMPORT"),
|
|
// }),
|
|
// );
|
|
|
|
const eligibleFilters = useMemo(
|
|
() =>
|
|
schedule
|
|
? {
|
|
originStationId: schedule.originStation?.id,
|
|
destinationStationId: schedule.destinationStation?.id,
|
|
// Only this schedule's own bookings are eligible — same rule as the auto batch.
|
|
trainScheduleId: scheduleId,
|
|
}
|
|
: undefined,
|
|
[schedule, scheduleId],
|
|
);
|
|
|
|
const eligibleFreightType =
|
|
freightType === "CONTAINER" || freightType === "BULK" ? freightType : undefined;
|
|
|
|
const eligibleQuery = useQuery(
|
|
api.trainScheduling.eligibleBookings.queryOptions({
|
|
input: { filters: eligibleFilters, freightType: eligibleFreightType },
|
|
enabled: Boolean(schedule),
|
|
}),
|
|
);
|
|
const preview = useMutation(api.trainScheduling.preview.mutationOptions());
|
|
const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions());
|
|
const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
|
|
const switchGov = useMutation(api.trainScheduling.switchGovernmentBooking.mutationOptions());
|
|
const dispatch = useMutation(api.trainScheduling.dispatchSchedule.mutationOptions());
|
|
const downloadMarshalling = useMutation({
|
|
mutationFn: ({ id, direction, variant }: { id: string; direction?: string | null; variant?: "INTERCITY" }) =>
|
|
variant === "INTERCITY"
|
|
? trainSchedulingService.downloadIntercityMarshallingDocument(id)
|
|
: direction === "EXPORT"
|
|
? trainSchedulingService.downloadExportLoadListDocument(id)
|
|
: trainSchedulingService.downloadImportDjiboutiLoadListDocument(id),
|
|
});
|
|
|
|
useEffect(() => {
|
|
const operation = gatepassQuery.data;
|
|
if (!operation) return;
|
|
const secured = operation.gatepassSecuredAt ?? operation.gatepassGrantedAt;
|
|
setGatepassSecuredAt(secured ? new Date(secured).toISOString().slice(0, 16) : "");
|
|
setGatepassReference(operation.documents?.GATE_PASS?.reference ?? "");
|
|
setGatepassFileUrl(operation.documents?.GATE_PASS?.fileUrl ?? "");
|
|
setGatepassNotes(operation.documents?.GATE_PASS?.notes ?? operation.notes ?? "");
|
|
}, [gatepassQuery.data]);
|
|
|
|
const assignedIds = useMemo(
|
|
() => (schedule?.bookings ?? []).map((b) => b.id),
|
|
[schedule?.bookings],
|
|
);
|
|
|
|
const allSelectedIds = useMemo(() => {
|
|
const merged = new Set([...assignedIds, ...selectedBookingIds]);
|
|
return [...merged];
|
|
}, [assignedIds, selectedBookingIds]);
|
|
|
|
const containerUnits = previewResult?.containerUnits ?? [];
|
|
const containerSlots = previewResult?.containerSlotSequenceNos ?? [];
|
|
// Container-number placement step removed — the customer enters container
|
|
// numbers when booking, so scheduling skips straight from the wagon plan to
|
|
// finalize. Steps: select bookings → review wagons → finalize.
|
|
const hasContainerStep = false;
|
|
|
|
const displayWagonPlan = useMemo(() => {
|
|
const savedWagons = schedule?.trainSet?.wagons ?? [];
|
|
// Map each slot to its reserved physical wagon number (from the wagon table) so the
|
|
// plan shows real wagon ids (e.g. WGN-DEMO-001) instead of generic "Wagon #1".
|
|
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, schedule?.trainSet?.wagons]);
|
|
|
|
// EXPORT schedules render the consist back-to-front (the train turns around
|
|
// for the return run) — DISPLAY ONLY: stored sequenceNos, allocations,
|
|
// documents, and the adjust-consist / placement flows keep the as-built order.
|
|
const isExportDisplay = schedule?.direction === "EXPORT";
|
|
const displayWagonPlanOriented = useMemo(
|
|
() => (isExportDisplay ? [...displayWagonPlan].reverse() : displayWagonPlan),
|
|
[displayWagonPlan, isExportDisplay],
|
|
);
|
|
|
|
const runPreview = useCallback(
|
|
async (options?: { silent?: boolean; advanceStep?: boolean }) => {
|
|
if (!schedule || !scheduleId) return null;
|
|
if (!allSelectedIds.length) {
|
|
if (!options?.silent) {
|
|
toast({ title: "Select at least one booking", variant: "destructive" });
|
|
}
|
|
return null;
|
|
}
|
|
const originStationId = schedule.originStation?.id;
|
|
const destinationStationId = schedule.destinationStation?.id;
|
|
if (!originStationId || !destinationStationId) {
|
|
if (!options?.silent) {
|
|
toast({ title: "Schedule missing origin or destination", variant: "destructive" });
|
|
}
|
|
return null;
|
|
}
|
|
try {
|
|
const result = await preview.mutateAsync({
|
|
freightType,
|
|
payload: {
|
|
bookingIds: allSelectedIds,
|
|
scheduleDate: schedule.scheduledDepartureDate,
|
|
originStationId,
|
|
destinationStationId,
|
|
targetScheduleId: scheduleId,
|
|
},
|
|
});
|
|
setPreviewResult(result);
|
|
if (result.containerUnits?.length && result.containerSlotSequenceNos?.length) {
|
|
const autoFilled = autoFillPlacements(
|
|
result.containerUnits,
|
|
result.containerSlotSequenceNos,
|
|
);
|
|
const saved = schedule.trainSet?.wagons
|
|
? placementsFromScheduleWagons(schedule.trainSet.wagons)
|
|
: [];
|
|
setContainerPlacements(
|
|
saved.length ? mergePlacementsWithSaved(autoFilled, saved) : autoFilled,
|
|
);
|
|
} else {
|
|
setContainerPlacements([]);
|
|
}
|
|
if (!options?.silent) {
|
|
if (!result.valid) {
|
|
toast({ title: "Preview has violations", variant: "destructive" });
|
|
} else if (options?.advanceStep !== false) {
|
|
setActiveStep(1);
|
|
}
|
|
}
|
|
return result;
|
|
} catch (err) {
|
|
if (!options?.silent) {
|
|
toast({
|
|
title: "Preview failed",
|
|
description: parseError(err, "Could not preview"),
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
return null;
|
|
}
|
|
},
|
|
[allSelectedIds, freightType, preview, schedule, scheduleId, toast],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!schedule || !scheduleId || autoPreviewedRef.current) return;
|
|
if (!assignedIds.length) return;
|
|
autoPreviewedRef.current = true;
|
|
void runPreview({ silent: true, advanceStep: false });
|
|
}, [assignedIds.length, runPreview, schedule, scheduleId]);
|
|
|
|
const savedPlacementsFromSchedule = useMemo(
|
|
() =>
|
|
schedule?.trainSet?.wagons
|
|
? placementsFromScheduleWagons(schedule.trainSet.wagons)
|
|
: [],
|
|
[schedule?.trainSet?.wagons],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!containerUnits.length || !containerSlots.length) return;
|
|
|
|
setContainerPlacements((current) => {
|
|
if (current.length && current.some((p) => p.containerNumber?.trim())) {
|
|
return current;
|
|
}
|
|
const autoFilled = autoFillPlacements(containerUnits, containerSlots);
|
|
if (savedPlacementsFromSchedule.length) {
|
|
return mergePlacementsWithSaved(autoFilled, savedPlacementsFromSchedule);
|
|
}
|
|
if (current.length) return current;
|
|
return autoFilled;
|
|
});
|
|
}, [containerUnits, containerSlots, savedPlacementsFromSchedule]);
|
|
|
|
if (detailQuery.isLoading) {
|
|
return (
|
|
<PageContainer>
|
|
<Group justify="center" py="xl">
|
|
<Loader size="sm" />
|
|
</Group>
|
|
</PageContainer>
|
|
);
|
|
}
|
|
|
|
if (!schedule || !scheduleId) {
|
|
return (
|
|
<PageContainer>
|
|
<Text c="dimmed" py="xl">
|
|
Schedule not found
|
|
</Text>
|
|
</PageContainer>
|
|
);
|
|
}
|
|
|
|
// All locomotives pulling the train (≥2), falling back to the legacy single loco.
|
|
const locomotives =
|
|
schedule.trainSet?.locomotives && schedule.trainSet.locomotives.length > 0
|
|
? schedule.trainSet.locomotives
|
|
: schedule.trainSet?.locomotive
|
|
? [schedule.trainSet.locomotive]
|
|
: [];
|
|
|
|
const canEditBookings = ["DRAFT", "SCHEDULED"].includes(schedule.status);
|
|
const canDispatch = schedule.status === "SCHEDULED";
|
|
|
|
// Dispatch readiness: bookings with no wagon, and wagon-loaded bookings whose
|
|
// cargo staff never marked loaded. Both are warnings, not blockers — staff can
|
|
// still dispatch after confirming.
|
|
const dispatchBookings = schedule.bookings ?? [];
|
|
const unassignedCount = dispatchBookings.filter((b) => !b.wagonAssigned).length;
|
|
const unloadedCount = dispatchBookings.filter(
|
|
(b) => b.wagonAssigned && (b.loadingStatus ?? "UNLOADED") !== "LOADED",
|
|
).length;
|
|
// Intercity ride-alongs load through the journey flow (Load at their origin
|
|
// yard), not the workspace toggle — dispatching before that leaves paid cargo
|
|
// stranded on the platform while its train departs.
|
|
const intercityNotLoadedCount = dispatchBookings.filter(
|
|
(b) =>
|
|
b.tradeDirection === "DOMESTIC" &&
|
|
!b.loadedAt &&
|
|
!["IN_TRANSIT", "COMPLETED"].includes(b.status ?? ""),
|
|
).length;
|
|
// No loading hard-block: bookings may board mid-corridor, so loading happens
|
|
// per yard from the track page's log-pass flow. Everything below is advisory.
|
|
const hasDispatchWarnings =
|
|
unassignedCount > 0 || unloadedCount > 0 || intercityNotLoadedCount > 0;
|
|
|
|
const finalizeStep = hasContainerStep ? 3 : 2;
|
|
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
|
|
const canPrintMarshalling =
|
|
(schedule.direction === "IMPORT" || schedule.direction === "EXPORT") &&
|
|
["DISPATCHED", "ARRIVED"].includes(schedule.status);
|
|
|
|
const openMarshallingDocument = async (options?: {
|
|
title?: string;
|
|
successDescription?: string;
|
|
errorTitle?: string;
|
|
variant?: "INTERCITY";
|
|
}) => {
|
|
const pdfWindow = window.open("", "_blank");
|
|
try {
|
|
const blob = await downloadMarshalling.mutateAsync({
|
|
id: scheduleId,
|
|
direction: schedule.direction,
|
|
variant: options?.variant,
|
|
});
|
|
const prefix =
|
|
options?.variant === "INTERCITY"
|
|
? "intercity-marshalling"
|
|
: schedule.direction === "EXPORT"
|
|
? "export-marshalling"
|
|
: "import-marshalling";
|
|
const filename = `${prefix}-${schedule.trainNumber ?? scheduleId}.pdf`;
|
|
const opened = openPdfBlob(blob, filename, pdfWindow);
|
|
toast({
|
|
title: options?.title ?? "Marshalling document ready",
|
|
description:
|
|
options?.successDescription ??
|
|
(opened
|
|
? "The PDF opened in a browser tab for printing or saving."
|
|
: "The browser blocked the preview tab, so the PDF was downloaded."),
|
|
});
|
|
} catch (error) {
|
|
pdfWindow?.close();
|
|
toast({
|
|
title: options?.errorTitle ?? "Could not open marshalling document",
|
|
description: parseError(error, "Make sure the train has wagon allocations, then try again."),
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
};
|
|
|
|
const runDispatch = async () => {
|
|
setDispatchConfirmOpen(false);
|
|
try {
|
|
await dispatch.mutateAsync(scheduleId);
|
|
await openMarshallingDocument({
|
|
title: "Train dispatched",
|
|
successDescription: "Marshalling document generated for the dispatched train.",
|
|
errorTitle: "Train dispatched, but document could not open",
|
|
});
|
|
} catch (err) {
|
|
toast({
|
|
title: "Dispatch failed",
|
|
description: parseError(err, "Could not dispatch"),
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleAssign = async () => {
|
|
if (!allSelectedIds.length) return;
|
|
|
|
if (hasContainerStep) {
|
|
const issues = validateLocalPlacements(containerUnits, containerPlacements);
|
|
if (issues.length) {
|
|
toast({
|
|
title: "Complete container assignments",
|
|
description: issues.join(", "),
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const result = await assign.mutateAsync({
|
|
id: scheduleId,
|
|
freightType,
|
|
payload: {
|
|
bookingIds: allSelectedIds,
|
|
forceAssign,
|
|
containerPlacements: hasContainerStep ? containerPlacements : undefined,
|
|
},
|
|
});
|
|
toast({ title: "Bookings assigned — wagons auto-pinned" });
|
|
const refreshed = await detailQuery.refetch();
|
|
const saved = refreshed.data?.trainSet?.wagons
|
|
? placementsFromScheduleWagons(refreshed.data.trainSet.wagons)
|
|
: [];
|
|
if (saved.length) {
|
|
setContainerPlacements(saved);
|
|
}
|
|
autoPreviewedRef.current = false;
|
|
setActiveStep(finalizeStep);
|
|
if (result.deferredBookings?.length) {
|
|
toast({
|
|
title: "Partial assignment",
|
|
description: `${result.deferredBookings.length} booking(s) deferred to next train`,
|
|
});
|
|
}
|
|
} catch (err) {
|
|
toast({
|
|
title: "Assign failed",
|
|
description: parseError(err, "Could not assign"),
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleUnassign = async (bookingId: string) => {
|
|
// Gov bookings never leave a train by removal — only by switching. The API
|
|
// enforces this too; the guard here just gives the warning without a call.
|
|
if (schedule?.bookings?.some((b) => b.id === bookingId && b.isGovernment)) {
|
|
toast({
|
|
title: "Government booking cannot be removed",
|
|
description:
|
|
"Government bookings cannot be removed from the train. They can only be switched onto another allocation.",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
try {
|
|
await unassign.mutateAsync({ id: scheduleId, bookingId });
|
|
toast({ title: "Booking unassigned" });
|
|
setSelectedBookingIds((ids) => ids.filter((id) => id !== bookingId));
|
|
setPreviewResult(null);
|
|
autoPreviewedRef.current = false;
|
|
} catch (err) {
|
|
toast({
|
|
title: "Unassign failed",
|
|
description: parseError(err, "Could not unassign"),
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
};
|
|
|
|
const containerComplete =
|
|
hasContainerStep &&
|
|
containerUnits.length > 0 &&
|
|
validateLocalPlacements(containerUnits, containerPlacements).length === 0;
|
|
const finalizeComplete = ["SCHEDULED", "DISPATCHED", "ARRIVED"].includes(
|
|
schedule.status,
|
|
);
|
|
|
|
const stepsMeta = [
|
|
{
|
|
key: "bookings",
|
|
icon: Package,
|
|
title: "Bookings",
|
|
subtitle: "Select cargo & preview the plan",
|
|
complete: Boolean(previewResult) || assignedIds.length > 0,
|
|
},
|
|
{
|
|
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: "Dispatch",
|
|
subtitle: "Review the consist & dispatch",
|
|
complete: finalizeComplete,
|
|
},
|
|
];
|
|
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 ? "edr-green" : "red"}
|
|
radius="sm"
|
|
>
|
|
{previewResult.valid ? "Plan valid" : "Has issues"}
|
|
</Badge>
|
|
);
|
|
}
|
|
return allSelectedIds.length ? (
|
|
<Badge variant="light" color="edr-green" radius="sm">
|
|
{allSelectedIds.length} selected
|
|
</Badge>
|
|
) : null;
|
|
}
|
|
if (key === "wagon" && displayWagonPlan.length) {
|
|
return (
|
|
<Group gap="xs">
|
|
{schedule.reverseWagonOrder ? (
|
|
<Badge variant="light" color="orange" radius="sm">
|
|
Reversed order
|
|
</Badge>
|
|
) : null}
|
|
<Badge variant="light" color="edr-green" radius="sm">
|
|
{displayWagonPlan.length} wagons
|
|
</Badge>
|
|
</Group>
|
|
);
|
|
}
|
|
if (key === "container" && containerUnits.length) {
|
|
return (
|
|
<Badge
|
|
variant="light"
|
|
color={containerComplete ? "edr-green" : "yellow"}
|
|
radius="sm"
|
|
>
|
|
{containerUnits.length} units
|
|
</Badge>
|
|
);
|
|
}
|
|
if (key === "finalize") {
|
|
return <StatusPill status={schedule.status} />;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const renderStepBody = (key: string) => {
|
|
if (key === "bookings") {
|
|
return (
|
|
<Stack gap="md">
|
|
<ScheduleBookingsStep
|
|
assignedBookings={(schedule.bookings ?? []).map((b) => ({
|
|
id: b.id,
|
|
reference: b.reference ?? b.id.slice(0, 8),
|
|
weightTons: b.weightTons,
|
|
isGovernment: b.isGovernment,
|
|
wagonsRequired: b.wagonsRequired,
|
|
contractReference: b.contractReference,
|
|
origin: b.origin,
|
|
destination: b.destination,
|
|
}))}
|
|
eligibleItems={eligibleQuery.data?.items ?? []}
|
|
eligibleLoading={eligibleQuery.isLoading}
|
|
selectedIds={allSelectedIds}
|
|
onSelectionChange={(ids) => {
|
|
const assigned = new Set(assignedIds);
|
|
setSelectedBookingIds(ids.filter((id) => !assigned.has(id)));
|
|
}}
|
|
assignedIds={assignedIds}
|
|
freightType={freightType}
|
|
canRemove={canModifyBookings}
|
|
onRemove={handleUnassign}
|
|
onSwitch={canModifyBookings ? setSwitchTarget : undefined}
|
|
/>
|
|
|
|
{canEditBookings ? (
|
|
<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="edr-green"
|
|
radius="md"
|
|
leftSection={<Eye size={16} />}
|
|
loading={preview.isPending}
|
|
onClick={() => void runPreview()}
|
|
>
|
|
Preview plan
|
|
</Button>
|
|
</Group>
|
|
) : null}
|
|
|
|
{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}
|
|
<FleetAvailabilitySummary
|
|
fleetAvailability={previewResult?.fleetAvailability}
|
|
deferredBookings={previewResult?.deferredBookings}
|
|
/>
|
|
{isExportDisplay && displayWagonPlanOriented.length ? (
|
|
<Text size="xs" c="dimmed">
|
|
Shown rear-first (export direction) — positions keep their original numbers.
|
|
</Text>
|
|
) : null}
|
|
<WagonPlanGrid wagonPlan={displayWagonPlanOriented} freightType={freightType} />
|
|
{canEditBookings && (previewResult || displayWagonPlan.length) ? (
|
|
<Group>
|
|
{hasContainerStep ? (
|
|
<Button
|
|
color="edr-green"
|
|
radius="md"
|
|
rightSection={<ContainerIcon size={16} />}
|
|
onClick={() => setActiveStep(2)}
|
|
>
|
|
Continue to containers
|
|
</Button>
|
|
) : null}
|
|
<Button variant="default" radius="md" onClick={() => void runPreview()}>
|
|
Refresh preview
|
|
</Button>
|
|
</Group>
|
|
) : null}
|
|
</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}
|
|
/>
|
|
)}
|
|
{canEditBookings ? (
|
|
<Group>
|
|
<Button
|
|
color="edr-green"
|
|
radius="md"
|
|
loading={assign.isPending}
|
|
onClick={handleAssign}
|
|
>
|
|
{assignedIds.length ? "Save assignments" : "Assign bookings"}
|
|
</Button>
|
|
<Button
|
|
variant="default"
|
|
radius="md"
|
|
onClick={() => setActiveStep(finalizeStep)}
|
|
>
|
|
Skip to dispatch
|
|
</Button>
|
|
</Group>
|
|
) : null}
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
// finalize — the train is known here, so draw the full composition the
|
|
// same way the batch board's composition tab does (interactive consist).
|
|
return (
|
|
<Stack gap="md">
|
|
{schedule.trainSet ? (
|
|
<TrainConsistView
|
|
scheduleDetail={schedule}
|
|
scheduleId={scheduleId ?? ""}
|
|
maxWagons={schedule.maxWagons ?? 53}
|
|
/>
|
|
) : (
|
|
<TrainCompositionDiagram
|
|
locomotive={null}
|
|
locomotives={locomotives}
|
|
wagons={[]}
|
|
freightType={freightType}
|
|
trainNumber={schedule.trainNumber ?? schedule.train?.code ?? null}
|
|
totalLengthMeters={null}
|
|
/>
|
|
)}
|
|
<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="#F2A516">
|
|
<CheckCircle2 size={22} />
|
|
</ThemeIcon>
|
|
<Stack gap={2}>
|
|
<Text fw={600}>Ready to depart</Text>
|
|
<Text size="sm" c="dimmed">
|
|
Dispatch begins rail movement and notifies the yard.
|
|
</Text>
|
|
</Stack>
|
|
</Group>
|
|
</Paper>
|
|
<Group>
|
|
{canDispatch ? (
|
|
<Button
|
|
color="edr-green"
|
|
size="md"
|
|
radius="md"
|
|
leftSection={<Send size={18} />}
|
|
loading={dispatch.isPending}
|
|
onClick={() => setDispatchConfirmOpen(true)}
|
|
>
|
|
Dispatch train
|
|
</Button>
|
|
) : null}
|
|
{!canDispatch ? (
|
|
<Text size="sm" c="dimmed">
|
|
No actions available for this schedule status.
|
|
</Text>
|
|
) : null}
|
|
</Group>
|
|
</Stack>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<PageContainer>
|
|
<Button
|
|
component={Link}
|
|
to="/dashboard/operations/train-scheduling-v2"
|
|
variant="subtle"
|
|
color="gray"
|
|
size="compact-sm"
|
|
leftSection={<ArrowLeft size={16} />}
|
|
w="fit-content"
|
|
>
|
|
Back to schedules
|
|
</Button>
|
|
|
|
<Paper
|
|
radius="xl"
|
|
p="xl"
|
|
style={{ position: "relative", overflow: "hidden" }}
|
|
>
|
|
<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="light" color="#F2A516">
|
|
<Train size={28} />
|
|
</ThemeIcon>
|
|
<Stack gap={6}>
|
|
<Group gap="sm" align="center" wrap="wrap">
|
|
{schedule.reference ? (
|
|
<Badge
|
|
variant="filled"
|
|
color="edr-green"
|
|
radius="sm"
|
|
style={{ fontWeight: 700, fontFamily: "monospace" }}
|
|
>
|
|
{schedule.reference}
|
|
</Badge>
|
|
) : null}
|
|
<Title order={2} fw={700} style={{ color: "#0f172a" }}>
|
|
{schedule.route?.name ?? "Train schedule"}
|
|
</Title>
|
|
{schedule.trainNumber ? (
|
|
<Badge variant="light" color="#F2A516" radius="sm" style={{ fontWeight: 600 }}>
|
|
{schedule.trainNumber}
|
|
</Badge>
|
|
) : null}
|
|
{schedule.train ? (
|
|
<Text size="xs" c="dimmed" ff="monospace">
|
|
Train {schedule.train.code}
|
|
</Text>
|
|
) : null}
|
|
</Group>
|
|
{(schedule.stops?.length ?? 0) >= 3 ||
|
|
(schedule.bookings ?? []).some(
|
|
(b) => b.tradeDirection === "DOMESTIC",
|
|
) ? (
|
|
<SegmentOccupancyStrip
|
|
stops={schedule.stops ?? []}
|
|
bookings={schedule.bookings ?? []}
|
|
maxWagons={schedule.maxWagons}
|
|
maxGrossTons={schedule.maxGrossWeightTons}
|
|
/>
|
|
) : (
|
|
<Box maw={340}>
|
|
<RouteCorridor
|
|
origin={
|
|
schedule.originStation?.label ?? schedule.originStation?.code
|
|
}
|
|
destination={
|
|
schedule.destinationStation?.label ??
|
|
schedule.destinationStation?.code
|
|
}
|
|
/>
|
|
</Box>
|
|
)}
|
|
<Group gap="sm" align="center">
|
|
<FreightTypeBadge freightType={schedule.freightType} />
|
|
<StatusPill status={schedule.status} />
|
|
</Group>
|
|
</Stack>
|
|
</Group>
|
|
<Group gap="sm">
|
|
{(schedule.trainSet?.wagons?.length ?? 0) > 0 ? (
|
|
<Button
|
|
variant="gradient"
|
|
gradient={{ from: "#0f172a", to: "#334155" }}
|
|
radius="lg"
|
|
size="sm"
|
|
leftSection={<Eye size={16} />}
|
|
onClick={() => setVisualization3DOpen(true)}
|
|
>
|
|
3D Visualization
|
|
</Button>
|
|
) : null}
|
|
{canPrintMarshalling ? (
|
|
<Button
|
|
variant="light"
|
|
color="edr-green"
|
|
radius="lg"
|
|
size="sm"
|
|
leftSection={<FileText size={16} />}
|
|
loading={downloadMarshalling.isPending}
|
|
onClick={() => void openMarshallingDocument()}
|
|
>
|
|
Marshalling PDF
|
|
</Button>
|
|
) : null}
|
|
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
|
|
<Button
|
|
variant="light"
|
|
color="edr-green"
|
|
radius="lg"
|
|
size="sm"
|
|
leftSection={<FileText size={16} />}
|
|
loading={downloadMarshalling.isPending}
|
|
onClick={() =>
|
|
void openMarshallingDocument({
|
|
title: "Intercity marshalling ready",
|
|
variant: "INTERCITY",
|
|
})
|
|
}
|
|
>
|
|
Intercity Marshalling
|
|
</Button>
|
|
) : null}
|
|
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
|
|
<Button
|
|
component={Link}
|
|
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}/track`}
|
|
color="edr-green"
|
|
radius="lg"
|
|
size="sm"
|
|
leftSection={<Navigation size={16} />}
|
|
>
|
|
Track train
|
|
</Button>
|
|
) : null}
|
|
{schedule.windowPhase === "PRE_WINDOW" ? (
|
|
<Button
|
|
variant="light"
|
|
color="edr-green"
|
|
radius="lg"
|
|
size="sm"
|
|
leftSection={<Clock size={16} />}
|
|
onClick={() => setWindowSettingsOpen(true)}
|
|
>
|
|
Window settings
|
|
</Button>
|
|
) : null}
|
|
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
|
|
<Button
|
|
variant="default"
|
|
radius="lg"
|
|
size="sm"
|
|
onClick={() => setMaintenanceOpen(true)}
|
|
>
|
|
Reschedule train
|
|
</Button>
|
|
) : null}
|
|
{gatepassApplies ? (
|
|
gatepassSecured ? (
|
|
<Button
|
|
variant="light"
|
|
color="edr-green"
|
|
radius="lg"
|
|
size="sm"
|
|
leftSection={<CheckCircle2 size={16} />}
|
|
disabled
|
|
>
|
|
Gate pass secured
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
color="edr-green"
|
|
radius="lg"
|
|
size="sm"
|
|
leftSection={<FileText size={16} />}
|
|
loading={secureGatepass.isPending}
|
|
onClick={() => secureGatepass.mutate()}
|
|
>
|
|
Secure gate pass
|
|
</Button>
|
|
)
|
|
) : null}
|
|
</Group>
|
|
</Group>
|
|
|
|
{previewResult ? (
|
|
<Badge
|
|
size="lg"
|
|
radius="sm"
|
|
variant="light"
|
|
color={previewResult.valid ? "edr-green" : "red"}
|
|
leftSection={
|
|
<Box
|
|
w={8}
|
|
h={8}
|
|
style={{
|
|
borderRadius: 999,
|
|
background: previewResult.valid
|
|
? "var(--mantine-color-edr-green-6)"
|
|
: "var(--mantine-color-red-6)",
|
|
}}
|
|
/>
|
|
}
|
|
>
|
|
Preview {previewResult.valid ? "valid" : "has issues"}
|
|
</Badge>
|
|
) : null}
|
|
</Stack>
|
|
</Paper>
|
|
|
|
<KpiStrip
|
|
items={[
|
|
...(schedule.train
|
|
? [
|
|
{
|
|
label: "Train",
|
|
value: schedule.train.code,
|
|
hint: schedule.train.trainName ?? "Built train (Train Builder)",
|
|
icon: Train,
|
|
},
|
|
]
|
|
: []),
|
|
{
|
|
label: locomotives.length > 1 ? "Locomotives" : "Locomotive",
|
|
value: locomotives.length
|
|
? locomotives.map((l) => l.code).join(" + ")
|
|
: "—",
|
|
hint: locomotives.length
|
|
? `${locomotives.length} locomotive${locomotives.length > 1 ? "s" : ""}`
|
|
: "No locomotives assigned",
|
|
icon: Train,
|
|
},
|
|
...(schedule.trainSet?.totalLengthMeters
|
|
? [
|
|
{
|
|
label: "Train length",
|
|
// Physical consist length — the same figure Train Builder shows.
|
|
value: `${schedule.trainSet.totalLengthMeters}m`,
|
|
hint: "built consist — matches Train Builder",
|
|
icon: Ruler,
|
|
},
|
|
]
|
|
: []),
|
|
{
|
|
label: "Bookings",
|
|
value: schedule.bookings?.length ?? 0,
|
|
hint: (() => {
|
|
const intercity = (schedule.bookings ?? []).filter(
|
|
(b) => b.tradeDirection === "DOMESTIC",
|
|
).length;
|
|
return intercity > 0
|
|
? `${intercity} intercity ride-along${intercity === 1 ? "" : "s"}`
|
|
: undefined;
|
|
})(),
|
|
icon: Package,
|
|
},
|
|
{
|
|
label: "Wagons / load",
|
|
// Gross: cargo load + wagon tare — the weight the locomotives
|
|
// actually haul. Heaviest corridor edge when the server sends it;
|
|
// plain consist sums over-report a multi-stop train (cross-leg
|
|
// wagon sharing counts one physical wagon as several slots).
|
|
value: (() => {
|
|
const heaviest = schedule.trainSet?.heaviestLeg;
|
|
const wagonCount =
|
|
heaviest?.loadedWagonCount ??
|
|
schedule.trainSet?.wagonCount ??
|
|
displayWagonPlan.length;
|
|
const grossTons =
|
|
heaviest?.grossWeightTons ??
|
|
Math.round(
|
|
((schedule.trainSet?.totalWeightTons ?? 0) +
|
|
(schedule.trainSet?.wagons ?? []).reduce(
|
|
(sum, w) => sum + (Number(w.tareWeightTons) || 0),
|
|
0,
|
|
)) *
|
|
100,
|
|
) / 100;
|
|
return `${wagonCount} · ${grossTons}T`;
|
|
})(),
|
|
hint: schedule.trainSet?.heaviestLeg
|
|
? "heaviest leg · wagon tare + cargo"
|
|
: "gross · wagon tare + cargo",
|
|
icon: Weight,
|
|
},
|
|
{
|
|
label: "Departure",
|
|
value: new Date(schedule.scheduledDepartureDate).toLocaleDateString("en", {
|
|
month: "short",
|
|
day: "2-digit",
|
|
}),
|
|
hint: new Date(schedule.scheduledDepartureDate).toLocaleTimeString("en", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
}),
|
|
icon: CalendarClock,
|
|
},
|
|
]}
|
|
/>
|
|
|
|
{/* Import loading confirmation — superseded by the per-booking Load/Unload
|
|
toggle in the Workspace tab (works for all directions). Kept commented
|
|
in case the import-only confirmation flow is needed again.
|
|
{schedule?.direction === "IMPORT" ? (
|
|
<Paper radius="xl" p="lg" withBorder>
|
|
<Stack gap="md">
|
|
<Text fw={600}>Import loading confirmation</Text>
|
|
<Text size="sm" c="dimmed">
|
|
Paid import bookings with a wagon allocated on this schedule. Marking loaded/unloaded
|
|
is tracking only — it does not block dispatch.
|
|
</Text>
|
|
<ImportLoadingConfirmationPanel
|
|
scheduleId={scheduleId as string}
|
|
items={importLoadingQuery.data?.items ?? []}
|
|
isLoading={importLoadingQuery.isLoading}
|
|
/>
|
|
</Stack>
|
|
</Paper>
|
|
) : null}
|
|
*/}
|
|
|
|
<Tabs defaultValue="workflow" radius="md" color="edr-green" keepMounted={false}>
|
|
<Tabs.List mb="md">
|
|
<Tabs.Tab value="workflow" leftSection={<WorkflowIcon size={16} />}>
|
|
Workflow
|
|
</Tabs.Tab>
|
|
<Tabs.Tab value="workspace" leftSection={<PackageCheck size={16} />}>
|
|
Workspace
|
|
</Tabs.Tab>
|
|
<Tabs.Tab value="legs" leftSection={<RouteIcon size={16} />}>
|
|
Leg capacity
|
|
</Tabs.Tab>
|
|
<Tabs.Tab value="history" leftSection={<HistoryIcon size={16} />}>
|
|
History
|
|
</Tabs.Tab>
|
|
</Tabs.List>
|
|
|
|
<Tabs.Panel value="workflow">
|
|
<Stack gap="lg">
|
|
<Paper radius="xl" p="lg">
|
|
<Stack gap="lg">
|
|
{/* Workflow header with ring progress */}
|
|
<Group justify="space-between" align="center" wrap="nowrap">
|
|
<Group gap="md" align="center" wrap="nowrap">
|
|
<ThemeIcon size={44} radius="md" variant="light" color="edr-green">
|
|
<RouteIcon size={22} />
|
|
</ThemeIcon>
|
|
<Stack gap={2}>
|
|
<Title order={4} fw={700}>
|
|
Scheduling workflow
|
|
</Title>
|
|
<Text size="sm" c="dimmed">
|
|
{completedCount} of {stepsMeta.length} steps complete · expand any
|
|
step to edit
|
|
</Text>
|
|
</Stack>
|
|
</Group>
|
|
<RingProgress
|
|
size={64}
|
|
thickness={6}
|
|
roundCaps
|
|
sections={[{ value: progressPct, color: "edr-green" }]}
|
|
label={
|
|
<Text ta="center" size="xs" fw={700} c="edr-green.7">
|
|
{progressPct}%
|
|
</Text>
|
|
}
|
|
/>
|
|
</Group>
|
|
|
|
<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>
|
|
|
|
{/* <ScheduleBatchPanel schedule={schedule} /> */}
|
|
</Stack>
|
|
</Tabs.Panel>
|
|
|
|
<Tabs.Panel value="workspace">
|
|
<ScheduleWorkspacePanel
|
|
schedule={schedule}
|
|
onChanged={() => {
|
|
autoPreviewedRef.current = false;
|
|
void detailQuery.refetch();
|
|
}}
|
|
/>
|
|
{scheduleId ? (
|
|
<IntercityRideAlongPanel
|
|
scheduleId={scheduleId}
|
|
direction={schedule.direction}
|
|
/>
|
|
) : null}
|
|
</Tabs.Panel>
|
|
|
|
<Tabs.Panel value="legs">
|
|
<LegCapacityPanel schedule={schedule} />
|
|
</Tabs.Panel>
|
|
|
|
<Tabs.Panel value="history">
|
|
{scheduleId ? <ScheduleHistoryPanel scheduleId={scheduleId} /> : null}
|
|
</Tabs.Panel>
|
|
</Tabs>
|
|
|
|
{scheduleId ? (
|
|
<RescheduleTrainDialog
|
|
scheduleId={scheduleId}
|
|
currentBookingIds={(schedule.bookings ?? []).map((b) => b.id)}
|
|
opened={maintenanceOpen}
|
|
onClose={() => setMaintenanceOpen(false)}
|
|
onComplete={() => void detailQuery.refetch()}
|
|
/>
|
|
) : null}
|
|
|
|
<BookingWindowSettingsModal
|
|
scheduleId={scheduleId ?? null}
|
|
opened={windowSettingsOpen}
|
|
onClose={() => setWindowSettingsOpen(false)}
|
|
onSaved={() => void detailQuery.refetch()}
|
|
/>
|
|
|
|
<SwitchGovernmentBookingModal
|
|
key={switchTarget?.id ?? "none"}
|
|
opened={Boolean(switchTarget)}
|
|
onClose={() => setSwitchTarget(null)}
|
|
govBooking={switchTarget}
|
|
assignedBookings={schedule.bookings ?? []}
|
|
loading={switchGov.isPending}
|
|
onConfirm={async (removeBookingIds) => {
|
|
if (!scheduleId || !switchTarget) return;
|
|
try {
|
|
await switchGov.mutateAsync({
|
|
id: scheduleId,
|
|
governmentBookingId: switchTarget.id,
|
|
removeBookingIds,
|
|
});
|
|
toast({ title: `Government booking ${switchTarget.reference} switched onto the train` });
|
|
setSwitchTarget(null);
|
|
setSelectedBookingIds([]);
|
|
setPreviewResult(null);
|
|
autoPreviewedRef.current = false;
|
|
} catch (err) {
|
|
toast({
|
|
title: "Switch failed",
|
|
description: parseError(err, "Could not switch the government booking"),
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
}}
|
|
/>
|
|
|
|
<Modal
|
|
opened={dispatchConfirmOpen}
|
|
onClose={() => setDispatchConfirmOpen(false)}
|
|
centered
|
|
radius="lg"
|
|
title={
|
|
<Group gap={8}>
|
|
<Send size={18} />
|
|
<Text fw={700}>Dispatch this train?</Text>
|
|
</Group>
|
|
}
|
|
>
|
|
<Stack gap="md">
|
|
<Text size="sm" c="dimmed">
|
|
Dispatch locks the composition and begins rail movement. This cannot be
|
|
undone.
|
|
</Text>
|
|
|
|
{hasDispatchWarnings ? (
|
|
<Alert
|
|
color="orange"
|
|
variant="light"
|
|
radius="md"
|
|
icon={<AlertTriangle size={18} />}
|
|
title="Some bookings are not fully ready"
|
|
>
|
|
<List size="sm" spacing={4}>
|
|
{unassignedCount > 0 ? (
|
|
<List.Item>
|
|
<Text span fw={700}>
|
|
{unassignedCount}
|
|
</Text>{" "}
|
|
booking{unassignedCount === 1 ? "" : "s"} not assigned to a wagon
|
|
</List.Item>
|
|
) : null}
|
|
{unloadedCount > 0 ? (
|
|
<List.Item>
|
|
<Text span fw={700}>
|
|
{unloadedCount}
|
|
</Text>{" "}
|
|
wagon-assigned booking{unloadedCount === 1 ? "" : "s"} not loaded
|
|
yet — mid-route boarders load from the track page when the train
|
|
reaches their yard
|
|
</List.Item>
|
|
) : null}
|
|
{intercityNotLoadedCount > 0 ? (
|
|
<List.Item>
|
|
<Text span fw={700}>
|
|
{intercityNotLoadedCount}
|
|
</Text>{" "}
|
|
intercity ride-along{intercityNotLoadedCount === 1 ? "" : "s"} not
|
|
loaded yet — load them from the Workspace tab (Yard work) before
|
|
the train leaves their origin yard
|
|
</List.Item>
|
|
) : null}
|
|
</List>
|
|
<Text size="xs" c="dimmed" mt={6}>
|
|
You can still dispatch — confirm to proceed.
|
|
</Text>
|
|
</Alert>
|
|
) : (
|
|
<Alert
|
|
color="edr-green"
|
|
variant="light"
|
|
radius="md"
|
|
icon={<CheckCircle2 size={18} />}
|
|
>
|
|
All bookings are assigned to a wagon and marked loaded.
|
|
</Alert>
|
|
)}
|
|
|
|
<Group justify="flex-end" gap="sm">
|
|
<Button
|
|
variant="default"
|
|
onClick={() => setDispatchConfirmOpen(false)}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
color="edr-green"
|
|
leftSection={<Send size={16} />}
|
|
loading={dispatch.isPending}
|
|
onClick={() => void runDispatch()}
|
|
>
|
|
{hasDispatchWarnings ? "Dispatch anyway" : "Dispatch train"}
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
{visualization3DOpen ? (
|
|
<Train3DVisualization schedule={schedule} onClose={() => setVisualization3DOpen(false)} />
|
|
) : null}
|
|
</PageContainer>
|
|
);
|
|
}
|