Files
edr-platform/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx
2026-06-22 08:11:11 +00:00

1000 lines
33 KiB
TypeScript

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