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

View File

@@ -0,0 +1,202 @@
import { useMemo } from "react";
import {
Badge,
Button,
Card,
Group,
Paper,
Progress,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { CheckCircle2, Container } from "lucide-react";
import type { ContainerPlacement, ContainerUnitRow } from "@/types/trainScheduling";
import { autoFillPlacements, unitKey, validateLocalPlacements } from "./containerPlacement.util";
export function ContainerPlacementGrid({
units,
containerSlots,
placements,
onChange,
}: {
units: ContainerUnitRow[];
containerSlots: number[];
placements: ContainerPlacement[];
onChange: (placements: ContainerPlacement[]) => void;
}) {
const placementMap = useMemo(() => {
const map = new Map<string, ContainerPlacement>();
for (const placement of placements) {
map.set(unitKey(placement.bookingContainerId, placement.unitIndex), placement);
}
return map;
}, [placements]);
const issues = useMemo(() => validateLocalPlacements(units, placements), [units, placements]);
const completedCount = useMemo(
() =>
units.filter((unit) => {
const placement = placementMap.get(unitKey(unit.bookingContainerId, unit.unitIndex));
return placement?.sequenceNo && placement.containerNumber?.trim();
}).length,
[units, placementMap],
);
const slotOptions = containerSlots.map((seq) => ({
value: String(seq),
label: `Wagon #${seq}`,
}));
const updatePlacement = (unit: ContainerUnitRow, patch: Partial<ContainerPlacement>) => {
const key = unitKey(unit.bookingContainerId, unit.unitIndex);
const existing = placementMap.get(key);
const next: ContainerPlacement = {
bookingContainerId: unit.bookingContainerId,
unitIndex: unit.unitIndex,
sequenceNo: existing?.sequenceNo ?? containerSlots[0] ?? 1,
containerNumber: existing?.containerNumber,
sealNumber: existing?.sealNumber,
...patch,
};
onChange([
...placements.filter(
(p) => !(p.bookingContainerId === unit.bookingContainerId && p.unitIndex === unit.unitIndex),
),
next,
]);
};
if (!units.length) {
return (
<Text size="sm" c="dimmed">
No container units in this selection.
</Text>
);
}
const progress = units.length ? Math.round((completedCount / units.length) * 100) : 0;
return (
<Stack gap="md">
<Paper p="md" radius="xl" withBorder>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Stack gap={4}>
<Group gap="xs">
<Container size={18} />
<Text fw={600} size="sm">
Container assignment
</Text>
</Group>
<Text size="xs" c="dimmed">
Map each booking unit to a wagon slot and enter the container number. One wagon fits
either 1×40ft or 2×20ft containers.
</Text>
</Stack>
<Button
variant="light"
size="compact-sm"
onClick={() => onChange(autoFillPlacements(units, containerSlots))}
>
Auto-fill slots
</Button>
</Group>
<Stack gap={6} mt="md">
<Group justify="space-between">
<Text size="xs" c="dimmed">
{completedCount} of {units.length} units complete
</Text>
<Text size="xs" fw={500}>
{progress}%
</Text>
</Group>
<Progress
value={progress}
size="sm"
radius="xl"
color={issues.length ? "yellow" : "teal"}
/>
</Stack>
</Paper>
{issues.length ? (
<Stack gap={6}>
{issues.map((issue) => (
<Badge key={issue} color="red" variant="light" size="sm" w="fit-content">
{issue}
</Badge>
))}
</Stack>
) : (
<Badge
color="teal"
variant="light"
size="sm"
w="fit-content"
leftSection={<CheckCircle2 size={12} />}
>
All units mapped
</Badge>
)}
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
{units.map((unit) => {
const key = unitKey(unit.bookingContainerId, unit.unitIndex);
const placement = placementMap.get(key);
const isComplete = placement?.sequenceNo && placement.containerNumber?.trim();
return (
<Card key={key} radius="xl" padding="md" withBorder>
<Stack gap="md">
<Group justify="space-between" align="flex-start">
<Stack gap={2}>
<Text size="sm" fw={600}>
{unit.bookingReference}
</Text>
<Text size="xs" c="dimmed">
{unit.label} · {unit.containerTypeCode} · {unit.grossWeightTons}T
</Text>
</Stack>
<Badge size="sm" variant="light" color={isComplete ? "teal" : "gray"}>
{isComplete ? "Ready" : "Pending"}
</Badge>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<Select
label="Wagon slot"
size="sm"
data={slotOptions}
value={placement?.sequenceNo ? String(placement.sequenceNo) : null}
onChange={(value) =>
updatePlacement(unit, { sequenceNo: Number(value ?? containerSlots[0]) })
}
placeholder="Select wagon"
searchable
/>
<TextInput
label="Container number"
size="sm"
placeholder="e.g. MSCU1234567"
value={placement?.containerNumber ?? ""}
onChange={(e) =>
updatePlacement(unit, {
containerNumber: e.currentTarget.value,
})
}
/>
</SimpleGrid>
</Stack>
</Card>
);
})}
</SimpleGrid>
</Stack>
);
}

View File

@@ -0,0 +1,245 @@
import { useMemo, useState } from "react";
import { ArrowRight, Package } from "lucide-react";
import {
Accordion,
Badge,
Button,
Checkbox,
Group,
Loader,
Paper,
Stack,
Text,
} from "@mantine/core";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import type { EligibleContainerBooking, FreightType } from "@/types/trainScheduling";
import { groupBookingsByThreeHourWindow } from "@/utils/groupBookingsByThreeHourWindow";
function EligibleBookingRow({
booking,
freightType,
selected,
onToggle,
}: {
booking: EligibleContainerBooking;
freightType?: FreightType;
selected: boolean;
onToggle: () => void;
}) {
const resolvedFreightType = booking.freightType ?? freightType;
const isBulk = resolvedFreightType === "BULK";
return (
<Group
align="flex-start"
wrap="nowrap"
p="sm"
style={{
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: 10,
background: selected ? "var(--mantine-color-teal-0)" : undefined,
}}
>
<Checkbox checked={selected} onChange={onToggle} mt={4} />
<Stack gap={4} style={{ flex: 1 }}>
<Group gap="xs" wrap="wrap">
<Package size={14} />
<Text fw={600} size="sm">
{booking.reference}
</Text>
{resolvedFreightType ? (
<Badge variant="outline" size="xs">
{resolvedFreightType}
</Badge>
) : null}
{booking.schedulingStatus ? (
<Badge variant="light" size="xs">
{booking.schedulingStatus}
</Badge>
) : null}
</Group>
<Text size="xs" c="dimmed">
{booking.customer}
</Text>
<Group gap={6}>
<Text size="xs">{booking.origin}</Text>
<ArrowRight size={12} />
<Text size="xs">{booking.destination}</Text>
</Group>
<Group gap="sm">
<BookingPriorityBadge score={booking.priorityScore ?? 0} />
<Text size="xs" c="dimmed">
{isBulk
? `${booking.weightTons}T`
: `${booking.quantity} × ${booking.containerType}`}
</Text>
{booking.preferredDepartureDate ? (
<Text size="xs" c="dimmed">
{new Date(booking.preferredDepartureDate).toLocaleString("en-GB", {
timeZone: "UTC",
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
})}{" "}
UTC
</Text>
) : null}
</Group>
</Stack>
</Group>
);
}
export function EligibleBookingsPanel({
items,
isLoading,
selectedIds,
onSelectionChange,
assignedIds = [],
freightType,
}: {
items: EligibleContainerBooking[];
isLoading?: boolean;
selectedIds: string[];
onSelectionChange: (ids: string[]) => void;
assignedIds?: string[];
freightType?: FreightType;
}) {
const assignedSet = useMemo(() => new Set(assignedIds), [assignedIds]);
const availableItems = useMemo(
() => items.filter((b) => !assignedSet.has(b.id)),
[items, assignedSet],
);
const buckets = useMemo(
() => groupBookingsByThreeHourWindow(availableItems),
[availableItems],
);
const selectableIds = useMemo(() => {
return [...assignedIds, ...availableItems.map((b) => b.id)];
}, [availableItems, assignedIds]);
const toggle = (id: string) => {
if (selectedIds.includes(id)) {
onSelectionChange(selectedIds.filter((x) => x !== id));
} else {
onSelectionChange([...selectedIds, id]);
}
};
const toggleBucket = (bucketIds: string[], select: boolean) => {
if (select) {
const merged = new Set([...selectedIds, ...bucketIds]);
onSelectionChange([...merged]);
} else {
onSelectionChange(selectedIds.filter((id) => !bucketIds.includes(id)));
}
};
if (isLoading) {
return (
<Group justify="center" py="md">
<Loader size="sm" />
<Text size="sm" c="dimmed">
Loading eligible bookings
</Text>
</Group>
);
}
if (!items.length && !assignedIds.length) {
return (
<Paper p="lg" radius="lg" withBorder bg="gray.0">
<Text size="sm" c="dimmed" ta="center">
No eligible bookings for this corridor
</Text>
</Paper>
);
}
return (
<Stack gap="md">
<Group justify="space-between" wrap="wrap">
<Text size="sm" fw={500}>
Eligible bookings ({availableItems.length})
</Text>
<Group gap="sm">
<Button
variant="light"
size="compact-sm"
onClick={() => onSelectionChange(selectableIds)}
>
Select all
</Button>
<Button variant="subtle" size="compact-sm" onClick={() => onSelectionChange(assignedIds)}>
Clear
</Button>
</Group>
</Group>
{buckets.length > 0 ? (
<Accordion defaultValue={buckets[0]?.key} variant="separated" radius="lg">
{buckets.map((bucket) => {
const bucketIds = bucket.bookings.map((b) => b.id);
const selectedInBucket = bucketIds.filter((id) => selectedIds.includes(id));
const allSelected = bucketIds.length > 0 && selectedInBucket.length === bucketIds.length;
return (
<Accordion.Item key={bucket.key} value={bucket.key}>
<Accordion.Control>
<Group justify="space-between" wrap="nowrap" pr="md">
<Stack gap={2}>
<Text fw={600} size="sm">
{bucket.label}
</Text>
<Text size="xs" c="dimmed">
{bucket.bookings.length} booking
{bucket.bookings.length === 1 ? "" : "s"} · priority sorted
</Text>
</Stack>
<Group gap="xs" onClick={(e) => e.stopPropagation()}>
<Badge variant="light" color="teal">
{selectedInBucket.length} selected
</Badge>
<Button
variant="subtle"
size="compact-xs"
onClick={(e) => {
e.stopPropagation();
toggleBucket(bucketIds, !allSelected);
}}
>
{allSelected ? "Deselect bucket" : "Select bucket"}
</Button>
</Group>
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
{bucket.bookings.map((booking) => (
<EligibleBookingRow
key={booking.id}
booking={booking}
freightType={freightType}
selected={selectedIds.includes(booking.id)}
onToggle={() => toggle(booking.id)}
/>
))}
</Stack>
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion>
) : (
<Text size="sm" c="dimmed">
No additional eligible bookings in this corridor.
</Text>
)}
</Stack>
);
}

View File

@@ -0,0 +1,135 @@
import {
Alert,
Badge,
Group,
Paper,
Progress,
SimpleGrid,
Stack,
Table,
Text,
} from "@mantine/core";
import { AlertTriangle, Train } from "lucide-react";
import type { DeferredBookingRow, FleetAvailabilityRow } from "@/types/trainScheduling";
export function FleetAvailabilitySummary({
fleetAvailability = [],
deferredBookings = [],
}: {
fleetAvailability?: FleetAvailabilityRow[];
deferredBookings?: DeferredBookingRow[];
}) {
if (!fleetAvailability.length && !deferredBookings.length) return null;
const totalNeeded = fleetAvailability.reduce((sum, row) => sum + row.needed, 0);
const totalAvailable = fleetAvailability.reduce((sum, row) => sum + row.available, 0);
const totalShortfall = fleetAvailability.reduce((sum, row) => sum + row.shortfall, 0);
const fillRate =
totalNeeded > 0 ? Math.round((Math.min(totalAvailable, totalNeeded) / totalNeeded) * 100) : 100;
return (
<Paper p="md" radius="xl" withBorder>
<Stack gap="md">
<Group justify="space-between" align="flex-start" wrap="wrap">
<Group gap="xs">
<Train size={18} />
<Stack gap={2}>
<Text fw={600} size="sm">
Fleet wagon availability
</Text>
<Text size="xs" c="dimmed">
Plan is capped to available physical wagons by type
</Text>
</Stack>
</Group>
<Badge variant="light" color={totalShortfall > 0 ? "yellow" : "teal"}>
{fillRate}% fleet coverage
</Badge>
</Group>
{totalNeeded > 0 ? (
<Stack gap={6}>
<Group justify="space-between">
<Text size="xs" c="dimmed">
{Math.min(totalAvailable, totalNeeded)} of {totalNeeded} wagon slots can be filled
</Text>
</Group>
<Progress
value={fillRate}
size="sm"
radius="xl"
color={totalShortfall > 0 ? "yellow" : "teal"}
/>
</Stack>
) : null}
{fleetAvailability.length > 0 ? (
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Wagon type</Table.Th>
<Table.Th>Needed</Table.Th>
<Table.Th>Available</Table.Th>
<Table.Th>Shortfall</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{fleetAvailability.map((row) => (
<Table.Tr key={row.wagonTypeId}>
<Table.Td>{row.wagonTypeCode}</Table.Td>
<Table.Td>{row.needed}</Table.Td>
<Table.Td>{row.available}</Table.Td>
<Table.Td>
{row.shortfall > 0 ? (
<Badge color="red" variant="light" size="sm">
{row.shortfall}
</Badge>
) : (
<Text size="sm" c="teal">
0
</Text>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
) : null}
{totalShortfall > 0 || deferredBookings.length > 0 ? (
<Alert color="yellow" variant="light" radius="lg" icon={<AlertTriangle size={16} />}>
<Text size="sm">
Train will depart with available wagons only.
{deferredBookings.length
? ` ${deferredBookings.length} booking(s) will wait for the next train.`
: ""}
</Text>
</Alert>
) : null}
{deferredBookings.length > 0 ? (
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm">
{deferredBookings.map((booking) => (
<Paper key={booking.id} p="sm" radius="lg" withBorder bg="gray.0">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Stack gap={2}>
<Text size="sm" fw={600}>
{booking.reference}
</Text>
<Text size="xs" c="dimmed">
{booking.reason}
</Text>
</Stack>
<Badge variant="light" color="orange" size="sm">
Next train
</Badge>
</Group>
</Paper>
))}
</SimpleGrid>
) : null}
</Stack>
</Paper>
);
}

View File

@@ -0,0 +1,207 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Alert,
Badge,
Button,
Group,
Paper,
Progress,
Select,
SimpleGrid,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { AlertTriangle, Link2, Wand2 } from "lucide-react";
import { Freight } from "@edr/types";
import type { PinWagonAssignment, TrainScheduleDetail } from "@/types/trainScheduling";
import type { Wagon } from "@/services/wagon.service";
import { wagonMatchesScheduleDirection } from "@/utils/wagonAvailability";
import { autoFillWagonAssignments, countFilledSlots } from "./pinWagons.util";
export function PinWagonsForm({
schedule,
availableWagons,
isSubmitting,
onSubmit,
autoFillOnMount = true,
}: {
schedule: TrainScheduleDetail;
availableWagons: Wagon[];
isSubmitting?: boolean;
onSubmit: (assignments: PinWagonAssignment[]) => void;
autoFillOnMount?: boolean;
}) {
const slots = schedule.trainSet?.wagons ?? [];
const [assignments, setAssignments] = useState<Record<string, string>>({});
const wagonOptionsByType = useMemo(() => {
const map = new Map<string, Array<{ value: string; label: string }>>();
for (const wagon of availableWagons) {
const isPinnedOnSlot = slots.some((s) => s.physicalWagonId === wagon.id);
if (
!wagonMatchesScheduleDirection(wagon, schedule.direction, {
allowPinned: isPinnedOnSlot,
})
) {
continue;
}
if (wagon.status !== Freight.WagonStatus.Available && !isPinnedOnSlot) {
continue;
}
const typeId = wagon.wagonTypeId;
const list = map.get(typeId) ?? [];
list.push({ value: wagon.id, label: wagon.wagonNumber });
map.set(typeId, list);
}
return map;
}, [availableWagons, schedule.direction, slots]);
const runAutoFill = useCallback(
(preserveManual = false) => {
const existing = preserveManual ? assignments : {};
setAssignments(autoFillWagonAssignments(slots, wagonOptionsByType, existing));
},
[assignments, slots, wagonOptionsByType],
);
useEffect(() => {
if (!autoFillOnMount || !slots.length) return;
setAssignments(autoFillWagonAssignments(slots, wagonOptionsByType));
}, [schedule.id, slots, wagonOptionsByType, autoFillOnMount]);
const fillStats = useMemo(
() => countFilledSlots(slots, assignments),
[slots, assignments],
);
const progress =
fillStats.total > 0 ? Math.round((fillStats.filled / fillStats.total) * 100) : 0;
const handleSubmit = () => {
const payload: PinWagonAssignment[] = Object.entries(assignments)
.filter(([, wagonId]) => Boolean(wagonId))
.map(([trainSetWagonId, physicalWagonId]) => ({ trainSetWagonId, physicalWagonId }));
onSubmit(payload);
};
if (!slots.length) {
return (
<Text size="sm" c="dimmed">
Assign bookings first to create wagon slots.
</Text>
);
}
return (
<Stack gap="md">
<Paper p="md" radius="xl" withBorder>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Stack gap={4}>
<Group gap="xs">
<Link2 size={18} />
<Text fw={600} size="sm">
Pin physical wagons
</Text>
</Group>
<Text size="xs" c="dimmed">
Match each train slot to a fleet wagon. Slots are auto-filled when possible.
</Text>
</Stack>
<Button
variant="light"
size="compact-sm"
leftSection={<Wand2 size={14} />}
onClick={() => runAutoFill(false)}
>
Auto-fill all slots
</Button>
</Group>
<Stack gap={6} mt="md">
<Group justify="space-between">
<Text size="xs" c="dimmed">
{fillStats.filled} of {fillStats.total} slots filled
</Text>
<Badge variant="light" color={progress === 100 ? "teal" : "yellow"}>
{progress}%
</Badge>
</Group>
<Progress value={progress} size="sm" radius="xl" color={progress === 100 ? "teal" : "yellow"} />
</Stack>
</Paper>
{fillStats.unfilledSlotNumbers.length > 0 ? (
<Alert
color="yellow"
variant="light"
radius="lg"
icon={<AlertTriangle size={16} />}
title="Some slots could not be auto-filled"
>
<Text size="sm">
No matching fleet wagon for slot
{fillStats.unfilledSlotNumbers.length === 1 ? "" : "s"} #
{fillStats.unfilledSlotNumbers.join(", #")}. Select manually or add wagons to the fleet.
</Text>
</Alert>
) : null}
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
{slots.map((slot) => {
const typeId = slot.wagonType?.id ?? "";
const options =
wagonOptionsByType.get(typeId) ??
availableWagons.map((w) => ({
value: w.id,
label: w.wagonNumber,
}));
return (
<Paper key={slot.id} p="md" radius="lg" withBorder>
<Group align="flex-end" wrap="nowrap" gap="md">
<Stack gap={2} style={{ minWidth: 90 }}>
<Group gap={6}>
<ThemeIcon size="sm" radius="md" variant="light" color="teal">
<Text size="xs" fw={700}>
{slot.sequenceNo}
</Text>
</ThemeIcon>
<Text size="sm" fw={600}>
Slot #{slot.sequenceNo}
</Text>
</Group>
<Text size="xs" c="dimmed">
{slot.wagonType?.code ?? "—"} · {slot.capacityTons}T
</Text>
</Stack>
<Select
style={{ flex: 1 }}
placeholder="Select physical wagon"
data={options}
value={assignments[slot.id] ?? null}
onChange={(value) =>
setAssignments((current) => ({
...current,
[slot.id]: value ?? "",
}))
}
searchable
/>
</Group>
</Paper>
);
})}
</SimpleGrid>
<Group justify="flex-end">
<Button color="teal" loading={isSubmitting} onClick={handleSubmit}>
Pin wagons
</Button>
</Group>
</Stack>
);
}

View File

@@ -0,0 +1,76 @@
import { useState } from "react";
import { Button, Group, Modal, Stack, Text, TextInput, Textarea } from "@mantine/core";
import toast from "react-hot-toast";
import { trainSchedulingService } from "@/services/trainScheduling.service";
export function RescheduleTrainDialog({
scheduleId,
currentBookingIds,
opened,
onClose,
onComplete,
}: {
scheduleId: string;
currentBookingIds: string[];
opened: boolean;
onClose: () => void;
onComplete?: () => void;
}) {
const [newDepartureDate, setNewDepartureDate] = useState("");
const [reason, setReason] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async () => {
if (!newDepartureDate) {
toast.error("Select a new departure date");
return;
}
setLoading(true);
try {
await trainSchedulingService.maintenanceReschedule(scheduleId, {
incomingBookingIds: currentBookingIds,
newDepartureDate: new Date(newDepartureDate).toISOString(),
reason,
});
toast.success("Train rescheduled for maintenance");
onComplete?.();
onClose();
} catch {
toast.error("Reschedule failed");
} finally {
setLoading(false);
}
};
return (
<Modal opened={opened} onClose={onClose} title="Reschedule train (maintenance)" radius="lg">
<Stack gap="md">
<Text size="sm" c="dimmed">
Updates departure and rebalances bookings on this train. Displaced bookings return to
the operations queue when capacity is insufficient.
</Text>
<TextInput
label="New departure"
type="datetime-local"
value={newDepartureDate}
onChange={(e) => setNewDepartureDate(e.target.value)}
/>
<Textarea
label="Reason"
placeholder="e.g. Locomotive maintenance"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button loading={loading} onClick={handleSubmit}>
Reschedule
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,133 @@
import { ArrowRight, Package, Train } from "lucide-react";
import {
Badge,
Button,
Group,
Paper,
Stack,
Tabs,
Text,
} from "@mantine/core";
import type { EligibleContainerBooking, FreightType } from "@/types/trainScheduling";
import { EligibleBookingsPanel } from "./EligibleBookingsPanel";
export type AssignedBookingRow = {
id: string;
reference: string;
weightTons?: number;
};
export function ScheduleBookingsStep({
assignedBookings,
eligibleItems,
eligibleLoading,
selectedIds,
onSelectionChange,
assignedIds,
freightType,
canRemove,
onRemove,
}: {
assignedBookings: AssignedBookingRow[];
eligibleItems: EligibleContainerBooking[];
eligibleLoading?: boolean;
selectedIds: string[];
onSelectionChange: (ids: string[]) => void;
assignedIds?: string[];
freightType?: FreightType;
canRemove?: boolean;
onRemove?: (bookingId: string) => void;
}) {
return (
<Paper p="md" radius="xl" withBorder>
<Tabs defaultValue={assignedBookings.length ? "on-train" : "add"} radius="lg" variant="pills">
<Tabs.List mb="md">
<Tabs.Tab
value="on-train"
leftSection={<Train size={14} />}
rightSection={
assignedBookings.length ? (
<Badge size="xs" variant="light" color="teal" circle>
{assignedBookings.length}
</Badge>
) : undefined
}
>
On this train
</Tabs.Tab>
<Tabs.Tab value="add" leftSection={<Package size={14} />}>
Add bookings
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="on-train">
{assignedBookings.length ? (
<Stack gap="sm">
{assignedBookings.map((booking) => (
<Group
key={booking.id}
justify="space-between"
p="sm"
style={{
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: 10,
background: "var(--mantine-color-teal-0)",
}}
>
<Stack gap={4}>
<Group gap="xs">
<Text fw={600} size="sm">
{booking.reference}
</Text>
{booking.weightTons != null ? (
<Badge variant="outline" size="xs">
{booking.weightTons}T
</Badge>
) : null}
</Group>
<Group gap={6}>
<Text size="xs" c="dimmed">
Assigned to this consist
</Text>
<ArrowRight size={12} />
<Text size="xs" c="teal">
Ready for wagon plan
</Text>
</Group>
</Stack>
{canRemove && onRemove ? (
<Button
variant="subtle"
color="red"
size="compact-xs"
onClick={() => onRemove(booking.id)}
>
Remove
</Button>
) : null}
</Group>
))}
</Stack>
) : (
<Text size="sm" c="dimmed" ta="center" py="lg">
No bookings on this train yet. Use the Add bookings tab to select eligible cargo.
</Text>
)}
</Tabs.Panel>
<Tabs.Panel value="add">
<EligibleBookingsPanel
items={eligibleItems}
isLoading={eligibleLoading}
selectedIds={selectedIds}
onSelectionChange={onSelectionChange}
assignedIds={assignedIds}
freightType={freightType}
/>
</Tabs.Panel>
</Tabs>
</Paper>
);
}

View File

@@ -0,0 +1,44 @@
import { Badge } from "@mantine/core";
const STATUS_COLORS: Record<string, string> = {
DRAFT: "gray",
SCHEDULED: "blue",
DISPATCHED: "green",
ARRIVED: "teal",
CANCELLED: "red",
};
export function ScheduleStatusBadge({ status }: { status: string }) {
return (
<Badge variant="light" color={STATUS_COLORS[status] ?? "gray"} size="sm">
{status}
</Badge>
);
}
export function FreightTypeBadge({ freightType }: { freightType?: string | null }) {
if (!freightType) return <Badge variant="light" color="gray" size="sm"></Badge>;
const color =
freightType === "BULK" ? "orange" : freightType === "MIXED" ? "grape" : "cyan";
return (
<Badge variant="light" color={color} size="sm">
{freightType}
</Badge>
);
}
export function SchedulingStatusBadge({ status }: { status?: string | null }) {
if (!status) return null;
const colors: Record<string, string> = {
NOT_SCHEDULED: "gray",
HOLDING: "yellow",
ELIGIBLE: "blue",
SCHEDULED: "indigo",
DISPATCHED: "green",
};
return (
<Badge variant="light" color={colors[status] ?? "gray"} size="sm">
{status.replace(/_/g, " ")}
</Badge>
);
}

View File

@@ -0,0 +1,75 @@
import { Alert, List, Paper, SimpleGrid, Stack, Text } from "@mantine/core";
import { AlertTriangle, XCircle } from "lucide-react";
export function ScheduleWarningsAlert({
violations = [],
warnings = [],
}: {
violations?: string[];
warnings?: string[];
}) {
if (!violations.length && !warnings.length) return null;
return (
<Stack gap="sm">
{violations.length > 0 ? (
<Alert color="red" radius="xl" icon={<XCircle size={16} />} title="Violations">
<List size="sm" spacing={4}>
{violations.map((v) => (
<List.Item key={v}>{v}</List.Item>
))}
</List>
</Alert>
) : null}
{warnings.length > 0 ? (
<Alert color="yellow" radius="xl" icon={<AlertTriangle size={16} />} title="Warnings">
<List size="sm" spacing={4}>
{warnings.map((w) => (
<List.Item key={w}>{w}</List.Item>
))}
</List>
</Alert>
) : null}
</Stack>
);
}
export function PreviewSummary({
summary,
}: {
summary?: {
totalBookings: number;
totalWeightTons: number;
wagonType: string;
wagonsNeeded: number;
totalLengthMeters: number;
};
}) {
if (!summary) return null;
const stats = [
{ label: "Bookings", value: String(summary.totalBookings) },
{ label: "Wagons", value: String(summary.wagonsNeeded) },
{ label: "Wagon type", value: summary.wagonType },
{ label: "Total weight", value: `${summary.totalWeightTons}T` },
{ label: "Train length", value: `${summary.totalLengthMeters}m` },
];
return (
<Paper p="md" radius="xl" withBorder bg="teal.0">
<Text size="sm" fw={600} mb="sm">
Plan summary
</Text>
<SimpleGrid cols={{ base: 2, sm: 3, md: 5 }} spacing="sm">
{stats.map((stat) => (
<Stack key={stat.label} gap={2}>
<Text size="xs" c="dimmed" tt="uppercase">
{stat.label}
</Text>
<Text size="sm" fw={600}>
{stat.value}
</Text>
</Stack>
))}
</SimpleGrid>
</Paper>
);
}

View File

@@ -0,0 +1,90 @@
import { Badge, Group, Paper, Progress, Stack, Text, ThemeIcon } from "@mantine/core";
import type { LucideIcon } from "lucide-react";
import {
CheckCircle2,
Container,
LayoutGrid,
Link2,
Package,
} from "lucide-react";
const stepIcons: Record<string, LucideIcon> = {
package: Package,
layout: LayoutGrid,
container: Container,
link: Link2,
check: CheckCircle2,
};
export function SchedulingWorkflowHeader({
title,
subtitle,
activeStep,
totalSteps,
stepLabel,
stepDescription,
stepIcon = "package",
}: {
title: string;
subtitle?: string;
activeStep: number;
totalSteps: number;
stepLabel: string;
stepDescription?: string;
stepIcon?: keyof typeof stepIcons;
}) {
const Icon = stepIcons[stepIcon] ?? Package;
const progress = totalSteps > 0 ? Math.round(((activeStep + 1) / totalSteps) * 100) : 0;
return (
<Paper
p="md"
radius="xl"
withBorder
style={{
background:
"linear-gradient(180deg, var(--mantine-color-white) 0%, var(--mantine-color-gray-0) 100%)",
}}
>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap="md" align="flex-start">
<ThemeIcon size={42} radius="xl" variant="gradient" gradient={{ from: "teal", to: "green", deg: 135 }}>
<Icon size={20} />
</ThemeIcon>
<Stack gap={4}>
<Text fw={700} size="lg">
{title}
</Text>
{subtitle ? (
<Text size="sm" c="dimmed">
{subtitle}
</Text>
) : null}
</Stack>
</Group>
<Badge size="lg" variant="light" color="teal">
Step {activeStep + 1} of {totalSteps}
</Badge>
</Group>
<Stack gap={6} mt="md">
<Group justify="space-between">
<Text size="sm" fw={600}>
{stepLabel}
{stepDescription ? (
<Text span c="dimmed" fw={400}>
{" "}
· {stepDescription}
</Text>
) : null}
</Text>
<Text size="xs" c="dimmed">
{progress}%
</Text>
</Group>
<Progress value={progress} size="sm" radius="xl" color="teal" />
</Stack>
</Paper>
);
}

View File

@@ -0,0 +1,188 @@
import { Badge, Card, Group, Progress, SimpleGrid, Stack, Text, ThemeIcon } from "@mantine/core";
import { Box, Package } from "lucide-react";
import type { TrainScheduleWagonAllocation, WagonPlanRow } from "@/types/trainScheduling";
type WagonSlot = WagonPlanRow | {
sequenceNo: number;
capacityTons: number;
assignedWeightTons: number;
slotLoadType?: string;
wagonType?: { code: string } | null;
wagonTypeCode?: string;
physicalWagonNumber?: string | null;
allocations?: TrainScheduleWagonAllocation[] | Array<{
id?: string;
bookingId: string;
bookingReference?: string | null;
allocatedWeightTons: number;
loadType?: string | null;
containerItems?: Array<{ containerNumber: string | null; grossWeightTons?: number | null }>;
bulkLoad?: { weightTons: number; cargoDescription: string | null } | null;
}>;
};
function loadTypeColor(loadType: string | undefined, freightType?: string | null) {
const normalized = loadType?.toUpperCase() ?? "";
if (normalized.includes("BULK")) return "orange";
if (normalized.includes("CONTAINER")) return "cyan";
return freightType === "BULK" ? "orange" : "cyan";
}
function slotLabel(slot: WagonSlot, freightType?: string | null) {
if ("slotLoadType" in slot && slot.slotLoadType) return slot.slotLoadType;
const fromAlloc = slot.allocations?.[0]?.loadType?.toString().toUpperCase();
if (fromAlloc) return fromAlloc;
if (freightType === "MIXED") return "MIXED";
return freightType ?? "SLOT";
}
function wagonTypeLabel(slot: WagonSlot) {
if ("wagonType" in slot && slot.wagonType?.code) return slot.wagonType.code;
if ("wagonTypeCode" in slot && slot.wagonTypeCode) return slot.wagonTypeCode;
return null;
}
export function WagonPlanGrid({
wagonPlan,
freightType,
}: {
wagonPlan: WagonSlot[];
freightType?: string | null;
}) {
if (!wagonPlan?.length) {
return (
<Card radius="lg" padding="xl" withBorder bg="gray.0">
<Stack align="center" gap="sm">
<ThemeIcon size="lg" radius="xl" variant="light" color="gray">
<Package size={20} />
</ThemeIcon>
<Text size="sm" fw={500}>
No wagon plan yet
</Text>
<Text size="xs" c="dimmed" ta="center" maw={360}>
Select bookings and run <strong>Preview plan</strong> to generate wagon slots and
allocations.
</Text>
</Stack>
</Card>
);
}
const totalCapacity = wagonPlan.reduce((sum, w) => sum + w.capacityTons, 0);
const totalAssigned = wagonPlan.reduce((sum, w) => sum + w.assignedWeightTons, 0);
const usedSlots = wagonPlan.filter((w) => (w.allocations?.length ?? 0) > 0).length;
const isBulk = freightType === "BULK" || wagonPlan.every((w) => w.slotLoadType === "BULK" || (!w.slotLoadType && w.allocations?.[0]?.loadType === "Bulk"));
return (
<Stack gap="md">
<Group gap="lg">
<Text size="sm" c="dimmed">
<strong>{wagonPlan.length}</strong> wagons · <strong>{usedSlots}</strong> in use
</Text>
{isBulk ? (
<Text size="sm" c="dimmed">
Load: <strong>{totalAssigned}</strong> / {totalCapacity}T
</Text>
) : null}
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, xl: 3 }} spacing="md">
{wagonPlan.map((wagon) => {
const seq = wagon.sequenceNo;
const capacity = wagon.capacityTons;
const assigned = wagon.assignedWeightTons;
const allocations = wagon.allocations ?? [];
const utilization = capacity > 0 ? Math.min(100, Math.round((assigned / capacity) * 100)) : 0;
const label = slotLabel(wagon, freightType);
const typeCode = wagonTypeLabel(wagon);
return (
<Card key={seq} radius="lg" padding="md" withBorder>
<Stack gap="sm">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<ThemeIcon size="md" radius="md" variant="light" color={loadTypeColor(label, freightType)}>
<Box size={16} />
</ThemeIcon>
<Stack gap={0}>
<Text fw={600} size="sm">
Wagon #{seq}
</Text>
{typeCode ? (
<Text size="xs" c="dimmed">
{typeCode}
{wagon.physicalWagonNumber ? ` · ${wagon.physicalWagonNumber}` : ""}
</Text>
) : null}
</Stack>
</Group>
<Badge variant="light" size="sm" color={loadTypeColor(label, freightType)}>
{label}
</Badge>
</Group>
{label === "BULK" ? (
<Stack gap={4}>
<Group justify="space-between">
<Text size="xs" c="dimmed">
Capacity
</Text>
<Text size="xs" fw={500}>
{assigned} / {capacity}T
</Text>
</Group>
<Progress
value={utilization}
size="sm"
radius="xl"
color={utilization > 95 ? "red" : utilization > 80 ? "yellow" : "green"}
/>
</Stack>
) : null}
<Stack gap={6}>
{allocations.length ? (
allocations.map((alloc, index) => (
<Card key={`${alloc.bookingId}-${index}`} padding="xs" radius="md" bg="gray.0">
<Stack gap={2}>
<Group justify="space-between" gap="xs">
<Text size="xs" fw={500} lineClamp={1}>
{alloc.bookingReference ?? alloc.bookingId}
</Text>
{label === "BULK" ? (
<Text size="xs" c="dimmed">
{alloc.allocatedWeightTons}T
</Text>
) : null}
</Group>
{"containerItems" in alloc && alloc.containerItems?.length ? (
<Text size="xs" c="dimmed">
{alloc.containerItems.length} container{alloc.containerItems.length > 1 ? "s" : ""}
</Text>
) : null}
{"bulkLoad" in alloc && alloc.bulkLoad ? (
<Text size="xs" c="dimmed" lineClamp={2}>
Bulk · {alloc.bulkLoad.weightTons}T
{alloc.bulkLoad.cargoDescription
? `${alloc.bulkLoad.cargoDescription}`
: ""}
</Text>
) : null}
</Stack>
</Card>
))
) : (
<Text size="xs" c="dimmed" fs="italic">
Empty slot
</Text>
)}
</Stack>
</Stack>
</Card>
);
})}
</SimpleGrid>
</Stack>
);
}

View File

@@ -0,0 +1,244 @@
import { describe, it, expect } from 'vitest';
import { autoFillPlacements, unitKey, validateLocalPlacements } from './containerPlacement.util';
import type { ContainerUnitRow } from '@/types/trainScheduling';
function makeUnits(containerType: string, sizeFt: number, quantity: number): ContainerUnitRow[] {
const units: ContainerUnitRow[] = [];
const containersPerWagon = sizeFt >= 40 ? 1 : 2;
const wagonsPerUnit = sizeFt >= 40 ? 1 : 0.5;
for (let i = 0; i < quantity; i++) {
units.push({
bookingId: 'booking-1',
bookingReference: 'BKG-001',
bookingContainerId: 'bc-1',
unitIndex: i,
containerTypeId: 'ct-1',
containerTypeCode: containerType,
label: `${containerType} ${i + 1}/${quantity}`,
grossWeightTons: 25,
sizeFt,
wagonsPerUnit,
containersPerWagon,
teuSlots: sizeFt >= 40 ? 2 : 1,
});
}
return units;
}
describe('containerPlacement.util', () => {
describe('unitKey', () => {
it('creates unique keys for units', () => {
expect(unitKey('bc-1', 0)).toBe('bc-1:0');
expect(unitKey('bc-1', 1)).toBe('bc-1:1');
expect(unitKey('bc-2', 0)).toBe('bc-2:0');
});
});
describe('autoFillPlacements', () => {
it('returns empty array when no units or slots', () => {
expect(autoFillPlacements([], [1, 2, 3])).toEqual([]);
expect(autoFillPlacements(makeUnits('20GP', 20, 1), [])).toEqual([]);
});
it('places 2×20ft containers in 1 wagon slot', () => {
const units = makeUnits('20GP', 20, 2);
const slots = [1, 2, 3];
const placements = autoFillPlacements(units, slots);
expect(placements).toHaveLength(2);
// Both 20ft containers should be in slot 1
expect(placements[0]?.sequenceNo).toBe(1);
expect(placements[1]?.sequenceNo).toBe(1);
});
it('places 6×20ft containers in 3 wagon slots (2 per wagon)', () => {
const units = makeUnits('20GP', 20, 6);
const slots = [1, 2, 3, 4, 5];
const placements = autoFillPlacements(units, slots);
expect(placements).toHaveLength(6);
// Units 0,1 -> Slot 1
expect(placements[0]?.sequenceNo).toBe(1);
expect(placements[1]?.sequenceNo).toBe(1);
// Units 2,3 -> Slot 2
expect(placements[2]?.sequenceNo).toBe(2);
expect(placements[3]?.sequenceNo).toBe(2);
// Units 4,5 -> Slot 3
expect(placements[4]?.sequenceNo).toBe(3);
expect(placements[5]?.sequenceNo).toBe(3);
});
it('places 1×40ft container in 1 wagon slot', () => {
const units = makeUnits('40GP', 40, 1);
const slots = [1, 2, 3];
const placements = autoFillPlacements(units, slots);
expect(placements).toHaveLength(1);
expect(placements[0]?.sequenceNo).toBe(1);
});
it('places 3×40ft containers in 3 wagon slots (1 per wagon)', () => {
const units = makeUnits('40GP', 40, 3);
const slots = [1, 2, 3, 4, 5];
const placements = autoFillPlacements(units, slots);
expect(placements).toHaveLength(3);
// Each 40ft container gets its own slot
expect(placements[0]?.sequenceNo).toBe(1);
expect(placements[1]?.sequenceNo).toBe(2);
expect(placements[2]?.sequenceNo).toBe(3);
});
it('handles mixed 20ft and 40ft containers correctly', () => {
const units20 = makeUnits('20GP', 20, 2);
const units40 = makeUnits('40GP', 40, 1);
const units = [...units20, ...units40];
const slots = [1, 2, 3, 4, 5];
const placements = autoFillPlacements(units, slots);
expect(placements).toHaveLength(3);
// First two 20ft containers share slot 1
expect(placements[0]?.sequenceNo).toBe(1);
expect(placements[1]?.sequenceNo).toBe(1);
// 40ft container gets slot 2
expect(placements[2]?.sequenceNo).toBe(2);
});
it('falls back to last slot when running out of slots', () => {
const units = makeUnits('20GP', 20, 6);
const slots = [1, 2]; // Only 2 slots available
const placements = autoFillPlacements(units, slots);
expect(placements).toHaveLength(6);
// First 4 units fit in slots 1 and 2
expect(placements[0]?.sequenceNo).toBe(1);
expect(placements[1]?.sequenceNo).toBe(1);
expect(placements[2]?.sequenceNo).toBe(2);
expect(placements[3]?.sequenceNo).toBe(2);
// Remaining units fall back to last available slot (slot 2)
expect(placements[4]?.sequenceNo).toBe(2);
expect(placements[5]?.sequenceNo).toBe(2);
});
it('defaults to 2 containers per wagon when sizeFt is not provided', () => {
const units: ContainerUnitRow[] = [
{
bookingId: 'booking-1',
bookingReference: 'BKG-001',
bookingContainerId: 'bc-1',
unitIndex: 0,
containerTypeId: 'ct-1',
containerTypeCode: '20GP',
label: 'Container 1',
grossWeightTons: 25,
// sizeFt not provided, should default to 2 per wagon
},
{
bookingId: 'booking-1',
bookingReference: 'BKG-001',
bookingContainerId: 'bc-1',
unitIndex: 1,
containerTypeId: 'ct-1',
containerTypeCode: '20GP',
label: 'Container 2',
grossWeightTons: 25,
},
];
const slots = [1, 2, 3];
const placements = autoFillPlacements(units, slots);
expect(placements[0]?.sequenceNo).toBe(1);
expect(placements[1]?.sequenceNo).toBe(1);
});
it('uses 1 container per wagon for 40ft when sizeFt is 40', () => {
const units: ContainerUnitRow[] = [
{
bookingId: 'booking-1',
bookingReference: 'BKG-001',
bookingContainerId: 'bc-1',
unitIndex: 0,
containerTypeId: 'ct-1',
containerTypeCode: '40GP',
label: 'Container 1',
grossWeightTons: 25,
sizeFt: 40,
},
{
bookingId: 'booking-1',
bookingReference: 'BKG-001',
bookingContainerId: 'bc-1',
unitIndex: 1,
containerTypeId: 'ct-1',
containerTypeCode: '40GP',
label: 'Container 2',
grossWeightTons: 25,
sizeFt: 40,
},
];
const slots = [1, 2, 3];
const placements = autoFillPlacements(units, slots);
// Each 40ft container should get its own slot
expect(placements[0]?.sequenceNo).toBe(1);
expect(placements[1]?.sequenceNo).toBe(2);
});
});
describe('validateLocalPlacements', () => {
it('returns empty array for valid placements', () => {
const units = makeUnits('20GP', 20, 1);
const placements = [
{
bookingContainerId: 'bc-1',
unitIndex: 0,
sequenceNo: 1,
containerNumber: 'CNTR123',
},
];
expect(validateLocalPlacements(units, placements)).toEqual([]);
});
it('returns error for missing slot', () => {
const units = makeUnits('20GP', 20, 1);
const placements: ReturnType<typeof autoFillPlacements> = [];
const issues = validateLocalPlacements(units, placements);
expect(issues.some((i) => i.includes('Slot missing'))).toBe(true);
});
it('returns error for missing container number', () => {
const units = makeUnits('20GP', 20, 1);
const placements = [
{
bookingContainerId: 'bc-1',
unitIndex: 0,
sequenceNo: 1,
// No containerNumber or containerId
},
];
const issues = validateLocalPlacements(units, placements);
expect(issues.some((i) => i.includes('Enter a container number'))).toBe(true);
});
it('returns error for duplicate container numbers', () => {
const units = makeUnits('20GP', 20, 2);
const placements = [
{
bookingContainerId: 'bc-1',
unitIndex: 0,
sequenceNo: 1,
containerNumber: 'CNTR123',
},
{
bookingContainerId: 'bc-1',
unitIndex: 1,
sequenceNo: 1,
containerNumber: 'CNTR123', // Duplicate!
},
];
const issues = validateLocalPlacements(units, placements);
expect(issues.some((i) => i.includes('Duplicate container number'))).toBe(true);
});
});
});

View File

@@ -0,0 +1,128 @@
import type { ContainerPlacement, ContainerUnitRow } from "@/types/trainScheduling";
export function unitKey(bookingContainerId: string, unitIndex: number) {
return `${bookingContainerId}:${unitIndex}`;
}
type ScheduleWagonForPlacements = {
sequenceNo: number;
allocations?: Array<{
containerItems?: Array<{
bookingContainerId?: string | null;
positionOnWagon?: number | null;
containerId?: string | null;
containerNumber?: string | null;
}>;
}>;
};
export function placementsFromScheduleWagons(
wagons: ScheduleWagonForPlacements[],
): ContainerPlacement[] {
const placements: ContainerPlacement[] = [];
for (const wagon of wagons) {
for (const allocation of wagon.allocations ?? []) {
for (const containerItem of allocation.containerItems ?? []) {
if (containerItem.bookingContainerId && containerItem.positionOnWagon != null) {
placements.push({
bookingContainerId: containerItem.bookingContainerId,
unitIndex: containerItem.positionOnWagon - 1,
sequenceNo: wagon.sequenceNo,
containerNumber: containerItem.containerNumber ?? undefined,
});
}
}
}
}
return placements;
}
export function mergePlacementsWithSaved(
autoFilled: ContainerPlacement[],
saved: ContainerPlacement[],
): ContainerPlacement[] {
const savedMap = new Map(
saved.map((placement) => [unitKey(placement.bookingContainerId, placement.unitIndex), placement]),
);
return autoFilled.map((placement) => {
const existing = savedMap.get(unitKey(placement.bookingContainerId, placement.unitIndex));
if (existing?.containerNumber?.trim()) {
return {
...placement,
containerNumber: existing.containerNumber,
containerId: undefined,
sealNumber: existing.sealNumber,
};
}
return placement;
});
}
export function autoFillPlacements(
units: ContainerUnitRow[],
containerSlots: number[],
): ContainerPlacement[] {
if (!units.length || !containerSlots.length) return [];
const placements: ContainerPlacement[] = [];
let currentSlotIndex = 0;
let unitsInCurrentSlot = 0;
for (const unit of units) {
const perWagon = unit.containersPerWagon ?? (unit.sizeFt && unit.sizeFt >= 40 ? 1 : 2);
if (unitsInCurrentSlot >= perWagon) {
currentSlotIndex += 1;
unitsInCurrentSlot = 0;
}
const sequenceNo =
containerSlots[Math.min(currentSlotIndex, containerSlots.length - 1)] ??
containerSlots[containerSlots.length - 1] ??
containerSlots[0];
placements.push({
bookingContainerId: unit.bookingContainerId,
unitIndex: unit.unitIndex,
sequenceNo,
});
unitsInCurrentSlot += 1;
}
return placements;
}
export function validateLocalPlacements(
units: ContainerUnitRow[],
placements: ContainerPlacement[],
): string[] {
const issues: string[] = [];
const numbers = new Set<string>();
for (const unit of units) {
const placement = placements.find(
(p) =>
p.bookingContainerId === unit.bookingContainerId && p.unitIndex === unit.unitIndex,
);
if (!placement?.sequenceNo) {
issues.push(`Slot missing for ${unit.label}`);
continue;
}
if (!placement.containerNumber?.trim()) {
issues.push(`Enter a container number for ${unit.label}`);
}
if (placement.containerNumber?.trim()) {
const normalized = placement.containerNumber.trim().toUpperCase();
if (numbers.has(normalized)) {
issues.push(`Duplicate container number ${normalized}`);
}
numbers.add(normalized);
}
}
return issues;
}

View File

@@ -0,0 +1,56 @@
export interface WagonSlotForPin {
id: string;
sequenceNo: number;
physicalWagonId?: string | null;
wagonType?: { id: string } | null;
}
export function autoFillWagonAssignments(
slots: WagonSlotForPin[],
wagonOptionsByType: Map<string, Array<{ value: string; label: string }>>,
existingAssignments: Record<string, string> = {},
): Record<string, string> {
const next: Record<string, string> = {};
const assignedWagonIds = new Set<string>();
for (const slot of slots) {
const pinnedId = slot.physicalWagonId ?? existingAssignments[slot.id];
if (pinnedId) {
next[slot.id] = pinnedId;
assignedWagonIds.add(pinnedId);
}
}
for (const slot of slots) {
if (next[slot.id]) continue;
const typeId = slot.wagonType?.id ?? "";
const options = wagonOptionsByType.get(typeId) ?? [];
const availableWagon = options.find((option) => !assignedWagonIds.has(option.value));
if (availableWagon) {
next[slot.id] = availableWagon.value;
assignedWagonIds.add(availableWagon.value);
}
}
return next;
}
export function countFilledSlots(
slots: WagonSlotForPin[],
assignments: Record<string, string>,
): { filled: number; total: number; unfilledSlotNumbers: number[] } {
const unfilledSlotNumbers: number[] = [];
for (const slot of slots) {
if (!assignments[slot.id]) {
unfilledSlotNumbers.push(slot.sequenceNo);
}
}
return {
filled: slots.length - unfilledSlotNumbers.length,
total: slots.length,
unfilledSlotNumbers,
};
}

View File

@@ -0,0 +1,14 @@
import type { FreightType } from "@/types/trainScheduling";
const isContainerFreight = (freightType?: string | null) => freightType === "CONTAINER";
/** Show container number placement step when train includes container cargo. */
export function shouldShowContainerPlacementStep(params: {
containerUnitCount: number;
scheduleFreightType?: FreightType | string | null;
bookingFreightTypes: Array<FreightType | string | null | undefined>;
}): boolean {
if (params.containerUnitCount > 0) return true;
if (isContainerFreight(params.scheduleFreightType)) return true;
return params.bookingFreightTypes.some(isContainerFreight);
}

View File

@@ -0,0 +1,28 @@
import type { MantineTheme } from "@mantine/core";
export const schedulingWorkflow = {
stepper: {
color: "teal" as const,
iconSize: 32,
size: "sm" as const,
},
card: {
radius: "xl" as const,
padding: "lg" as const,
withBorder: true,
},
heroGradient: (theme: MantineTheme) =>
`linear-gradient(135deg, ${theme.colors.teal[0]} 0%, ${theme.white} 55%, ${theme.colors.gray[0]} 100%)`,
workflowGradient: (theme: MantineTheme) =>
`linear-gradient(180deg, ${theme.white} 0%, ${theme.colors.gray[0]} 100%)`,
accentColor: "teal" as const,
successColor: "teal" as const,
warningColor: "yellow" as const,
};
export const schedulingStepMeta = [
{ label: "Bookings", description: "Select & preview", icon: "package" },
{ label: "Wagon plan", description: "Allocations", icon: "layout" },
{ label: "Containers", description: "Map units", icon: "container" },
{ label: "Finalize", description: "Depart", icon: "check" },
] as const;