schedule logic

This commit is contained in:
Marshal
2026-06-10 09:22:57 +00:00
parent 0335555892
commit 088295d81f
23 changed files with 1766 additions and 319 deletions

View File

@@ -2,19 +2,34 @@ import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { isAxiosError } from "axios";
import {
Badge,
Box,
Button,
Card,
Checkbox,
Group,
Modal,
Paper,
Radio,
RingProgress,
Select,
SimpleGrid,
Stack,
Stepper,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import { CheckCircle2 } from "lucide-react";
import {
CheckCircle2,
Container as ContainerIcon,
Eye,
Flame,
LayoutGrid,
Package,
Route as RouteIcon,
Train,
Wallet,
Weight,
} from "lucide-react";
import {
useAvailableLocomotives,
@@ -46,10 +61,16 @@ 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 { FreightTypeBadge, SchedulingStatusBadge } from "./ScheduleStatusBadge";
import {
RouteCorridor,
StatTile,
StatusPill,
scheduleBrand,
} from "./scheduleVisuals";
import { TrainCompositionDiagram } from "./TrainCompositionDiagram";
import { WagonPlanGrid } from "./WagonPlanGrid";
import { WorkflowRail, WorkflowStep } from "./WorkflowStep";
const parseError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
@@ -157,13 +178,6 @@ export function AllocateBookingWizard({
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);
@@ -216,6 +230,21 @@ export function AllocateBookingWizard({
[routesQuery.data],
);
const displayWagonPlan = useMemo(() => {
const savedWagons = assignedSchedule?.trainSet?.wagons ?? [];
const physicalBySeq = new Map(
savedWagons.map((w) => [w.sequenceNo, w.physicalWagonNumber ?? null]),
);
if (previewResult?.wagonPlan?.length) {
return previewResult.wagonPlan.map((slot) => ({
...slot,
physicalWagonNumber: physicalBySeq.get(slot.sequenceNo) ?? null,
}));
}
if (savedWagons.length) return savedWagons;
return [];
}, [previewResult?.wagonPlan, assignedSchedule?.trainSet?.wagons]);
const ensureSchedule = async (): Promise<string> => {
if (scheduleMode === "existing" && selectedScheduleId) return selectedScheduleId;
if (!routeId || !scheduleDate || !locomotiveId) {
@@ -357,305 +386,602 @@ export function AllocateBookingWizard({
}
};
const amount = Number(booking.totalAmount);
const containers = booking.bookingContainers ?? [];
const containerCount = containers.reduce((sum, c) => sum + Number(c.quantity ?? 0), 0);
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
const holdCountdown = formatCountdown(booking.holdExpiresAt);
const stepDescription =
activeStep === 0
? "Select & preview"
: activeStep === 1
? "Allocations"
: hasContainerStep && activeStep === 2
? "Map units"
: "Depart";
const containerComplete =
hasContainerStep &&
containerUnits.length > 0 &&
validateLocalPlacements(containerUnits, containerPlacements).length === 0;
const stepIcon =
activeStep === 0
? "package"
: activeStep === 1
? "layout"
: hasContainerStep && activeStep === 2
? "container"
: "check";
const stepsMeta = [
{
key: "bookings",
icon: Package,
title: "Bookings",
subtitle: "Select cargo & preview the plan",
complete: Boolean(previewResult) || Boolean(assignedSchedule),
},
{
key: "wagon",
icon: LayoutGrid,
title: "Wagon plan",
subtitle: "Review generated allocations",
complete: displayWagonPlan.length > 0,
},
...(hasContainerStep
? [
{
key: "container",
icon: ContainerIcon,
title: "Containers",
subtitle: "Map units to wagon slots",
complete: containerComplete,
},
]
: []),
{
key: "finalize",
icon: CheckCircle2,
title: "Finalize",
subtitle: "Lock the plan & dispatch",
complete: allocationComplete,
},
];
const completedCount = stepsMeta.filter((s) => s.complete).length;
const progressPct = Math.round((completedCount / stepsMeta.length) * 100);
const toggleStep = (i: number) => setActiveStep((cur) => (cur === i ? -1 : i));
const renderStepRightSlot = (key: string) => {
if (key === "bookings") {
if (previewResult) {
return (
<Badge variant="light" color={previewResult.valid ? "green" : "red"} radius="sm">
{previewResult.valid ? "Plan valid" : "Has issues"}
</Badge>
);
}
return allBookingIds.length ? (
<Badge variant="light" color="green" radius="sm">
{allBookingIds.length} selected
</Badge>
) : null;
}
if (key === "wagon" && displayWagonPlan.length) {
return (
<Badge variant="light" color="green" radius="sm">
{displayWagonPlan.length} wagons
</Badge>
);
}
if (key === "container" && containerUnits.length) {
return (
<Badge variant="light" color={containerComplete ? "green" : "yellow"} radius="sm">
{containerUnits.length} units
</Badge>
);
}
if (key === "finalize" && allocationComplete) {
return <StatusPill status="SCHEDULED" />;
}
return null;
};
const renderStepBody = (key: string) => {
if (key === "bookings") {
return (
<Stack gap="md">
<Paper p="md" radius="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap="sm">
<Text fw={600} size="sm">
Train schedule
</Text>
<Radio.Group
value={scheduleMode}
onChange={(v) => setScheduleMode(v as "existing" | "new")}
>
<Group gap="lg">
<Radio value="existing" label="Use existing draft schedule" />
<Radio value="new" label="Create new schedule" />
</Group>
</Radio.Group>
{scheduleMode === "existing" ? (
<Select
label="Draft schedule"
data={matchingSchedules.map((s) => ({
value: s.id,
label: `${s.routeName ?? "Schedule"} · ${new Date(s.scheduleDate).toLocaleDateString()} · ${s.freightType ?? "MIXED"}`,
}))}
value={selectedScheduleId}
onChange={setSelectedScheduleId}
searchable
/>
) : (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<Select
label="Route"
data={activeRoutes.map((r) => ({ value: r.id, label: r.name }))}
value={routeId || null}
onChange={(v) => setRouteId(v ?? "")}
searchable
/>
<Select
label="Locomotive"
data={(locomotivesQuery.data ?? []).map((l) => ({
value: l.id,
label: `${l.code} · ${
l.readiness === "EXPORT_READY" ? "Export-ready" : "Import-ready"
}`,
}))}
value={locomotiveId || null}
onChange={(v) => setLocomotiveId(v ?? "")}
searchable
/>
</SimpleGrid>
)}
{holdCountdown ? (
<Text size="xs" c={holdCountdown.includes("expired") ? "red" : "yellow.8"}>
Hold window: {holdCountdown}
</Text>
) : null}
</Stack>
</Paper>
<ScheduleBookingsStep
assignedBookings={(assignedSchedule?.bookings ?? []).map((b) => ({
id: b.id,
reference: b.reference ?? b.id.slice(0, 8),
weightTons: b.weightTons,
}))}
eligibleItems={eligibleQuery.data?.items ?? []}
eligibleLoading={eligibleQuery.isLoading}
selectedIds={allBookingIds}
onSelectionChange={(ids) => {
setExtraBookingIds(ids.filter((id) => id !== booking.id));
}}
freightType={bookingFreightType}
/>
<Group
align="center"
justify="space-between"
wrap="wrap"
gap="md"
p="sm"
style={{
borderRadius: 12,
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<Checkbox
label="Force assign (bypass hold / overweight warnings)"
checked={forceAssign}
onChange={(e) => setForceAssign(e.currentTarget.checked)}
size="sm"
/>
<Button
color="green"
radius="md"
leftSection={<Eye size={16} />}
loading={preview.isPending}
onClick={handlePreview}
>
Preview plan
</Button>
</Group>
{previewResult ? (
<Stack gap="sm">
<ScheduleWarningsAlert
violations={previewResult.violations}
warnings={previewResult.warnings}
/>
<FleetAvailabilitySummary
fleetAvailability={previewResult.fleetAvailability}
deferredBookings={previewResult.deferredBookings}
/>
<PreviewSummary summary={previewResult.summary} />
</Stack>
) : null}
</Stack>
);
}
if (key === "wagon") {
return (
<Stack gap="md">
{!displayWagonPlan.length && !previewResult ? (
<Paper p="md" radius="lg" withBorder bg="gray.0">
<Text size="sm" c="dimmed">
Run a preview from the Bookings step to generate the wagon plan.
</Text>
</Paper>
) : null}
{reschedulePlan?.displaced.length ? (
<Paper p="md" radius="lg" withBorder style={{ borderColor: "var(--mantine-color-orange-2)", background: "var(--mantine-color-orange-0)" }}>
<Stack gap="sm">
<Text fw={600} size="sm" c="orange.8">
Government preempt bookings to displace
</Text>
{reschedulePlan.displaced.map((b) => (
<Text key={b.id} size="sm">
{b.reference} (priority {b.priorityScore})
</Text>
))}
<Checkbox
label="I confirm displacing the bookings listed above"
checked={confirmPreempt}
onChange={(e) => setConfirmPreempt(e.currentTarget.checked)}
/>
</Stack>
</Paper>
) : null}
<ScheduleWarningsAlert
violations={previewResult?.violations}
warnings={previewResult?.warnings}
/>
<FleetAvailabilitySummary
fleetAvailability={previewResult?.fleetAvailability}
deferredBookings={previewResult?.deferredBookings}
/>
<WagonPlanGrid
wagonPlan={displayWagonPlan}
freightType={previewFreightType ?? bookingFreightType}
/>
<Group>
{!hasContainerStep ? (
<Button
color="green"
radius="md"
loading={assign.isPending || create.isPending}
onClick={handleAssign}
>
Assign bookings
</Button>
) : (
<Button
color="green"
radius="md"
rightSection={<ContainerIcon size={16} />}
onClick={() => setActiveStep(2)}
>
Continue to containers
</Button>
)}
<Button variant="default" radius="md" onClick={handlePreview}>
Refresh preview
</Button>
</Group>
</Stack>
);
}
if (key === "container") {
return (
<Stack gap="md">
{!containerUnits.length ? (
<Paper p="md" radius="lg" withBorder bg="gray.0">
<Text size="sm" c="dimmed">
Run preview from the Bookings step to load container units for numbering.
</Text>
</Paper>
) : (
<ContainerPlacementGrid
units={containerUnits}
containerSlots={containerSlots}
placements={containerPlacements}
onChange={setContainerPlacements}
/>
)}
<Group>
<Button
color="green"
radius="md"
loading={assign.isPending || create.isPending}
onClick={handleAssign}
>
Assign bookings
</Button>
<Button variant="default" radius="md" onClick={() => setActiveStep(finalizeStep)}>
Skip to finalize
</Button>
</Group>
</Stack>
);
}
// finalize
return (
<Stack gap="md">
{displayWagonPlan.length || assignedSchedule?.trainSet?.wagons?.length ? (
<TrainCompositionDiagram
locomotive={assignedSchedule?.trainSet?.locomotive}
wagons={
assignedSchedule?.trainSet?.wagons?.length
? assignedSchedule.trainSet.wagons
: displayWagonPlan
}
freightType={previewFreightType ?? bookingFreightType}
trainNumber={assignedSchedule?.trainNumber}
totalLengthMeters={assignedSchedule?.trainSet?.totalLengthMeters}
/>
) : null}
{allocationComplete ? (
<Paper
p="lg"
radius="lg"
withBorder
style={{ background: scheduleBrand.softSurface, borderColor: scheduleBrand.mutedBorder }}
>
<Group gap="md" align="flex-start" wrap="nowrap">
<ThemeIcon size={44} radius="md" variant="light" color="green">
<CheckCircle2 size={22} />
</ThemeIcon>
<Stack gap={2} style={{ flex: 1 }}>
<Text fw={700} size="lg">
Allocation complete
</Text>
<Text size="sm" c="dimmed">
Booking {booking.reference} is scheduled on train{" "}
<Text span fw={600} c="green.7">
{assignedSchedule?.trainSet?.locomotive?.code ?? "—"}
</Text>
.
</Text>
<Group mt="sm">
<Button
color="green"
radius="md"
onClick={() => {
onClose();
if (assignedSchedule?.id) {
navigate(
`/dashboard/operations/train-scheduling-v2/${assignedSchedule.id}`,
);
}
}}
>
View schedule
</Button>
<Button variant="default" radius="md" onClick={onClose}>
Close
</Button>
</Group>
</Stack>
</Group>
</Paper>
) : (
<>
<Paper
p="lg"
radius="lg"
withBorder
style={{ background: scheduleBrand.softSurface, borderColor: scheduleBrand.mutedBorder }}
>
<Group gap="md" align="flex-start" wrap="nowrap">
<ThemeIcon size={44} radius="md" variant="light" color="green">
<CheckCircle2 size={22} />
</ThemeIcon>
<Stack gap={2}>
<Text fw={600}>Ready to finalize</Text>
<Text size="sm" c="dimmed">
Finalizing locks the plan, moves the schedule to{" "}
<Text span fw={600} c="green.7">
SCHEDULED
</Text>
, and completes the booking allocation.
</Text>
</Stack>
</Group>
</Paper>
<Group>
<Button
color="green"
size="md"
radius="md"
leftSection={<CheckCircle2 size={18} />}
loading={finalize.isPending}
onClick={handleFinalize}
>
Finalize schedule
</Button>
</Group>
</>
)}
</Stack>
);
};
return (
<Modal
opened={opened}
onClose={onClose}
title={<Text fw={600}>Allocate booking {booking.reference}</Text>}
withCloseButton
size="90%"
radius="xl"
radius="lg"
centered
styles={{ content: { maxWidth: 1200 } }}
padding="lg"
styles={{ content: { maxWidth: 1200 }, body: { paddingTop: 8 } }}
>
<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}
{/* Hero */}
<Paper
radius="xl"
p="xl"
style={{
position: "relative",
overflow: "hidden",
background: scheduleBrand.heroGradient,
boxShadow: scheduleBrand.shadow,
}}
>
<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} />
<Box
style={{
position: "absolute",
top: -90,
right: -50,
width: 280,
height: 280,
borderRadius: "50%",
background: "rgba(255,255,255,0.10)",
pointerEvents: "none",
}}
/>
<Stack gap="lg" style={{ position: "relative" }}>
<Group justify="space-between" align="flex-start" wrap="wrap">
<Group gap="md" align="flex-start" wrap="nowrap">
<ThemeIcon
size={56}
radius="lg"
variant="white"
style={{ color: "var(--mantine-color-green-7)" }}
>
<Train size={28} />
</ThemeIcon>
<Stack gap={6}>
<Group gap="sm" align="center" wrap="wrap">
<Title order={2} c="white" fw={700}>
Allocate {booking.reference}
</Title>
</Group>
<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
<Box maw={360}>
<RouteCorridor
onDark
origin={booking.originYard?.name ?? booking.originYard?.label}
destination={
booking.destinationYard?.name ?? booking.destinationYard?.label
}
/>
) : (
<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>
)}
</Box>
<Group gap="sm" align="center">
<FreightTypeBadge freightType={booking.freightType} />
{booking.schedulingStatus ? (
<SchedulingStatusBadge status={booking.schedulingStatus} />
) : null}
</Group>
</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)}
<Badge
size="lg"
radius="sm"
variant="white"
c={previewResult.valid ? "green.8" : "red.7"}
leftSection={
<Box
w={8}
h={8}
style={{
borderRadius: 999,
background: previewResult.valid
? "var(--mantine-color-green-6)"
: "var(--mantine-color-red-6)",
}}
/>
</Stack>
</Card>
}
>
Preview {previewResult.valid ? "valid" : "has issues"}
</Badge>
) : null}
<PreviewSummary summary={previewResult?.summary} />
<FleetAvailabilitySummary
fleetAvailability={previewResult?.fleetAvailability}
deferredBookings={previewResult?.deferredBookings}
</Group>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
<StatTile
onDark
icon={Wallet}
label="Total value"
value={`${booking.paymentCurrency} ${amount.toLocaleString(undefined, {
minimumFractionDigits: 2,
})}`}
hint={booking.paymentStatus}
/>
<WagonPlanGrid
wagonPlan={previewResult?.wagonPlan ?? []}
freightType={previewFreightType ?? bookingFreightType}
<StatTile onDark icon={Weight} label="Cargo weight" value={`${weight} T`} hint="VGM total" />
<StatTile
onDark
icon={ContainerIcon}
label="Containers"
value={containerCount || "—"}
hint={`${containers.length} line${containers.length === 1 ? "" : "s"}`}
/>
<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>
<StatTile
onDark
icon={Flame}
label="Priority"
value={booking.priorityScore ?? 0}
hint={booking.tradeDirection}
/>
</SimpleGrid>
</Stack>
</Paper>
{/* Workflow */}
<Paper radius="xl" p="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap="lg">
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="md" align="center" wrap="nowrap">
<ThemeIcon
size={44}
radius="md"
variant="gradient"
gradient={{ from: "green", to: "teal", deg: 135 }}
>
<RouteIcon size={22} />
</ThemeIcon>
<Stack gap={2}>
<Title order={4} fw={700}>
Allocation workflow
</Title>
<Text size="sm" c="dimmed">
{completedCount} of {stepsMeta.length} steps complete · expand any step
to edit
</Text>
</Stack>
</Group>
</Stack>
</Stepper.Step>
<RingProgress
size={64}
thickness={6}
roundCaps
sections={[{ value: progressPct, color: "green" }]}
label={
<Text ta="center" size="xs" fw={700} c="green.7">
{progressPct}%
</Text>
}
/>
</Group>
{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>
<WorkflowRail>
{stepsMeta.map((step, index) => (
<WorkflowStep
key={step.key}
index={index}
icon={step.icon}
title={step.title}
subtitle={step.subtitle}
state={
activeStep === index
? "active"
: step.complete
? "complete"
: "upcoming"
}
open={activeStep === index}
onToggle={() => toggleStep(index)}
rightSlot={renderStepRightSlot(step.key)}
>
{renderStepBody(step.key)}
</WorkflowStep>
))}
</WorkflowRail>
</Stack>
</Paper>
</Stack>
</Modal>
);

View File

@@ -0,0 +1,192 @@
import { Fragment } from "react";
import { Badge, Box, Button, Group, Stack, Text } from "@mantine/core";
import { Check, Flag, MapPin, Train } from "lucide-react";
import { freightBrand } from "@/theme/freight-brand";
import type { TrainCheckpoint, TrackStation } from "@/types/trainScheduling";
export interface RouteCorridorTrackProps {
stations: TrackStation[];
/** Highest sequenceNo reached so far (1 = not yet departed). */
currentSequenceNo: number;
checkpoints: TrainCheckpoint[];
/** True when the train is DISPATCHED and staff may log progress. */
canLog: boolean;
loggingSeq?: number | null;
onLogCheckpoint?: (sequenceNo: number) => void;
}
const COLUMN_WIDTH = 150;
const PASSED = freightBrand.primary;
const UPCOMING = "var(--mantine-color-gray-3)";
function railColor(active: boolean) {
return active ? PASSED : UPCOMING;
}
export function RouteCorridorTrack({
stations,
currentSequenceNo,
checkpoints,
canLog,
loggingSeq,
onLogCheckpoint,
}: RouteCorridorTrackProps) {
const bySeq = new Map(checkpoints.map((c) => [c.sequenceNo, c]));
const lastIndex = stations.length - 1;
return (
<Box style={{ overflowX: "auto", paddingBottom: 4 }}>
<Group
gap={0}
wrap="nowrap"
align="flex-start"
style={{ minWidth: stations.length * COLUMN_WIDTH }}
>
{stations.map((station, index) => {
const passed = station.sequenceNo <= currentSequenceNo;
const isCurrent = station.sequenceNo === currentSequenceNo;
const isFinal = index === lastIndex;
const isNext = canLog && station.sequenceNo === currentSequenceNo + 1;
const checkpoint = bySeq.get(station.sequenceNo);
// left rail solid once this node is reached; right rail solid once the next node is reached
const leftActive = station.sequenceNo <= currentSequenceNo;
const rightActive = station.sequenceNo + 1 <= currentSequenceNo;
return (
<Fragment key={station.sequenceNo}>
<Stack gap={6} align="center" style={{ width: COLUMN_WIDTH, flexShrink: 0 }}>
{/* rail + node */}
<Box style={{ position: "relative", height: 44, width: "100%" }}>
{index > 0 && (
<Box
style={{
position: "absolute",
top: 21,
left: 0,
width: "50%",
height: 3,
borderRadius: 2,
background: railColor(leftActive),
}}
/>
)}
{index < lastIndex && (
<Box
style={{
position: "absolute",
top: 21,
left: "50%",
width: "50%",
height: 3,
borderRadius: 2,
background: railColor(rightActive),
}}
/>
)}
{/* train marker hovering over the current node */}
{isCurrent && (
<Box
style={{
position: "absolute",
top: -8,
left: "50%",
transform: "translateX(-50%)",
color: freightBrand.primaryDark,
}}
>
<Train size={18} />
</Box>
)}
{/* node */}
<Box
style={{
position: "absolute",
top: 12,
left: "50%",
transform: "translateX(-50%)",
width: 22,
height: 22,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: passed ? PASSED : "white",
border: `2px solid ${
passed
? PASSED
: isNext
? freightBrand.primaryLight
: "var(--mantine-color-gray-4)"
}`,
boxShadow: isCurrent ? `0 0 0 4px ${freightBrand.ring}` : "none",
color: "white",
zIndex: 1,
}}
>
{passed ? (
<Check size={13} />
) : isFinal ? (
<Flag size={12} color="var(--mantine-color-gray-5)" />
) : (
<MapPin size={12} color="var(--mantine-color-gray-5)" />
)}
</Box>
</Box>
{/* label */}
<Stack gap={0} align="center" style={{ minWidth: 0, padding: "0 6px" }}>
<Text
size="xs"
fw={passed ? 700 : 600}
ta="center"
lineClamp={2}
c={passed ? "green.8" : "dimmed"}
>
{station.label}
</Text>
{index === 0 ? (
<Badge size="xs" variant="light" color="gray" radius="sm">
Origin
</Badge>
) : isFinal ? (
<Badge size="xs" variant="light" color="gray" radius="sm">
Destination
</Badge>
) : null}
</Stack>
{/* checkpoint time or action */}
{checkpoint ? (
<Text size="10px" c="dimmed" ta="center">
{new Date(checkpoint.occurredAt).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</Text>
) : isNext ? (
<Button
size="compact-xs"
radius="md"
color={isFinal ? "teal" : "green"}
variant={isFinal ? "filled" : "light"}
loading={loggingSeq === station.sequenceNo}
onClick={() => onLogCheckpoint?.(station.sequenceNo)}
>
{isFinal ? "Mark arrived" : "Log pass"}
</Button>
) : (
<Box style={{ height: 22 }} />
)}
</Stack>
</Fragment>
);
})}
</Group>
</Box>
);
}