mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 22:18:12 +00:00
booking operations and trains scheduling also allocations
This commit is contained in:
@@ -0,0 +1,662 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { isAxiosError } from "axios";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Radio,
|
||||
Select,
|
||||
Stack,
|
||||
Stepper,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { CheckCircle2 } 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 { SchedulingWorkflowHeader } from "./SchedulingWorkflowHeader";
|
||||
import { schedulingWorkflow } from "./schedulingWorkflow.styles";
|
||||
import { SchedulingStatusBadge } from "./ScheduleStatusBadge";
|
||||
import { WagonPlanGrid } from "./WagonPlanGrid";
|
||||
|
||||
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();
|
||||
const { create, preview, assign, finalize } = useScheduleMutations(selectedScheduleId ?? undefined);
|
||||
|
||||
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;
|
||||
|
||||
const stepLabels = [
|
||||
"Bookings",
|
||||
"Wagon plan",
|
||||
...(hasContainerStep ? ["Containers"] : []),
|
||||
"Finalize",
|
||||
];
|
||||
|
||||
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 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 holdCountdown = formatCountdown(booking.holdExpiresAt);
|
||||
|
||||
const stepDescription =
|
||||
activeStep === 0
|
||||
? "Select & preview"
|
||||
: activeStep === 1
|
||||
? "Allocations"
|
||||
: hasContainerStep && activeStep === 2
|
||||
? "Map units"
|
||||
: "Depart";
|
||||
|
||||
const stepIcon =
|
||||
activeStep === 0
|
||||
? "package"
|
||||
: activeStep === 1
|
||||
? "layout"
|
||||
: hasContainerStep && activeStep === 2
|
||||
? "container"
|
||||
: "check";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Text fw={600}>Allocate booking — {booking.reference}</Text>}
|
||||
size="90%"
|
||||
radius="xl"
|
||||
centered
|
||||
styles={{ content: { maxWidth: 1200 } }}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<SchedulingWorkflowHeader
|
||||
title="Allocation workflow"
|
||||
subtitle={`${booking.reference} · ${booking.originYard?.name ?? "Origin"} → ${booking.destinationYard?.name ?? "Destination"}`}
|
||||
activeStep={activeStep}
|
||||
totalSteps={stepLabels.length}
|
||||
stepLabel={stepLabels[activeStep] ?? ""}
|
||||
stepDescription={stepDescription}
|
||||
stepIcon={stepIcon}
|
||||
/>
|
||||
|
||||
<Stepper
|
||||
active={activeStep}
|
||||
onStepClick={setActiveStep}
|
||||
color={schedulingWorkflow.stepper.color}
|
||||
iconSize={schedulingWorkflow.stepper.iconSize}
|
||||
size={schedulingWorkflow.stepper.size}
|
||||
>
|
||||
<Stepper.Step label="Bookings" description="Select & preview">
|
||||
<Stack gap="md" mt="lg">
|
||||
<Card withBorder padding="md" radius="xl">
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600}>{booking.reference}</Text>
|
||||
<SchedulingStatusBadge status={booking.schedulingStatus} />
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{booking.freightType} · {booking.cargoTotalWeightVgm}T
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{booking.originYard?.name ?? "Origin"} →{" "}
|
||||
{booking.destinationYard?.name ?? "Destination"}
|
||||
</Text>
|
||||
{booking.freightType === "CONTAINER" && booking.bookingContainers?.length ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{booking.bookingContainers.map((c) => `${c.quantity}× container`).join(", ")}
|
||||
</Text>
|
||||
) : null}
|
||||
{holdCountdown ? (
|
||||
<Text size="sm" c={holdCountdown.includes("expired") ? "red" : "yellow"}>
|
||||
Hold window: {holdCountdown}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Paper p="md" radius="xl" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text fw={600} size="sm">
|
||||
Train schedule
|
||||
</Text>
|
||||
<Radio.Group
|
||||
value={scheduleMode}
|
||||
onChange={(v) => setScheduleMode(v as "existing" | "new")}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Radio value="existing" label="Use existing draft schedule" />
|
||||
<Radio value="new" label="Create new schedule" />
|
||||
</Stack>
|
||||
</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
|
||||
/>
|
||||
) : (
|
||||
<Stack gap="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"
|
||||
data={(locomotivesQuery.data ?? []).map((l) => ({
|
||||
value: l.id,
|
||||
label: l.code,
|
||||
}))}
|
||||
value={locomotiveId || null}
|
||||
onChange={(v) => setLocomotiveId(v ?? "")}
|
||||
searchable
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</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" wrap="wrap">
|
||||
<Button loading={preview.isPending} onClick={handlePreview}>
|
||||
Preview plan
|
||||
</Button>
|
||||
<Checkbox
|
||||
label="Force assign (bypass hold/overweight warnings)"
|
||||
checked={forceAssign}
|
||||
onChange={(e) => setForceAssign(e.currentTarget.checked)}
|
||||
/>
|
||||
</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>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step label="Wagon plan" description="Allocations">
|
||||
<Stack gap="md" mt="lg">
|
||||
<ScheduleWarningsAlert
|
||||
violations={previewResult?.violations}
|
||||
warnings={previewResult?.warnings}
|
||||
/>
|
||||
{reschedulePlan?.displaced.length ? (
|
||||
<Card withBorder padding="md" radius="xl">
|
||||
<Stack gap="sm">
|
||||
<Text fw={600} size="sm" c="orange">
|
||||
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>
|
||||
</Card>
|
||||
) : null}
|
||||
<PreviewSummary summary={previewResult?.summary} />
|
||||
<FleetAvailabilitySummary
|
||||
fleetAvailability={previewResult?.fleetAvailability}
|
||||
deferredBookings={previewResult?.deferredBookings}
|
||||
/>
|
||||
<WagonPlanGrid
|
||||
wagonPlan={previewResult?.wagonPlan ?? []}
|
||||
freightType={previewFreightType ?? bookingFreightType}
|
||||
/>
|
||||
<Group>
|
||||
{!hasContainerStep ? (
|
||||
<Button color="teal" loading={assign.isPending || create.isPending} onClick={handleAssign}>
|
||||
Assign bookings
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="light" onClick={() => setActiveStep(2)}>
|
||||
Continue to containers
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="default" onClick={handlePreview}>
|
||||
Refresh preview
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
|
||||
{hasContainerStep ? (
|
||||
<Stepper.Step label="Containers" description="Map units">
|
||||
<Stack gap="md" mt="lg">
|
||||
{!containerUnits.length ? (
|
||||
<Paper p="md" radius="xl" 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="teal" loading={assign.isPending || create.isPending} onClick={handleAssign}>
|
||||
Assign bookings
|
||||
</Button>
|
||||
<Button variant="light" onClick={() => setActiveStep(finalizeStep)}>
|
||||
Skip to finalize
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
) : null}
|
||||
|
||||
<Stepper.Step label="Finalize" description="Depart">
|
||||
<Stack gap="md" mt="lg">
|
||||
{allocationComplete ? (
|
||||
<Paper p="lg" radius="xl" withBorder bg="teal.0">
|
||||
<Stack gap="md" align="center">
|
||||
<CheckCircle2 size={40} color="var(--mantine-color-teal-7)" />
|
||||
<Text fw={700} size="lg">
|
||||
Allocation complete
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
Booking {booking.reference} is scheduled on train{" "}
|
||||
{assignedSchedule?.trainSet?.locomotive?.code ?? "—"}.
|
||||
</Text>
|
||||
<Group>
|
||||
<Button
|
||||
color="teal"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
if (assignedSchedule?.id) {
|
||||
navigate(
|
||||
`/dashboard/operations/train-scheduling-v2/${assignedSchedule.id}`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
View schedule
|
||||
</Button>
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : (
|
||||
<>
|
||||
<Paper p="md" radius="xl" withBorder bg="gray.0">
|
||||
<Text size="sm" c="dimmed">
|
||||
Finalize moves the schedule to SCHEDULED and completes the booking
|
||||
allocation.
|
||||
</Text>
|
||||
</Paper>
|
||||
<Group>
|
||||
<Button color="teal" loading={finalize.isPending} onClick={handleFinalize}>
|
||||
Finalize schedule
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
</Stepper>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user