Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx
Marshal dc38e843a6 feat: full wagon cancel, leg board, wagon dates
feat(freight): editable train leg times, SL invoice payer
2026-08-15 10:12:37 +00:00

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