mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
1000 lines
33 KiB
TypeScript
1000 lines
33 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { isAxiosError } from "axios";
|
|
import {
|
|
Badge,
|
|
Box,
|
|
Button,
|
|
Checkbox,
|
|
Group,
|
|
Modal,
|
|
Paper,
|
|
Radio,
|
|
RingProgress,
|
|
Select,
|
|
SimpleGrid,
|
|
Stack,
|
|
Text,
|
|
ThemeIcon,
|
|
Title,
|
|
} from "@mantine/core";
|
|
import {
|
|
CheckCircle2,
|
|
Container as ContainerIcon,
|
|
Eye,
|
|
Flame,
|
|
LayoutGrid,
|
|
Package,
|
|
Route as RouteIcon,
|
|
Train,
|
|
Wallet,
|
|
Weight,
|
|
} from "lucide-react";
|
|
|
|
import {
|
|
useAvailableLocomotives,
|
|
useEligibleBookings,
|
|
useScheduleList,
|
|
useScheduleMutations,
|
|
} from "@/hooks/trainScheduling/useTrainScheduling";
|
|
import { useRoutes } from "@/hooks/useRoutes";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
|
import type { BookingDetail } from "@/types/booking";
|
|
import type {
|
|
ContainerPlacement,
|
|
FreightType,
|
|
ReschedulePlan,
|
|
TrainScheduleDetail,
|
|
TrainScheduleListItem,
|
|
TrainSchedulePreviewResponse,
|
|
} from "@/types/trainScheduling";
|
|
|
|
import { ContainerPlacementGrid } from "./ContainerPlacementGrid";
|
|
import {
|
|
autoFillPlacements,
|
|
mergePlacementsWithSaved,
|
|
placementsFromScheduleWagons,
|
|
validateLocalPlacements,
|
|
} from "./containerPlacement.util";
|
|
import { shouldShowContainerPlacementStep } from "./schedulingContainerStep.util";
|
|
import { FleetAvailabilitySummary } from "./FleetAvailabilitySummary";
|
|
import { ScheduleBookingsStep } from "./ScheduleBookingsStep";
|
|
import { PreviewSummary, ScheduleWarningsAlert } from "./ScheduleWarningsAlert";
|
|
import { FreightTypeBadge, SchedulingStatusBadge } from "./ScheduleStatusBadge";
|
|
import {
|
|
RouteCorridor,
|
|
StatTile,
|
|
StatusPill,
|
|
scheduleBrand,
|
|
} from "./scheduleVisuals";
|
|
import { TrainCompositionDiagram } from "./TrainCompositionDiagram";
|
|
import { WagonPlanGrid } from "./WagonPlanGrid";
|
|
import { WorkflowRail, WorkflowStep } from "./WorkflowStep";
|
|
|
|
const parseError = (error: unknown, fallback: string) => {
|
|
if (isAxiosError(error)) {
|
|
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;
|
|
};
|
|
|
|
const formatCountdown = (expiresAt?: string | null) => {
|
|
if (!expiresAt) return null;
|
|
const diff = new Date(expiresAt).getTime() - Date.now();
|
|
if (diff <= 0) return "Hold expired";
|
|
const hours = Math.floor(diff / 3600000);
|
|
const mins = Math.floor((diff % 3600000) / 60000);
|
|
return `${hours}h ${mins}m remaining`;
|
|
};
|
|
|
|
export function AllocateBookingWizard({
|
|
booking,
|
|
opened,
|
|
onClose,
|
|
initialBookingIds,
|
|
}: {
|
|
booking: BookingDetail;
|
|
opened: boolean;
|
|
onClose: () => void;
|
|
initialBookingIds?: string[];
|
|
}) {
|
|
const navigate = useNavigate();
|
|
const { toast } = useToast();
|
|
const bookingFreightType = booking.freightType as FreightType;
|
|
const [activeStep, setActiveStep] = useState(0);
|
|
const [scheduleMode, setScheduleMode] = useState<"existing" | "new">("existing");
|
|
const [selectedScheduleId, setSelectedScheduleId] = useState<string | null>(null);
|
|
const [routeId, setRouteId] = useState("");
|
|
const scheduleDate = booking.scheduledDate;
|
|
const [locomotiveId, setLocomotiveId] = useState("");
|
|
const [extraBookingIds, setExtraBookingIds] = useState<string[]>([]);
|
|
const [forceAssign, setForceAssign] = useState(false);
|
|
const [previewResult, setPreviewResult] = useState<TrainSchedulePreviewResponse | null>(null);
|
|
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
|
|
const [assignedSchedule, setAssignedSchedule] = useState<TrainScheduleDetail | null>(null);
|
|
const [reschedulePlan, setReschedulePlan] = useState<ReschedulePlan | null>(null);
|
|
const [confirmPreempt, setConfirmPreempt] = useState(false);
|
|
const [allocationComplete, setAllocationComplete] = useState(false);
|
|
|
|
const originId = booking.originYard?.id;
|
|
const destinationId = booking.destinationYard?.id;
|
|
|
|
const eligibleFilters = useMemo(
|
|
() => ({
|
|
originStationId: originId,
|
|
destinationStationId: destinationId,
|
|
}),
|
|
[originId, destinationId],
|
|
);
|
|
|
|
const eligibleQuery = useEligibleBookings(eligibleFilters, opened);
|
|
const schedulesQuery = useScheduleList();
|
|
const routesQuery = useRoutes();
|
|
const locomotivesQuery = useAvailableLocomotives(
|
|
scheduleMode === "new" && routeId ? routeId : undefined,
|
|
);
|
|
const { create, preview, assign, finalize } = useScheduleMutations(selectedScheduleId ?? undefined);
|
|
|
|
useEffect(() => {
|
|
if (scheduleMode === "new") {
|
|
setLocomotiveId("");
|
|
}
|
|
}, [routeId, scheduleMode]);
|
|
|
|
const matchingSchedules = useMemo(
|
|
() =>
|
|
(schedulesQuery.data ?? []).filter(
|
|
(s: TrainScheduleListItem) =>
|
|
s.status === "DRAFT" &&
|
|
(!s.freightType || s.freightType === "MIXED" || s.freightType === bookingFreightType),
|
|
),
|
|
[schedulesQuery.data, bookingFreightType],
|
|
);
|
|
|
|
const allBookingIds = useMemo(
|
|
() => [booking.id, ...extraBookingIds.filter((id) => id !== booking.id)],
|
|
[booking.id, extraBookingIds],
|
|
);
|
|
|
|
const containerUnits = previewResult?.containerUnits ?? [];
|
|
const containerSlots = previewResult?.containerSlotSequenceNos ?? [];
|
|
const hasContainerStep = useMemo(
|
|
() =>
|
|
shouldShowContainerPlacementStep({
|
|
containerUnitCount: containerUnits.length,
|
|
scheduleFreightType: booking.freightType,
|
|
bookingFreightTypes: [
|
|
booking.freightType,
|
|
...(eligibleQuery.data?.items ?? [])
|
|
.filter((item) => allBookingIds.includes(item.id))
|
|
.map((item) => item.freightType),
|
|
],
|
|
}),
|
|
[
|
|
allBookingIds,
|
|
booking.freightType,
|
|
containerUnits.length,
|
|
eligibleQuery.data?.items,
|
|
],
|
|
);
|
|
const previewFreightType = previewResult?.summary?.freightMode as FreightType | undefined;
|
|
const finalizeStep = hasContainerStep ? 3 : 2;
|
|
|
|
useEffect(() => {
|
|
if (!opened) {
|
|
setActiveStep(0);
|
|
setPreviewResult(null);
|
|
setAssignedSchedule(null);
|
|
setExtraBookingIds([]);
|
|
setContainerPlacements([]);
|
|
setReschedulePlan(null);
|
|
setConfirmPreempt(false);
|
|
setAllocationComplete(false);
|
|
return;
|
|
}
|
|
if (initialBookingIds?.length) {
|
|
setExtraBookingIds(initialBookingIds.filter((id) => id !== booking.id));
|
|
}
|
|
}, [opened, booking.id, initialBookingIds]);
|
|
|
|
useEffect(() => {
|
|
if (matchingSchedules.length && !selectedScheduleId) {
|
|
setSelectedScheduleId(matchingSchedules[0].id);
|
|
}
|
|
}, [matchingSchedules, selectedScheduleId]);
|
|
|
|
const savedPlacementsFromSchedule = useMemo(
|
|
() =>
|
|
assignedSchedule?.trainSet?.wagons
|
|
? placementsFromScheduleWagons(assignedSchedule.trainSet.wagons)
|
|
: [],
|
|
[assignedSchedule?.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]);
|
|
|
|
const activeRoutes = useMemo(
|
|
() => (routesQuery.data ?? []).filter((r) => r.isActive),
|
|
[routesQuery.data],
|
|
);
|
|
|
|
const displayWagonPlan = useMemo(() => {
|
|
const savedWagons = assignedSchedule?.trainSet?.wagons ?? [];
|
|
const physicalBySeq = new Map(
|
|
savedWagons.map((w) => [w.sequenceNo, w.physicalWagonNumber ?? null]),
|
|
);
|
|
if (previewResult?.wagonPlan?.length) {
|
|
return previewResult.wagonPlan.map((slot) => ({
|
|
...slot,
|
|
physicalWagonNumber: physicalBySeq.get(slot.sequenceNo) ?? null,
|
|
}));
|
|
}
|
|
if (savedWagons.length) return savedWagons;
|
|
return [];
|
|
}, [previewResult?.wagonPlan, assignedSchedule?.trainSet?.wagons]);
|
|
|
|
const ensureSchedule = async (): Promise<string> => {
|
|
if (scheduleMode === "existing" && selectedScheduleId) return selectedScheduleId;
|
|
if (!routeId || !scheduleDate || !locomotiveId) {
|
|
throw new Error("Select route, date, and locomotive");
|
|
}
|
|
const created = await create.mutateAsync({
|
|
payload: { routeId, scheduleDate, locomotiveId },
|
|
});
|
|
setSelectedScheduleId(created.id);
|
|
return created.id;
|
|
};
|
|
|
|
const handlePreview = async () => {
|
|
if (!originId || !destinationId) {
|
|
toast({ title: "Booking missing origin or destination", variant: "destructive" });
|
|
return;
|
|
}
|
|
try {
|
|
const targetScheduleId =
|
|
scheduleMode === "existing" ? (selectedScheduleId ?? undefined) : undefined;
|
|
const result = await preview.mutateAsync({
|
|
payload: {
|
|
bookingIds: allBookingIds,
|
|
scheduleDate,
|
|
originStationId: originId,
|
|
destinationStationId: destinationId,
|
|
targetScheduleId,
|
|
},
|
|
});
|
|
setPreviewResult(result);
|
|
if (booking.isGovernment && targetScheduleId) {
|
|
const plan = (await trainSchedulingService.previewReschedule(targetScheduleId, {
|
|
incomingBookingIds: allBookingIds,
|
|
trigger: "GOVERNMENT_PREEMPT",
|
|
})) as ReschedulePlan;
|
|
setReschedulePlan(plan);
|
|
} else {
|
|
setReschedulePlan(null);
|
|
}
|
|
if (result.containerUnits?.length && result.containerSlotSequenceNos?.length) {
|
|
const autoFilled = autoFillPlacements(
|
|
result.containerUnits,
|
|
result.containerSlotSequenceNos,
|
|
);
|
|
setContainerPlacements(autoFilled);
|
|
}
|
|
setActiveStep(1);
|
|
} catch (err) {
|
|
toast({
|
|
title: "Preview failed",
|
|
description: parseError(err, "Could not preview"),
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleAssign = async () => {
|
|
if (hasContainerStep) {
|
|
const issues = validateLocalPlacements(containerUnits, containerPlacements);
|
|
if (issues.length) {
|
|
toast({
|
|
title: "Complete container assignments",
|
|
description: issues.join(", "),
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (reschedulePlan?.displaced.length && !confirmPreempt) {
|
|
toast({
|
|
title: "Confirm displacement",
|
|
description: "Acknowledge displaced bookings before assigning",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const scheduleId = await ensureSchedule();
|
|
let result: TrainScheduleDetail;
|
|
if (reschedulePlan?.displaced.length) {
|
|
const executed = await trainSchedulingService.executeReschedule(scheduleId, {
|
|
incomingBookingIds: allBookingIds,
|
|
trigger: "GOVERNMENT_PREEMPT",
|
|
finalBookingIds: reschedulePlan.finalBookingIds,
|
|
displacedBookingIds: reschedulePlan.displaced.map((b) => b.id),
|
|
});
|
|
result = (executed as { schedule: TrainScheduleDetail }).schedule;
|
|
} else {
|
|
result = await assign.mutateAsync({
|
|
id: scheduleId,
|
|
freightType: previewFreightType,
|
|
payload: {
|
|
bookingIds: allBookingIds,
|
|
forceAssign,
|
|
containerPlacements: hasContainerStep ? containerPlacements : undefined,
|
|
},
|
|
});
|
|
}
|
|
setAssignedSchedule(result);
|
|
const saved = result.trainSet?.wagons
|
|
? placementsFromScheduleWagons(result.trainSet.wagons)
|
|
: [];
|
|
if (saved.length) {
|
|
setContainerPlacements(saved);
|
|
}
|
|
setActiveStep(finalizeStep);
|
|
toast({ title: "Bookings assigned — wagons auto-pinned" });
|
|
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 handleFinalize = async () => {
|
|
const scheduleId = assignedSchedule?.id ?? selectedScheduleId;
|
|
if (!scheduleId) return;
|
|
try {
|
|
const finalized = await finalize.mutateAsync(scheduleId);
|
|
setAssignedSchedule(finalized);
|
|
setAllocationComplete(true);
|
|
toast({ title: "Schedule finalized — booking allocated" });
|
|
} catch (err) {
|
|
toast({
|
|
title: "Finalize failed",
|
|
description: parseError(err, "Could not finalize schedule"),
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
};
|
|
|
|
const amount = Number(booking.totalAmount);
|
|
const containers = booking.bookingContainers ?? [];
|
|
const containerCount = containers.reduce((sum, c) => sum + Number(c.quantity ?? 0), 0);
|
|
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
|
|
const holdCountdown = formatCountdown(booking.holdExpiresAt);
|
|
|
|
const containerComplete =
|
|
hasContainerStep &&
|
|
containerUnits.length > 0 &&
|
|
validateLocalPlacements(containerUnits, containerPlacements).length === 0;
|
|
|
|
const stepsMeta = [
|
|
{
|
|
key: "bookings",
|
|
icon: Package,
|
|
title: "Bookings",
|
|
subtitle: "Select cargo & preview the plan",
|
|
complete: Boolean(previewResult) || Boolean(assignedSchedule),
|
|
},
|
|
{
|
|
key: "wagon",
|
|
icon: LayoutGrid,
|
|
title: "Wagon plan",
|
|
subtitle: "Review generated allocations",
|
|
complete: displayWagonPlan.length > 0,
|
|
},
|
|
...(hasContainerStep
|
|
? [
|
|
{
|
|
key: "container",
|
|
icon: ContainerIcon,
|
|
title: "Containers",
|
|
subtitle: "Map units to wagon slots",
|
|
complete: containerComplete,
|
|
},
|
|
]
|
|
: []),
|
|
{
|
|
key: "finalize",
|
|
icon: CheckCircle2,
|
|
title: "Finalize",
|
|
subtitle: "Lock the plan & dispatch",
|
|
complete: allocationComplete,
|
|
},
|
|
];
|
|
const completedCount = stepsMeta.filter((s) => s.complete).length;
|
|
const progressPct = Math.round((completedCount / stepsMeta.length) * 100);
|
|
const toggleStep = (i: number) => setActiveStep((cur) => (cur === i ? -1 : i));
|
|
|
|
const renderStepRightSlot = (key: string) => {
|
|
if (key === "bookings") {
|
|
if (previewResult) {
|
|
return (
|
|
<Badge variant="light" color={previewResult.valid ? "edr-green" : "red"} radius="sm">
|
|
{previewResult.valid ? "Plan valid" : "Has issues"}
|
|
</Badge>
|
|
);
|
|
}
|
|
return allBookingIds.length ? (
|
|
<Badge variant="light" color="edr-green" radius="sm">
|
|
{allBookingIds.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" && allocationComplete) {
|
|
return <StatusPill status="SCHEDULED" />;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const renderStepBody = (key: string) => {
|
|
if (key === "bookings") {
|
|
return (
|
|
<Stack gap="md">
|
|
<Paper p="md" radius="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
|
<Stack gap="sm">
|
|
<Text fw={600} size="sm">
|
|
Train schedule
|
|
</Text>
|
|
<Radio.Group
|
|
value={scheduleMode}
|
|
onChange={(v) => setScheduleMode(v as "existing" | "new")}
|
|
>
|
|
<Group gap="lg">
|
|
<Radio value="existing" label="Use existing draft schedule" />
|
|
<Radio value="new" label="Create new schedule" />
|
|
</Group>
|
|
</Radio.Group>
|
|
|
|
{scheduleMode === "existing" ? (
|
|
<Select
|
|
label="Draft schedule"
|
|
data={matchingSchedules.map((s) => ({
|
|
value: s.id,
|
|
label: `${s.routeName ?? "Schedule"} · ${new Date(s.scheduleDate).toLocaleDateString()} · ${s.freightType ?? "MIXED"}`,
|
|
}))}
|
|
value={selectedScheduleId}
|
|
onChange={setSelectedScheduleId}
|
|
searchable
|
|
/>
|
|
) : (
|
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
|
<Select
|
|
label="Route"
|
|
data={activeRoutes.map((r) => ({ value: r.id, label: r.name }))}
|
|
value={routeId || null}
|
|
onChange={(v) => setRouteId(v ?? "")}
|
|
searchable
|
|
/>
|
|
<Select
|
|
label="Locomotive"
|
|
placeholder={routeId ? "Select locomotive" : "Select a route first"}
|
|
data={(locomotivesQuery.data ?? []).map((l) => ({
|
|
value: l.id,
|
|
label: `${l.code}${l.name ? ` · ${l.name}` : ""}`,
|
|
}))}
|
|
value={locomotiveId || null}
|
|
onChange={(v) => setLocomotiveId(v ?? "")}
|
|
searchable
|
|
disabled={!routeId}
|
|
nothingFoundMessage={
|
|
routeId ? "No available locomotives for this corridor" : "Select a route first"
|
|
}
|
|
/>
|
|
</SimpleGrid>
|
|
)}
|
|
|
|
{holdCountdown ? (
|
|
<Text size="xs" c={holdCountdown.includes("expired") ? "red" : "yellow.8"}>
|
|
Hold window: {holdCountdown}
|
|
</Text>
|
|
) : null}
|
|
</Stack>
|
|
</Paper>
|
|
|
|
<ScheduleBookingsStep
|
|
assignedBookings={(assignedSchedule?.bookings ?? []).map((b) => ({
|
|
id: b.id,
|
|
reference: b.reference ?? b.id.slice(0, 8),
|
|
weightTons: b.weightTons,
|
|
}))}
|
|
eligibleItems={eligibleQuery.data?.items ?? []}
|
|
eligibleLoading={eligibleQuery.isLoading}
|
|
selectedIds={allBookingIds}
|
|
onSelectionChange={(ids) => {
|
|
setExtraBookingIds(ids.filter((id) => id !== booking.id));
|
|
}}
|
|
freightType={bookingFreightType}
|
|
/>
|
|
|
|
<Group
|
|
align="center"
|
|
justify="space-between"
|
|
wrap="wrap"
|
|
gap="md"
|
|
p="sm"
|
|
style={{
|
|
borderRadius: 12,
|
|
background: "var(--mantine-color-gray-0)",
|
|
border: "1px solid var(--mantine-color-gray-2)",
|
|
}}
|
|
>
|
|
<Checkbox
|
|
label="Force assign (bypass hold / overweight warnings)"
|
|
checked={forceAssign}
|
|
onChange={(e) => setForceAssign(e.currentTarget.checked)}
|
|
size="sm"
|
|
/>
|
|
<Button
|
|
color="edr-green"
|
|
radius="md"
|
|
leftSection={<Eye size={16} />}
|
|
loading={preview.isPending}
|
|
onClick={handlePreview}
|
|
>
|
|
Preview plan
|
|
</Button>
|
|
</Group>
|
|
|
|
{previewResult ? (
|
|
<Stack gap="sm">
|
|
<ScheduleWarningsAlert
|
|
violations={previewResult.violations}
|
|
warnings={previewResult.warnings}
|
|
/>
|
|
<FleetAvailabilitySummary
|
|
fleetAvailability={previewResult.fleetAvailability}
|
|
deferredBookings={previewResult.deferredBookings}
|
|
/>
|
|
<PreviewSummary summary={previewResult.summary} />
|
|
</Stack>
|
|
) : null}
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
if (key === "wagon") {
|
|
return (
|
|
<Stack gap="md">
|
|
{!displayWagonPlan.length && !previewResult ? (
|
|
<Paper p="md" radius="lg" withBorder bg="gray.0">
|
|
<Text size="sm" c="dimmed">
|
|
Run a preview from the Bookings step to generate the wagon plan.
|
|
</Text>
|
|
</Paper>
|
|
) : null}
|
|
{reschedulePlan?.displaced.length ? (
|
|
<Paper p="md" radius="lg" withBorder style={{ borderColor: "var(--mantine-color-orange-2)", background: "var(--mantine-color-orange-0)" }}>
|
|
<Stack gap="sm">
|
|
<Text fw={600} size="sm" c="orange.8">
|
|
Government preempt — bookings to displace
|
|
</Text>
|
|
{reschedulePlan.displaced.map((b) => (
|
|
<Text key={b.id} size="sm">
|
|
{b.reference} (priority {b.priorityScore})
|
|
</Text>
|
|
))}
|
|
<Checkbox
|
|
label="I confirm displacing the bookings listed above"
|
|
checked={confirmPreempt}
|
|
onChange={(e) => setConfirmPreempt(e.currentTarget.checked)}
|
|
/>
|
|
</Stack>
|
|
</Paper>
|
|
) : null}
|
|
<ScheduleWarningsAlert
|
|
violations={previewResult?.violations}
|
|
warnings={previewResult?.warnings}
|
|
/>
|
|
<FleetAvailabilitySummary
|
|
fleetAvailability={previewResult?.fleetAvailability}
|
|
deferredBookings={previewResult?.deferredBookings}
|
|
/>
|
|
<WagonPlanGrid
|
|
wagonPlan={displayWagonPlan}
|
|
freightType={previewFreightType ?? bookingFreightType}
|
|
/>
|
|
<Group>
|
|
{!hasContainerStep ? (
|
|
<Button
|
|
color="edr-green"
|
|
radius="md"
|
|
loading={assign.isPending || create.isPending}
|
|
onClick={handleAssign}
|
|
>
|
|
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={handlePreview}>
|
|
Refresh preview
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
if (key === "container") {
|
|
return (
|
|
<Stack gap="md">
|
|
{!containerUnits.length ? (
|
|
<Paper p="md" radius="lg" withBorder bg="gray.0">
|
|
<Text size="sm" c="dimmed">
|
|
Run preview from the Bookings step to load container units for numbering.
|
|
</Text>
|
|
</Paper>
|
|
) : (
|
|
<ContainerPlacementGrid
|
|
units={containerUnits}
|
|
containerSlots={containerSlots}
|
|
placements={containerPlacements}
|
|
onChange={setContainerPlacements}
|
|
/>
|
|
)}
|
|
<Group>
|
|
<Button
|
|
color="edr-green"
|
|
radius="md"
|
|
loading={assign.isPending || create.isPending}
|
|
onClick={handleAssign}
|
|
>
|
|
Assign bookings
|
|
</Button>
|
|
<Button variant="default" radius="md" onClick={() => setActiveStep(finalizeStep)}>
|
|
Skip to finalize
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
// finalize
|
|
return (
|
|
<Stack gap="md">
|
|
{displayWagonPlan.length || assignedSchedule?.trainSet?.wagons?.length ? (
|
|
<TrainCompositionDiagram
|
|
locomotive={assignedSchedule?.trainSet?.locomotive}
|
|
wagons={
|
|
assignedSchedule?.trainSet?.wagons?.length
|
|
? assignedSchedule.trainSet.wagons
|
|
: displayWagonPlan
|
|
}
|
|
freightType={previewFreightType ?? bookingFreightType}
|
|
trainNumber={assignedSchedule?.trainNumber}
|
|
totalLengthMeters={assignedSchedule?.trainSet?.totalLengthMeters}
|
|
/>
|
|
) : null}
|
|
|
|
{allocationComplete ? (
|
|
<Paper
|
|
p="lg"
|
|
radius="lg"
|
|
withBorder
|
|
style={{ background: scheduleBrand.softSurface, borderColor: scheduleBrand.mutedBorder }}
|
|
>
|
|
<Group gap="md" align="flex-start" wrap="nowrap">
|
|
<ThemeIcon size={44} radius="md" variant="light" color="edr-green">
|
|
<CheckCircle2 size={22} />
|
|
</ThemeIcon>
|
|
<Stack gap={2} style={{ flex: 1 }}>
|
|
<Text fw={700} size="lg">
|
|
Allocation complete
|
|
</Text>
|
|
<Text size="sm" c="dimmed">
|
|
Booking {booking.reference} is scheduled on train{" "}
|
|
<Text span fw={600} c="edr-green.7">
|
|
{assignedSchedule?.trainSet?.locomotive?.code ?? "—"}
|
|
</Text>
|
|
.
|
|
</Text>
|
|
<Group mt="sm">
|
|
<Button
|
|
color="edr-green"
|
|
radius="md"
|
|
onClick={() => {
|
|
onClose();
|
|
if (assignedSchedule?.id) {
|
|
navigate(
|
|
`/dashboard/operations/train-scheduling-v2/${assignedSchedule.id}`,
|
|
);
|
|
}
|
|
}}
|
|
>
|
|
View schedule
|
|
</Button>
|
|
<Button variant="default" radius="md" onClick={onClose}>
|
|
Close
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Group>
|
|
</Paper>
|
|
) : (
|
|
<>
|
|
<Paper
|
|
p="lg"
|
|
radius="lg"
|
|
withBorder
|
|
style={{ background: scheduleBrand.softSurface, borderColor: scheduleBrand.mutedBorder }}
|
|
>
|
|
<Group gap="md" align="flex-start" wrap="nowrap">
|
|
<ThemeIcon size={44} radius="md" variant="light" color="edr-green">
|
|
<CheckCircle2 size={22} />
|
|
</ThemeIcon>
|
|
<Stack gap={2}>
|
|
<Text fw={600}>Ready to finalize</Text>
|
|
<Text size="sm" c="dimmed">
|
|
Finalizing locks the plan, moves the schedule to{" "}
|
|
<Text span fw={600} c="edr-green.7">
|
|
SCHEDULED
|
|
</Text>
|
|
, and completes the booking allocation.
|
|
</Text>
|
|
</Stack>
|
|
</Group>
|
|
</Paper>
|
|
<Group>
|
|
<Button
|
|
color="edr-green"
|
|
size="md"
|
|
radius="md"
|
|
leftSection={<CheckCircle2 size={18} />}
|
|
loading={finalize.isPending}
|
|
onClick={handleFinalize}
|
|
>
|
|
Finalize schedule
|
|
</Button>
|
|
</Group>
|
|
</>
|
|
)}
|
|
</Stack>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<Modal
|
|
opened={opened}
|
|
onClose={onClose}
|
|
withCloseButton
|
|
size="90%"
|
|
radius="lg"
|
|
centered
|
|
padding="lg"
|
|
styles={{ content: { maxWidth: 1200 }, body: { paddingTop: 8 } }}
|
|
>
|
|
<Stack gap="lg">
|
|
{/* Hero */}
|
|
<Paper
|
|
radius="xl"
|
|
p="xl"
|
|
style={{
|
|
position: "relative",
|
|
overflow: "hidden",
|
|
background: scheduleBrand.heroGradient,
|
|
boxShadow: scheduleBrand.shadow,
|
|
}}
|
|
>
|
|
<Box
|
|
style={{
|
|
position: "absolute",
|
|
top: -90,
|
|
right: -50,
|
|
width: 280,
|
|
height: 280,
|
|
borderRadius: "50%",
|
|
background: "rgba(255,255,255,0.10)",
|
|
pointerEvents: "none",
|
|
}}
|
|
/>
|
|
<Stack gap="lg" style={{ position: "relative" }}>
|
|
<Group justify="space-between" align="flex-start" wrap="wrap">
|
|
<Group gap="md" align="flex-start" wrap="nowrap">
|
|
<ThemeIcon
|
|
size={56}
|
|
radius="lg"
|
|
variant="white"
|
|
style={{ color: "var(--mantine-color-edr-green-7)" }}
|
|
>
|
|
<Train size={28} />
|
|
</ThemeIcon>
|
|
<Stack gap={6}>
|
|
<Group gap="sm" align="center" wrap="wrap">
|
|
<Title order={2} c="white" fw={700}>
|
|
Allocate {booking.reference}
|
|
</Title>
|
|
</Group>
|
|
<Box maw={360}>
|
|
<RouteCorridor
|
|
onDark
|
|
origin={booking.originYard?.name ?? booking.originYard?.label}
|
|
destination={
|
|
booking.destinationYard?.name ?? booking.destinationYard?.label
|
|
}
|
|
/>
|
|
</Box>
|
|
<Group gap="sm" align="center">
|
|
<FreightTypeBadge freightType={booking.freightType} />
|
|
{booking.schedulingStatus ? (
|
|
<SchedulingStatusBadge status={booking.schedulingStatus} />
|
|
) : null}
|
|
</Group>
|
|
</Stack>
|
|
</Group>
|
|
{previewResult ? (
|
|
<Badge
|
|
size="lg"
|
|
radius="sm"
|
|
variant="white"
|
|
c={previewResult.valid ? "edr-green.8" : "red.7"}
|
|
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>
|
|
|
|
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
|
<StatTile
|
|
onDark
|
|
icon={Wallet}
|
|
label="Total value"
|
|
value={`${booking.paymentCurrency} ${amount.toLocaleString(undefined, {
|
|
minimumFractionDigits: 2,
|
|
})}`}
|
|
hint={booking.paymentStatus}
|
|
/>
|
|
<StatTile onDark icon={Weight} label="Cargo weight" value={`${weight} T`} hint="VGM total" />
|
|
<StatTile
|
|
onDark
|
|
icon={ContainerIcon}
|
|
label="Containers"
|
|
value={containerCount || "—"}
|
|
hint={`${containers.length} line${containers.length === 1 ? "" : "s"}`}
|
|
/>
|
|
<StatTile
|
|
onDark
|
|
icon={Flame}
|
|
label="Priority"
|
|
value={booking.priorityScore ?? 0}
|
|
hint={booking.tradeDirection}
|
|
/>
|
|
</SimpleGrid>
|
|
</Stack>
|
|
</Paper>
|
|
|
|
{/* Workflow */}
|
|
<Paper radius="xl" p="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
|
<Stack gap="lg">
|
|
<Group justify="space-between" align="center" wrap="nowrap">
|
|
<Group gap="md" align="center" wrap="nowrap">
|
|
<ThemeIcon
|
|
size={44}
|
|
radius="md"
|
|
variant="gradient"
|
|
gradient={{ from: "edr-green", to: "teal", deg: 135 }}
|
|
>
|
|
<RouteIcon size={22} />
|
|
</ThemeIcon>
|
|
<Stack gap={2}>
|
|
<Title order={4} fw={700}>
|
|
Allocation workflow
|
|
</Title>
|
|
<Text size="sm" c="dimmed">
|
|
{completedCount} of {stepsMeta.length} steps complete · expand any step
|
|
to edit
|
|
</Text>
|
|
</Stack>
|
|
</Group>
|
|
<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>
|
|
</Stack>
|
|
</Modal>
|
|
);
|
|
}
|