booking operations and trains scheduling also allocations

This commit is contained in:
marshal
2026-06-10 00:48:32 +03:00
parent 675975bc08
commit 5774d7db9d
180 changed files with 13423 additions and 3877 deletions

View File

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