mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
1156 lines
39 KiB
TypeScript
1156 lines
39 KiB
TypeScript
import {
|
|
Badge,
|
|
Box,
|
|
Button,
|
|
Checkbox,
|
|
Group,
|
|
Loader,
|
|
Paper,
|
|
RingProgress,
|
|
Stack,
|
|
Tabs,
|
|
Text,
|
|
Textarea,
|
|
TextInput,
|
|
ThemeIcon,
|
|
Title,
|
|
} from "@mantine/core";
|
|
import { isAxiosError } from "axios";
|
|
import {
|
|
ArrowLeft,
|
|
CalendarClock,
|
|
CheckCircle2,
|
|
Container as ContainerIcon,
|
|
Eye,
|
|
FileText,
|
|
LayoutGrid,
|
|
Navigation,
|
|
Package,
|
|
PackageCheck,
|
|
Route as RouteIcon,
|
|
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 { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
|
|
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
|
import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
|
|
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
|
|
import { ScheduleWorkspacePanel } from "@/components/trainScheduling/ScheduleWorkspacePanel";
|
|
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
|
import {
|
|
RouteCorridor,
|
|
StatusPill,
|
|
scheduleBrand,
|
|
} from "@/components/trainScheduling/scheduleVisuals";
|
|
import {
|
|
PreviewSummary,
|
|
ScheduleWarningsAlert,
|
|
} from "@/components/trainScheduling/ScheduleWarningsAlert";
|
|
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
|
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 { api } from "@/services/api";
|
|
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import type {
|
|
ContainerPlacement,
|
|
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 [gatepassSecuredAt, setGatepassSecuredAt] = useState("");
|
|
const [gatepassReference, setGatepassReference] = useState("");
|
|
const [gatepassFileUrl, setGatepassFileUrl] = useState("");
|
|
const [gatepassNotes, setGatepassNotes] = useState("");
|
|
const autoPreviewedRef = useRef(false);
|
|
|
|
const detailQuery = useQuery(
|
|
api.trainScheduling.scheduleDetail.queryOptions({
|
|
input: { id: scheduleId ?? "" },
|
|
enabled: 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 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",
|
|
});
|
|
},
|
|
});
|
|
|
|
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 finalize = useMutation(api.trainScheduling.finalizeSchedule.mutationOptions());
|
|
const dispatch = useMutation(api.trainScheduling.dispatchSchedule.mutationOptions());
|
|
const downloadMarshalling = useMutation({
|
|
mutationFn: ({ id, direction }: { id: string; direction?: string | null }) =>
|
|
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]);
|
|
|
|
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 canFinalize = schedule.status === "DRAFT" && (schedule.bookings?.length ?? 0) > 0;
|
|
const canDispatch = schedule.status === "SCHEDULED";
|
|
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;
|
|
}) => {
|
|
const pdfWindow = window.open("", "_blank");
|
|
try {
|
|
const blob = await downloadMarshalling.mutateAsync({
|
|
id: scheduleId,
|
|
direction: schedule.direction,
|
|
});
|
|
const prefix = 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 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) => {
|
|
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: "Finalize",
|
|
subtitle: "Lock the plan & 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 (
|
|
<Badge variant="light" color="edr-green" radius="sm">
|
|
{displayWagonPlan.length} wagons
|
|
</Badge>
|
|
);
|
|
}
|
|
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,
|
|
}))}
|
|
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}
|
|
/>
|
|
|
|
{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}
|
|
/>
|
|
<WagonPlanGrid wagonPlan={displayWagonPlan} freightType={freightType} />
|
|
{canEditBookings && (previewResult || displayWagonPlan.length) ? (
|
|
<Group>
|
|
{!hasContainerStep ? (
|
|
<Button
|
|
color="edr-green"
|
|
radius="md"
|
|
loading={assign.isPending}
|
|
onClick={handleAssign}
|
|
>
|
|
{assignedIds.length ? "Save assignments" : "Assign bookings"}
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
color="edr-green"
|
|
radius="md"
|
|
rightSection={<ContainerIcon size={16} />}
|
|
onClick={() => setActiveStep(2)}
|
|
>
|
|
Continue to containers
|
|
</Button>
|
|
)}
|
|
<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 finalize
|
|
</Button>
|
|
</Group>
|
|
) : null}
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
// finalize
|
|
return (
|
|
<Stack gap="md">
|
|
<TrainCompositionDiagram
|
|
locomotive={schedule.trainSet?.locomotive}
|
|
wagons={
|
|
schedule.trainSet?.wagons?.length
|
|
? schedule.trainSet.wagons
|
|
: displayWagonPlan
|
|
}
|
|
freightType={freightType}
|
|
trainNumber={schedule.trainNumber}
|
|
totalLengthMeters={schedule.trainSet?.totalLengthMeters}
|
|
/>
|
|
<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">
|
|
Finalizing locks the plan and moves the schedule to{" "}
|
|
<Text span fw={600} c="edr-green.7">
|
|
SCHEDULED
|
|
</Text>
|
|
. Dispatch then begins rail movement and notifies the yard.
|
|
</Text>
|
|
</Stack>
|
|
</Group>
|
|
</Paper>
|
|
<Group>
|
|
{canFinalize ? (
|
|
<Button
|
|
color="edr-green"
|
|
size="md"
|
|
radius="md"
|
|
leftSection={<CheckCircle2 size={18} />}
|
|
loading={finalize.isPending}
|
|
onClick={async () => {
|
|
try {
|
|
await finalize.mutateAsync(scheduleId);
|
|
toast({ title: "Schedule finalized" });
|
|
} catch (err) {
|
|
toast({
|
|
title: "Finalize failed",
|
|
description: parseError(err, "Could not finalize"),
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
}}
|
|
>
|
|
Finalize schedule
|
|
</Button>
|
|
) : null}
|
|
{canDispatch ? (
|
|
<Button
|
|
color="edr-green"
|
|
size="md"
|
|
radius="md"
|
|
leftSection={<Send size={18} />}
|
|
loading={dispatch.isPending}
|
|
onClick={async () => {
|
|
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",
|
|
});
|
|
}
|
|
}}
|
|
>
|
|
Dispatch train
|
|
</Button>
|
|
) : null}
|
|
{!canFinalize && !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">
|
|
<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}
|
|
</Group>
|
|
<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">
|
|
{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
|
|
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}
|
|
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
|
|
<Button
|
|
variant="default"
|
|
radius="lg"
|
|
size="sm"
|
|
onClick={() => setMaintenanceOpen(true)}
|
|
>
|
|
Reschedule train
|
|
</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={[
|
|
{
|
|
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,
|
|
},
|
|
{
|
|
label: "Bookings",
|
|
value: schedule.bookings?.length ?? 0,
|
|
icon: Package,
|
|
},
|
|
{
|
|
label: "Wagons / load",
|
|
value: `${schedule.trainSet?.wagonCount ?? displayWagonPlan.length} · ${
|
|
schedule.trainSet?.totalWeightTons ?? 0
|
|
}T`,
|
|
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,
|
|
},
|
|
]}
|
|
/>
|
|
|
|
{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}
|
|
|
|
{gatepassApplies ? (
|
|
<Paper radius="xl" p="lg" withBorder>
|
|
<Stack gap="md">
|
|
<Group justify="space-between" align="flex-start" wrap="wrap">
|
|
<Group gap="md" align="flex-start" wrap="nowrap">
|
|
<ThemeIcon
|
|
size={44}
|
|
radius="md"
|
|
variant="light"
|
|
color={gatepassQuery.data?.gatepassStatus === "SECURED" ? "edr-green" : "orange"}
|
|
>
|
|
<FileText size={22} />
|
|
</ThemeIcon>
|
|
<Stack gap={4}>
|
|
<Group gap="sm">
|
|
<Title order={4} fw={700}>
|
|
Djibouti Port gate pass
|
|
</Title>
|
|
<Badge
|
|
color={gatepassQuery.data?.gatepassStatus === "SECURED" ? "green" : "orange"}
|
|
variant="light"
|
|
>
|
|
{gatepassQuery.data?.gatepassStatus ?? "NOT_SECURED"}
|
|
</Badge>
|
|
</Group>
|
|
<Text size="sm" c="dimmed">
|
|
{schedule.direction === "IMPORT"
|
|
? "Secure before dispatch from Djibouti."
|
|
: "Secure after dispatch before Djibouti Port entry / unloading."}
|
|
</Text>
|
|
</Stack>
|
|
</Group>
|
|
{gatepassQuery.isLoading ? <Loader size="sm" /> : null}
|
|
</Group>
|
|
|
|
<Group align="flex-end" grow>
|
|
<TextInput
|
|
label="Secured date"
|
|
type="datetime-local"
|
|
value={gatepassSecuredAt}
|
|
onChange={(event) => setGatepassSecuredAt(event.currentTarget.value)}
|
|
/>
|
|
<TextInput
|
|
label="Document reference"
|
|
placeholder="Optional"
|
|
value={gatepassReference}
|
|
onChange={(event) => setGatepassReference(event.currentTarget.value)}
|
|
/>
|
|
<TextInput
|
|
label="Document URL"
|
|
placeholder="Optional upload/link"
|
|
value={gatepassFileUrl}
|
|
onChange={(event) => setGatepassFileUrl(event.currentTarget.value)}
|
|
/>
|
|
</Group>
|
|
<Textarea
|
|
label="Notes"
|
|
placeholder="Optional"
|
|
autosize
|
|
minRows={2}
|
|
value={gatepassNotes}
|
|
onChange={(event) => setGatepassNotes(event.currentTarget.value)}
|
|
/>
|
|
<Group justify="flex-end">
|
|
<Button
|
|
color="edr-green"
|
|
leftSection={<CheckCircle2 size={16} />}
|
|
loading={secureGatepass.isPending}
|
|
onClick={() => secureGatepass.mutate()}
|
|
>
|
|
Save as Secured
|
|
</Button>
|
|
</Group>
|
|
</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.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();
|
|
}}
|
|
/>
|
|
</Tabs.Panel>
|
|
</Tabs>
|
|
|
|
{scheduleId ? (
|
|
<RescheduleTrainDialog
|
|
scheduleId={scheduleId}
|
|
currentBookingIds={(schedule.bookings ?? []).map((b) => b.id)}
|
|
opened={maintenanceOpen}
|
|
onClose={() => setMaintenanceOpen(false)}
|
|
onComplete={() => void detailQuery.refetch()}
|
|
/>
|
|
) : null}
|
|
</PageContainer>
|
|
);
|
|
}
|