mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 14:08:11 +00:00
1558 lines
55 KiB
TypeScript
1558 lines
55 KiB
TypeScript
import {
|
|
ActionIcon,
|
|
Alert,
|
|
Badge,
|
|
Box,
|
|
Button,
|
|
Checkbox,
|
|
Group,
|
|
List,
|
|
Loader,
|
|
Menu,
|
|
Modal,
|
|
Paper,
|
|
RingProgress,
|
|
Stack,
|
|
Tabs,
|
|
Text,
|
|
ThemeIcon,
|
|
Title,
|
|
} from "@mantine/core";
|
|
import { isAxiosError } from "axios";
|
|
import {
|
|
AlertTriangle,
|
|
CalendarClock,
|
|
CheckCircle2,
|
|
Clock,
|
|
Merge,
|
|
Container as ContainerIcon,
|
|
Eye,
|
|
FileText,
|
|
History as HistoryIcon,
|
|
LayoutGrid,
|
|
MoreHorizontal,
|
|
Navigation,
|
|
Package,
|
|
PackageCheck,
|
|
Grid3x3,
|
|
Route as RouteIcon,
|
|
Ruler,
|
|
Send,
|
|
Train,
|
|
Weight,
|
|
Workflow as WorkflowIcon,
|
|
} from "lucide-react";
|
|
import { DateTimePicker } from "@mantine/dates";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { Link, useParams } from "react-router-dom";
|
|
|
|
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
|
import { useAuth } from "@/auth/useAuth";
|
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
|
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 { LegLoadBoardPanel } from "@/components/trainScheduling/LegLoadBoardPanel";
|
|
import { LoadEmptyContainersModal } from "@/components/trainScheduling/LoadEmptyContainersModal";
|
|
import MergeScheduleTrainModal from "@/components/trainScheduling/MergeScheduleTrainModal";
|
|
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 { user: authUser } = useAuth();
|
|
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 [mergeModalOpen, setMergeModalOpen] = useState(false);
|
|
const [gatepassSecuredAt, setGatepassSecuredAt] = useState("");
|
|
const [gatepassReference, setGatepassReference] = useState("");
|
|
const [gatepassFileUrl, setGatepassFileUrl] = useState("");
|
|
const [gatepassNotes, setGatepassNotes] = useState("");
|
|
const [dispatchConfirmOpen, setDispatchConfirmOpen] = useState(false);
|
|
// Actual departure — staff often dispatch on paper first and record it later,
|
|
// so the time is picked (defaults to now when the dialog opens).
|
|
const [dispatchAt, setDispatchAt] = useState<Date | null>(null);
|
|
const openDispatchConfirm = () => {
|
|
setDispatchAt(new Date());
|
|
setDispatchConfirmOpen(true);
|
|
};
|
|
const [switchTarget, setSwitchTarget] = useState<EligibleContainerBooking | null>(null);
|
|
const [visualization3DOpen, setVisualization3DOpen] = useState(false);
|
|
const [loadEmptiesOpen, setLoadEmptiesOpen] = 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" &&
|
|
hasPermission(authUser, FREIGHT_PERMS.trainScheduling.dispatch);
|
|
// 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({
|
|
id: scheduleId,
|
|
payload: dispatchAt ? { actualDepartureAt: dispatchAt.toISOString() } : {},
|
|
});
|
|
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),
|
|
customer: b.customer,
|
|
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={openDispatchConfirm}
|
|
>
|
|
Dispatch train
|
|
</Button>
|
|
) : null}
|
|
{!canDispatch ? (
|
|
<Text size="sm" c="dimmed">
|
|
No actions available for this schedule status.
|
|
</Text>
|
|
) : null}
|
|
</Group>
|
|
</Stack>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<PageContainer>
|
|
<PageHeader
|
|
title={schedule.route?.name ?? "Train schedule"}
|
|
backTo="/dashboard/operations/train-scheduling-v2"
|
|
breadcrumbs={[
|
|
{
|
|
label: "Train schedules",
|
|
href: "/dashboard/operations/train-scheduling-v2",
|
|
},
|
|
{ label: schedule.reference ?? "Schedule" },
|
|
]}
|
|
subtitle={
|
|
schedule.train ? (
|
|
<Text size="sm" c="dimmed">
|
|
{schedule.train.trainName ?? `Train ${schedule.train.code}`}
|
|
{schedule.train.trainName ? ` · Train ${schedule.train.code}` : ""}
|
|
</Text>
|
|
) : undefined
|
|
}
|
|
meta={
|
|
<Group gap={6} wrap="wrap">
|
|
{schedule.reference ? (
|
|
<Badge
|
|
variant="filled"
|
|
color="edr-green"
|
|
radius="sm"
|
|
style={{ fontWeight: 700, fontFamily: "monospace" }}
|
|
>
|
|
{schedule.reference}
|
|
</Badge>
|
|
) : null}
|
|
<FreightTypeBadge freightType={schedule.freightType} />
|
|
<StatusPill status={schedule.status} />
|
|
{gatepassApplies && gatepassSecured ? (
|
|
<Badge
|
|
variant="light"
|
|
color="edr-green"
|
|
radius="sm"
|
|
leftSection={<CheckCircle2 size={12} />}
|
|
>
|
|
Gate pass secured
|
|
</Badge>
|
|
) : null}
|
|
{previewResult ? (
|
|
<Badge
|
|
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}
|
|
</Group>
|
|
}
|
|
action={
|
|
<Group gap="sm" wrap="nowrap">
|
|
{/* Merging rewrites the consist, so it is offered only while
|
|
the departure can still be edited. */}
|
|
{canEditBookings ? (
|
|
<Button
|
|
variant="light"
|
|
size="compact-sm"
|
|
leftSection={<Merge size={14} />}
|
|
onClick={() => setMergeModalOpen(true)}
|
|
>
|
|
Merge
|
|
</Button>
|
|
) : null}
|
|
{/* Empties ride an export departure back to Djibouti — offered only
|
|
while the train can still take load. */}
|
|
{schedule.direction === "EXPORT" &&
|
|
["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
|
|
<Button
|
|
variant="light"
|
|
color="edr-green"
|
|
size="compact-sm"
|
|
leftSection={<ContainerIcon size={14} />}
|
|
onClick={() => setLoadEmptiesOpen(true)}
|
|
>
|
|
Load Empty Container
|
|
</Button>
|
|
) : null}
|
|
{(schedule.trainSet?.wagons?.length ?? 0) > 0 ? (
|
|
<Button
|
|
variant="gradient"
|
|
gradient={{ from: "#0f172a", to: "#334155" }}
|
|
radius="lg"
|
|
size="compact-sm"
|
|
leftSection={<Eye size={16} />}
|
|
onClick={() => setVisualization3DOpen(true)}
|
|
>
|
|
3D Visualization
|
|
</Button>
|
|
) : null}
|
|
<Menu position="bottom-end" width={240} withinPortal>
|
|
<Menu.Target>
|
|
<ActionIcon variant="default" size="lg" radius="md" aria-label="More actions">
|
|
<MoreHorizontal size={16} />
|
|
</ActionIcon>
|
|
</Menu.Target>
|
|
<Menu.Dropdown>
|
|
{canPrintMarshalling ? (
|
|
<Menu.Item
|
|
leftSection={<FileText size={15} />}
|
|
disabled={downloadMarshalling.isPending}
|
|
onClick={() => void openMarshallingDocument()}
|
|
>
|
|
Marshalling PDF
|
|
</Menu.Item>
|
|
) : null}
|
|
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
|
|
<Menu.Item
|
|
leftSection={<FileText size={15} />}
|
|
disabled={downloadMarshalling.isPending}
|
|
onClick={() =>
|
|
void openMarshallingDocument({
|
|
title: "Intercity marshalling ready",
|
|
variant: "INTERCITY",
|
|
})
|
|
}
|
|
>
|
|
Intercity Marshalling
|
|
</Menu.Item>
|
|
) : null}
|
|
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
|
|
<Menu.Item
|
|
component={Link}
|
|
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}/track`}
|
|
leftSection={<Navigation size={15} />}
|
|
>
|
|
Track train
|
|
</Menu.Item>
|
|
) : null}
|
|
{schedule.windowPhase === "PRE_WINDOW" ? (
|
|
<Menu.Item
|
|
leftSection={<Clock size={15} />}
|
|
onClick={() => setWindowSettingsOpen(true)}
|
|
>
|
|
Window settings
|
|
</Menu.Item>
|
|
) : null}
|
|
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
|
|
<Menu.Item onClick={() => setMaintenanceOpen(true)}>
|
|
Reschedule train
|
|
</Menu.Item>
|
|
) : null}
|
|
{gatepassApplies && !gatepassSecured ? (
|
|
<Menu.Item
|
|
leftSection={<FileText size={15} />}
|
|
disabled={secureGatepass.isPending}
|
|
onClick={() => secureGatepass.mutate()}
|
|
>
|
|
Secure gate pass
|
|
</Menu.Item>
|
|
) : null}
|
|
</Menu.Dropdown>
|
|
</Menu>
|
|
</Group>
|
|
}
|
|
/>
|
|
|
|
{/* Ops signage: Train No. / Voyage No. / Direction read at a glance from
|
|
across the room, so these stay large rather than folding into the
|
|
numeric KpiStrip below. */}
|
|
<Paper radius="xl" p="lg">
|
|
<Stack gap="md">
|
|
<Group gap="lg" align="center" wrap="wrap">
|
|
{schedule.trainNumber ? (
|
|
<Box>
|
|
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
|
|
Train No.
|
|
</Text>
|
|
<Text
|
|
ff="monospace"
|
|
fw={800}
|
|
lh={1.1}
|
|
style={{ fontSize: 32, color: "#0f172a" }}
|
|
>
|
|
{schedule.trainNumber}
|
|
</Text>
|
|
</Box>
|
|
) : null}
|
|
{schedule.voyageNumber ? (
|
|
<Box>
|
|
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
|
|
Voyage No.
|
|
</Text>
|
|
<Text
|
|
ff="monospace"
|
|
fw={800}
|
|
lh={1.1}
|
|
style={{ fontSize: 32, color: "#0f172a" }}
|
|
>
|
|
{schedule.voyageNumber}
|
|
</Text>
|
|
</Box>
|
|
) : null}
|
|
{schedule.direction ? (
|
|
<Box>
|
|
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
|
|
Direction
|
|
</Text>
|
|
<Text
|
|
fw={800}
|
|
lh={1.1}
|
|
tt="uppercase"
|
|
style={{
|
|
fontSize: 32,
|
|
letterSpacing: 0.5,
|
|
color:
|
|
schedule.direction === "IMPORT"
|
|
? "#2E5B96"
|
|
: schedule.direction === "EXPORT"
|
|
? "#0A6F4D"
|
|
: "#0f172a",
|
|
}}
|
|
>
|
|
{schedule.direction}
|
|
</Text>
|
|
</Box>
|
|
) : 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>
|
|
)}
|
|
</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="leg-board" leftSection={<Grid3x3 size={16} />}>
|
|
Leg board
|
|
</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="leg-board">
|
|
<LegLoadBoardPanel
|
|
schedule={schedule}
|
|
onChanged={() => void detailQuery.refetch()}
|
|
/>
|
|
</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()}
|
|
/>
|
|
|
|
<LoadEmptyContainersModal
|
|
opened={loadEmptiesOpen}
|
|
onClose={() => setLoadEmptiesOpen(false)}
|
|
schedule={schedule}
|
|
/>
|
|
|
|
<MergeScheduleTrainModal
|
|
scheduleId={scheduleId ?? null}
|
|
currentTrainId={schedule.trainSet?.trainId ?? null}
|
|
scheduleReference={schedule.reference ?? null}
|
|
opened={mergeModalOpen}
|
|
onClose={() => setMergeModalOpen(false)}
|
|
onMerged={() => 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>
|
|
|
|
<DateTimePicker
|
|
label="Actual departure"
|
|
description="When the train left — defaults to now; a past time is fine."
|
|
value={dispatchAt}
|
|
onChange={(v) => setDispatchAt(v ? new Date(v) : null)}
|
|
maxDate={new Date()}
|
|
valueFormat="DD MMM YYYY HH:mm"
|
|
clearable={false}
|
|
radius="md"
|
|
/>
|
|
|
|
{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>
|
|
);
|
|
}
|